diff --git a/.gitignore b/.gitignore index b223a2d6..cbcc5add 100644 --- a/.gitignore +++ b/.gitignore @@ -64,7 +64,6 @@ tmp/ # Disposable dotnet test/build output redirected by local validation runs .test-out/ -**/TestResults/ # Connected-gate, benchmark, and visual-capture artifacts are machine-local logs/ diff --git a/AGENTS.md b/AGENTS.md index 5ac7376d..dd7f5549 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,194 +132,8 @@ user-accepted, including exact response flags, independent examination window, inscription transaction, complete creature/item/spell reports, favorite-spell press/right-click behavior, modern scarab/prismatic formula, DAT component icons, foreground stacking, and authored 310 x 400 extent. -Slice 4 equipped-child world picking passed its two-client Coldeve gate and -was user-accepted 2026-07-29. **Slices 5 and 6 (the complete vendor -experience — browse, staged buying, selling, walk-to-use, the authored -panel) closed user-accepted 2026-08-08; the six-slice program is COMPLETE -(see the plan's PROGRAM CLOSEOUT). The vendor arc also exposed and fixed -two latent client-wide crashers (#348 cursor-handle exhaustion, #350 -render-ledger overflow).** **Campaign P — physics retail-feel parity -(`docs/plans/2026-07-29-physics-parity-campaign.md`) is CLOSED 2026-07-31 -— final user matrix accepted.** Every physics-scope gap from the -2026-07-29 audit landed and user-gated: #266 run speed (retail's ==800 -sentinel — ACE's >=800 is a misread; never re-import), the #265/#166 -landing-momentum + bounce family -(`docs/research/2026-07-30-landing-bounce-family.md`), the #267 vitae -panel, #268 (panel colors + augmentation bonuses), #269 (slope-stop slide -— the live-trace contact-plane-restore fix), and TS-8 (0x02C2 StatMod -parse). See the plan doc for the retired-row ledger. **Campaign A — audio -retail parity (`docs/plans/2026-08-08-audio-parity-campaign.md`) is -CODE-COMPLETE 2026-08-08** with slices A1–A6 landed and listening-gate -rounds user-driven; open tail: #358 (Ctrl+M mute chord never fires) and -the formal plan-status flip. **Campaign CH — chat & interface-text retail -parity (`docs/plans/2026-08-09-chat-parity-campaign.md`) is CLOSED -USER-ACCEPTED 2026-08-10** after five connected gate rounds: retail -colors, the SpewBox with retail's two-plane glyph outlines, working side -channels, the 152-verb command registry, the CH6 window shell (floating -windows, all-corner resize, opacity), and verbatim /help. Carried tail: -#360/#361, #366, #369, AP-177/190/191, and the round-5 review S1–S3 -polish items. **Campaign OP — the retail four-tab Options panel -(`docs/plans/2026-08-10-options-panel-campaign.md`) is CODE-COMPLETE -2026-08-11.** Retail's Options panel (Gameplay Options / Character / Chat / -Config, LayoutDesc `0x2100002B`) plus the Configure Keyboard screen are -acdream's ONE in-client settings surface (design D1): F11/toolbar open the -authored tab host; `RuntimeCharacterOptionsState` + the 53-id -`CharacterOptionTable` own option storage; retail's wire split ships exactly -(21 auto-save ids → `0x0005` immediate, the rest ride the real `0x01A1` -PlayerModule blob with Apply/logout/480 s flushes, header always `0x460`); -headless bots declare options by name (OP7's live bot-vs-ACE gate PASSED); -OP9 retired the dead F11 `SettingsPanel`/`SettingsVM` surface and the -`GameplaySettings` record outright. OP1/OP2/OP7/OP9 CLOSED through dual/ -combined Opus review. **2026-08-14 re-gate round:** the whole gate-4 fix -batch (#372 both halves, #374, #375, #378–#382, #385) is USER-PASSED; the -OP8 first look filed + same-day-fixed #394/#395/#396 (authored 18px-serif -row-caption font; the retail `GetNameFromKey` key-name pipeline — DAT -tables `0x2300000A`/`0x2300000B`/`0x23000007` via GetDIDByEnum category 4, -OS-localized fallback, register AD-96; the `InitiateBinding` capture- -instruction WAIT dialog) plus the WaitDialog-type-0x19 crash (`2a81e813`, -live-verified no-crash). **STILL OWED: the full §OP3–§OP6 script sections -and §OP8's visual re-check** — script -`docs/research/2026-08-11-campaign-op-test-script.md`, launch with -`ACDREAM_RETAIL_UI=1`. Tail: -#371, #373, AP-198/199/201/202/203. START at -`claude-memory/project_settings_options_digest.md`. - -**Campaign FA — the retail social panel (Fellowship & Allegiance) -(`docs/plans/2026-08-11-fellowship-allegiance-campaign.md`) is -CODE-COMPLETE 2026-08-12.** Retail authors ONE four-tab `gmPanelUI` social -panel (Friends / Allegiance / Fellowship / Squelch, host slot -`0x1000018F`, id 12; F3 = Allegiance, F4 = Fellowship, keyboard-only — -Allegiance is the authored DEFAULT tab), mounted with the OP3 Options-panel -recipe. The Fellowship and Allegiance pages are LIVE end-to-end: real wire -(FA1 repaired the never-called H.2 builders + parsers — retail's FOUR -tree-rejection rules, ELEVEN version gates, the byte-decoded `>=9` size and -the truncated XP-share table), two session-scoped Runtime owners -(`RuntimeFellowshipState`/`RuntimeAllegianceState`, both clear at -generation reset — D2 corrected), and the authored panels through -`LayoutImporter`. Friends/Squelch bind read-only to J4.1's owners. -**The fellowship two-session flow is PROVEN over the live wire** — FA6's -automated bot-vs-ACE gate (`testaccount`/`+Acdream` + `testaccount2`/ -`+Horan`) passed: the recruited bot's OWN `RuntimeFellowshipState` flips -`IsInFellowship`. Six FA slices, each dual-lens Opus reviewed → fix round → -narrow re-review; the reviews caught what tests can't (retail's 4th tree -rule, the D2 reset-lifetime inversion, the D6 server-side invite filter, -a seam-map entry that would have re-introduced a fixed bug). OWED: the -user's connected gates (§FA3-§FA6 of -`docs/research/2026-08-12-campaign-fa-test-script.md`, several -`[TWO-CLIENT]`), and **#384** — the allegiance-swear bot gate is -deferred/disabled because ACE returns NOTHING to the `0x001D` swear at -0.005 m (no confirmation, no tree update, no error; needs ACE-console -disambiguation — the swear CODE is done+reviewed, only its automated -two-session proof is unverified; register AD-87). Tail: #383 (installed- -DAT vs committed-fixture drift, found at FA3). START at -`claude-memory/project_fellowship_allegiance_campaign.md`. - -**2026-08-13/14 gate block — SOCIAL GATES + SECURE TRADE all -USER-PASSED.** The social panel's connected gate rounds closed (border-only -move cursor, amber row selection, wrapped empty-state text, composed -confirmation sentences via the new `DatStringResolver.ResolveTemplate` -StringTable-interleave port, the refused-drop SpewBox notice via the -`InventoryTransactionState.RequestFailed` seam, live friends -Online/Offline through the authored row state machine + the new UiText -per-state string swap). Same block: powerbar mode captions -(jump 'Height' right-aligned per-STATE justify / 'Power'↔'Accuracy' by -combat mode), release-edge airborne jump refusal (supersedes CH round-1's -press-edge report), and **SECURE TRADE SHIPPED + two-client user gate -PASSED 2026-08-14** — gmSecureTradeUI window (LayoutDesc `0x2100000D`), -full `0x1F6`–`0x208` wire, `RuntimeTradeState` as the third sibling -J-owner, both retail open paths, staged-item trading marker -(`ClientObject.TradeState` now live), cancel text. START at -`claude-memory/project_secure_trade.md`; the deferred-Func lesson is -`claude-memory/feedback_resolve_deferred_funcs_per_call.md`. Register: -AD-93/AD-94 filed, AD-85 narrowed, AD-81 amended, AD-89/AD-95 retired. -Filed: #393 (texture-detail options, post-M4). - -**Campaign LA — the alpha launcher (ACTIVE 2026-08-14):** Avalonia -launcher/installer/updater (Windows+Linux) + the retail character- -management screen, driven autonomously under a user-set goal: Fable -plans, Sonnet implements, Opus dual-lens reviews (architectural + -retail-faithful). Spec: -`docs/superpowers/specs/2026-08-14-launcher-campaign-design.md`; plan + -ledger: `docs/plans/2026-08-14-launcher-campaign.md`; START at -`claude-memory/project_launcher_direction.md`. Key recon corrections -already binding: retail's select screen (`gmCharacterManagementUI`) has -NO 3D preview (chargen-only machinery); UI Studio no longer exists -(deleted at Campaign V — ignore stale memory/docs claims otherwise); -App `Program.cs` has no subcommand dispatch (the `--session-config` flag -is additive). -LA0 through LA11's automated scope are review-closed. The launcher composer is now -compiled into both host test suites, and Launcher.Core runs in the portable -Windows/Ubuntu CI closure. The self-contained Avalonia launcher, -transactional two-host plugin lifetime, shared login-command route, -Runtime-owned retail selection state, authored DAT character screen, and -crash-safe verified installer plus atomic cross-platform updater/self-updater -are integrated. Windows group-isolated Headless stop, isolated update fixtures, -strict status/redaction evidence, and the exact Windows/Ubuntu operator script -are landed; the integrated preflight passes 32/32 commands and 14,012 tests / -5 skips. Only the connected/visual/real-DAT user gate remains before shipment. - -**Campaign CC — retail character creation (CLOSED USER-ACCEPTED -2026-08-16).** All seven slices REVIEW-CLOSED; the connected gate ran as -one extended round (findings GF-1..16 + re-tests R2/R3/R4, fix batches -A-G + closeout + two re-test rounds, final build `1.0.2-cc.o`) and -PASSED. **Milestone: the first live character ever created by acdream -against ACE landed mid-round.** The gate round's own harvest hardened -shared surfaces well beyond chargen: authored text margins (P0x23-26), -the authored Unselected/Selected state pair + per-state label color, -un-consumed Type-12 media children (frames/scrollbars client-wide), -single-sprite scrollbar thumbs, UiButton/UiDatElement Tint, the -dialog-always-on-top re-raise (the invisible-modal input blackhole), a -truthful client crash self-report + bounded stderr capture (#405-#407 -fixed, #406 fixed; #408/#409/#410 filed for their own rounds). The full retail creation flow: Create -button (retail's exact `UpdateButtons` roster` — live capture of every player-side + `PhysicsEngine.ResolveWithTransition` call. Each call appends one + JSON Lines record with full inputs, PhysicsBody snapshot before AND + after, plus the `ResolveResult`. Filtered to `IsPlayer` mover flag + — NPC / remote DR calls don't pollute. Pairs with the trajectory + replay harness comparison tests to diff captured vs harness state + per field — the first divergence pinpoints missing apparatus state. + Capture is OFF when the env var is unset (one null-check cost per + call). +- `ACDREAM_DUMP_CELLS=` / `ACDREAM_DUMP_GFXOBJS=` — dump + resolved cell/GfxObj polygon tables as JSON when ids cache. Used + for harness fixture extraction. ### Outbound motion wire format (acdream → ACE) @@ -1645,8 +1478,8 @@ already-running ACE session via the handshake race. ## Reference repos: cross-check the relevant ones -The `references/` tree holds **five** vendored projects (ACE, ACViewer, -WorldBuilder, Chorizite.ACProtocol, holtburger). They overlap in +The `references/` tree holds **six** vendored projects (ACE, ACViewer, +WorldBuilder, Chorizite.ACProtocol, holtburger, AC2D). They overlap in some areas and disagree in others. Before committing to an approach, **cross-reference at least two of them** for the domain you're working in — the per-domain hierarchy in the next section tells you which to @@ -1655,7 +1488,7 @@ the relevant references is almost always the truth. The user has repeatedly had to remind me about this when I narrowly searched one ref and missed obvious answers in another. -The five references: +The six references: - **`references/ACE/`** — ACEmulator server. Authority on the wire protocol (packet framing, ISAAC, game message opcodes, serialization @@ -1705,15 +1538,15 @@ The five references: the message-builder layer. ACE shows what the server expects; holtburger shows what a real client actually sends. -**AC2D is a retired reference (2026-07-29).** It was a C++ AC client demo -and the sixth entry in this list; it is no longer vendored under -`references/` and must not be re-cloned. Everything we took from it is -already written down and still stands: the terrain split formula -`FSplitNESW` (constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`, -`0x519B8F25`), the `0xF61C` movement packet layout, and the finding that a -client need not compute terrain Z itself. The historical analysis lives in -`docs/research/2026-04-12-movement-deep-dive.md`; the UI dat-id work it fed -is in `docs/research/retail-ui/`. Cite those, not the repo. +- **`references/AC2D/`** — **C++ AC client emulator.** Oldest reference, + fixed-function OpenGL, but has the **real AC terrain split formula** + (`FSplitNESW` with constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`, + `0x519B8F25`) which differs from WorldBuilder's physics-path formula. + Also has the complete `0xF61C` movement packet format with flag bits + and the `stMoveInfo` sequence counters. Key lesson from AC2D: it does + NOT do client-side terrain Z — it sends movement keys to the server + and uses the server's authoritative Z. See + `docs/research/2026-04-12-movement-deep-dive.md` for the full analysis. ### Reference hierarchy by domain @@ -1738,9 +1571,9 @@ decompiled client code and would have fixed it in minutes. | **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **WorldBuilder `EnvCellRenderManager.cs` + `PortalRenderManager.cs`** | ACME `EnvCellManager.cs` (more complete for collision); ACViewer `Physics/Common/EnvCell.cs` | WB is acdream's geometry base; ACME for collision until ported. | | **Particles / sky** (particle systems, weather, sky particles) | **WorldBuilder `SkyboxRenderManager.cs` + `ParticleEmitterRenderer.cs` + `ParticleBatcher.cs`** | retail decomp | WB is acdream's particle base. | | **Visibility / culling** (frustum, cell visibility) | **WorldBuilder `VisibilityManager.cs` + `Frustum.cs`** | — | WB. | -| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | `docs/research/named-retail/` | ACE shows the server side; holtburger shows the client side. AC2D was the second client-side cross-check here — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. | -| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | `docs/research/named-retail/` | holtburger is the most complete. AC2D was the simpler confirmed-working cross-check — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. | -| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | `docs/research/named-retail/` | AC2D `cNetwork.cpp:2592-2664` was the `0xF61C` format secondary — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`, which carries the packet layout and the terrain-split formula verbatim. | +| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. | +| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. | +| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. | | **Server expectations** (what ACE accepts/rejects, validation thresholds) | **ACE** `Source/ACE.Server/Network/` | — | Only ACE knows what the server actually validates. | | **Silk.NET / .NET 10 idioms** (GL calls, shader setup, VAO patterns) | **WorldBuilder original** | ACME (same stack) | Both use the same backend; original has cleaner isolated examples. | | **Protocol field order** (packed dwords, type prefixes, flag enums) | **Chorizite.ACProtocol** `Types/*.cs` | holtburger (cross-check) | Generated from protocol XML; has accurate field comments. | diff --git a/CLAUDE.md b/CLAUDE.md index 90acfba6..d99f6db2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1555,44 +1555,108 @@ governed by whether the previous shutdown was graceful or forced. ### Test character `+Acdream` at server guid `0x5000000A`. Starts at or near Holtburg. Has -basic stats. Run/jump skills arrive FROM the server and drive local motion -prediction (`LiveMovementStatsApplier` → `PlayerMovementController`); the -hardcoded fallbacks before the server speaks are 200 run / 300 jump. The -former `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` client-side overrides no -longer exist — see the Retired section of -[`docs/launch-options.md`](docs/launch-options.md). If you see a speed/anim -mismatch between local and observer views, check the server sync path -(`UpdateMotion.ForwardSpeed` echo via -`PlayerMovementController.ApplyServerRunRate`, or -`PlayerDescription (0x0013)`). +basic stats; `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` env vars (default +200) set the *client-side* skill value used by `PlayerWeenie.InqRunRate` +for local motion prediction. **These are NOT synced to the server** — +ACE's own character data is authoritative for broadcast motion. If you +see a speed/anim mismatch between local and observer views, the fix is +to sync the runSkill from ACE via `UpdateMotion.ForwardSpeed` echo (wired +via `PlayerMovementController.ApplyServerRunRate`) or from +`PlayerDescription (0x0013)`. ### Diagnostic env vars -Every environment variable and command-line argument the client reads — -what it does, its exact value shape, and **what else it changes about the -run** — is documented in -[`docs/launch-options.md`](docs/launch-options.md). That file is the single -source of truth for every probe we have and how to turn one on, and it is -enforced by `LaunchOptionsDocumentationTests`: a flag without a documented -row fails the build, and so does a documented row whose read site was -deleted. **Any future probe that stays in the code gets its row there in -the same commit — no exceptions.** - -The binding rules: - -- **Every probe and dump is OFF by default.** Nothing that prints, records, - or costs performance may activate without its env var explicitly set - (`=1`). The only default-on flags are retail *behaviors* wearing an - A/B off-switch (`ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`, - `ACDREAM_CAMERA_ALIGN_SLOPE`, `ACDREAM_RETAIL_CLOSE_DEGRADES` — `=0` - disables); that set is frozen by `LaunchOptionsDocumentationTests` — - never add a default-on diagnostic. -- **Read the side-effects column before any measurement.** Flags that look - inert are not: `ACDREAM_AUTOMATION_ARTIFACT_DIR` also builds a per-frame - diagnostics referee (#432), and `ACDREAM_STREAM_RADIUS` measures a - streaming window production never uses. -- **A temporary probe dies with its investigation.** Add the row when you - add the probe; delete both in the commit that fixes the issue. +- `ACDREAM_DUMP_MOTION=1` — dump every inbound `UpdateMotion` (guid, + stance, cmd, speed) + resulting `SetCycle` call. Massive for remote- + animation debugging. +- `ACDREAM_STREAM_RADIUS=N` — **legacy** streaming-radius override + (`RuntimeOptions.LegacyStreamRadius`). **Default is UNSET**, not 2: the + shipped radii come from the quality preset + (`QualityPreset.High` = NearRadius 4 / FarRadius 12, i.e. a 9×9 Near ring + inside a 25×25 Far window). When set it FORCES `NearRadius = N` and only + ever RAISES `FarRadius` (`SessionPlayerComposition.ComposeCore`), and it is + silently discarded by any later Settings `ApplyQuality` + (`RuntimeSettingsTargets.ApplyQuality` → `ReconfigureRadii`). **Leave it + unset for any measurement or gate run** — with it set you are measuring a + different window than production. Per-axis overrides + `ACDREAM_NEAR_RADIUS` / `ACDREAM_FAR_RADIUS` (`QualitySettings.WithEnvOverrides`) + are the modern spelling. +- `ACDREAM_PROBE_REVEAL_RADIUS=N` — #280 A/B measurement probe + (`StreamingDiagnostics.RevealRadiusOverride`). Forces the outdoor reveal + gate to landblock radius N instead of the derived streaming window, so the + same binary can run a route once with the pre-#280 behaviour (`=1`) and once + without. Not a user setting; not surfaced in Settings; not persisted. + Values below 1 are rejected by the parser: an outdoor acknowledgement with + `RequiredRenderRadius == 0` fails Runtime's `invalid-readiness-shape` + invariant, so `=0` would hang the route it is meant to measure. +- `ACDREAM_NO_AUDIO=1` — suppress OpenAL init for headless / driver- + broken setups. +- `ACDREAM_REMOTE_VEL_DIAG=1` — dump per-tick / per-UM remote motion + diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`, + `[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`, + `[VEL_DIAG]`, `[UPCYCLE]`). Heavy. +- `ACDREAM_PROBE_RESOLVE=1` — one `[resolve]` line per + `PhysicsEngine.ResolveWithTransition` call: input + target + output + position/cell, ok-vs-partial, grounded-in, contact-plane status, + wall normal if hit, **responsible entity guid**, env flag, walkable + polygon valid. Heavy (~30 Hz × every entity). Runtime-toggleable via + the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`. +- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per + `PlayerMovementController.CellId` change: old → new cell, world + position, reason tag (`resolver` / `teleport`). Low volume — only + fires on actual cell crossings. Runtime-toggleable via the same + DebugPanel section. +- `ACDREAM_PROBE_PUSH_BACK=1` — emits three line types per physics + tick: `[push-back]` (per `BSPQuery.AdjustSphereToPlane` call), + `[push-back-disp]` (per `BSPQuery.FindCollisions` dispatch), + `[push-back-cell]` (per `Transition.CheckOtherCells` off-cell hit). + Heavy under motion (~100–500 lines/sec). Pair with retail's cdb + breakpoint set at `tools/cdb/a6-probe.cdb` for the A6.P1 capture + protocol. Runtime-toggleable via the DebugPanel. +- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility + decisions at frame boundaries. Used to converge the U.4c flap fix + (root indoor visibility at player's cell, not eye). +- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]` + lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown), + per-armed-tick steer lines (signed gap dist, applied delta, heading + delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site. + Heavy while a pack is stuck (~60 Hz × stuck count). Converged the + #171 residuals (the deep-overlap sign pin AP-82). +- `ACDREAM_PROBE_SUPPORT=1` — **what is holding a body up, and is the + collision geometry where the visual geometry is?** (#337, TEMPORARY). + `[support]`: one line per resolve **for every body, not just the player** + (a corpse falling through geometry is the cheapest control there is on + "movement code vs geometry data"). It samples the outdoor terrain + independently at the body's own out-XY and prints the contact plane's own + height at that same XY, so `support=terrain` / `object` / `none` is a + measurement rather than an inference; `cpSrc=` names the site that wrote + the plane so provenance cross-checks the classification. Edge-eager, + throttled to 4 Hz per body, and emits every 10 cm of vertical movement. + `[geom]`: once per GfxObj near the mover — the object's physics-BSP vertex + cloud against its visual mesh AABB in the same frame, with a verdict + (`coincident` REFUTES "collision isn't where the visual is"; + `no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch` + each name a data defect). `ACDREAM_PROBE_RESOLVE` alone cannot separate + those cases — it carries no plane normal, no plane height, no terrain + sample and no provenance. +- `ACDREAM_WIRE_MESH=1` — upgrades the existing **F2** collision overlay from + a broadphase proxy cylinder to the real physics-BSP polygon edges (cyan) + beside the same objects' visual mesh boxes (magenta) and the terrain + surface (yellow). Settles "visual versus collision" by eye instead of by + log. `ACDREAM_WIRE_RADIUS=` sets the window (default 30). + TEMPORARY, with the #337 probe family. +- `ACDREAM_CAPTURE_RESOLVE=` — live capture of every player-side + `PhysicsEngine.ResolveWithTransition` call. Each call appends one + JSON Lines record with full inputs, PhysicsBody snapshot before AND + after, plus the `ResolveResult`. Filtered to `IsPlayer` mover flag + — NPC / remote DR calls don't pollute. Pairs with the trajectory + replay harness comparison tests to diff captured vs harness state + per field — the first divergence pinpoints missing apparatus state. + Capture is OFF when the env var is unset (one null-check cost per + call). +- `ACDREAM_DUMP_CELLS=` / `ACDREAM_DUMP_GFXOBJS=` — dump + resolved cell/GfxObj polygon tables as JSON when ids cache. Used + for harness fixture extraction. ### Outbound motion wire format (acdream → ACE) diff --git a/README.md b/README.md index b7e9b5fa..d9b668c2 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ missing. - A machine-local `acdream.pak` built from those DATs - A running ACE server for connected play; the examples use `127.0.0.1:9000` -- For the graphical client, a driver exposing the mandatory Vulkan - capabilities validated at startup +- For the graphical client, a driver exposing the mandatory modern OpenGL + capabilities The project does not distribute Microsoft/Turbine DAT files or derived prepared packages. @@ -111,9 +111,10 @@ dotnet build AcDream.slnx -c Release dotnet test AcDream.slnx -c Release --no-build ``` -The current CI-filtered Windows baseline is a successful Release build with -**16,151 passing tests and zero failures**; opt-in live, installed-DAT, -prepared-package, manual, timing, and platform-specific lanes run separately. +The current baseline is a successful Release build with **8,826 passing tests +and 5 intentional skips**. The build currently reports 17 test-project +warnings tracked by [`#228`](docs/ISSUES.md#228--clean-release-build-emits-17-test-project-warnings); +production compilation has zero errors. ## Prepare content @@ -126,9 +127,8 @@ dotnet run --project src\AcDream.Bake\AcDream.Bake.csproj -c Release -- ` --out "C:\Games\Asheron's Call\acdream.pak" ``` -A complete format-2 package from the standard installed DAT set is about -570 MiB (the former format-1 package was about 30 GB). It is machine-local and -must not be committed. `ACDREAM_PAK_PATH` overrides the default +A complete package is approximately 30 GB. It is machine-local and must not be +committed. `ACDREAM_PAK_PATH` overrides the default `\acdream.pak`. ## Run the graphical client diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 756713a2..fd4b9ed5 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,978 +24,9 @@ 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. -## #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. -**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. - -Running two copies of the exact isolated `app-release23` graphical artifact -against local ACE reproduced the same access violation three times. The -faulting process varied: secondary PID 13688 at 15:01:39, primary PID 15300 at -15:03:35, and fresh primary PID 31412 at 15:08:58. The last occurrence fired -while the fresh primary was still on character selection, before EnterWorld; -the already-in-world secondary survived. Windows Application Error reports -all three as `coreclr.dll` exception `0xC0000005`, fault offset `0x356d4f`. -The managed terminal stack is only: - -```text -Silk.NET.Windowing.WindowExtensions...Run -Silk.NET.Windowing.Internals.ViewImplementationBase.Run -Silk.NET.Windowing.Glfw.GlfwWindow.Run -AcDream.App.Rendering.GameWindow.Run -``` - -This is not #422's rare `0xC0000374` heap corruption during graceful process -exit: #452 happens while both graphical clients are active and reproduces -quickly. It is also not a MossTank/plugin-API, CoreCLR, Vulkan, PAK, or world- -cache failure. - -**Exact root (first-chance cdb proof):** the access violation is in packaged -GLFW's Win32 event pump at `glfw3+0x10681`, not in CoreCLR. During its modifier- -key repair pass `_glfwPollEventsWin32` calls `GetActiveWindow`, then -`GetPropW(hwnd, L"GLFW")`, and dereferences the returned value as this -process's `_GLFWwindow*`. Windows UI automation temporarily joins input queues, -so the primary process can receive the secondary process's HWND. Because every -GLFW process uses the same `GLFW` property name, `GetPropW` succeeds but returns -the secondary process's private pointer. The crashed primary had -`rbx=00000202a7180ab0`; a debugger breakpoint in the surviving secondary -reported its own valid `ACTIVE_GLFW_WINDOW=00000202a7180ab0` — exact pointer -identity across the process boundary. - -**Fix:** `Win32GlfwActiveWindowGuard` patches only `glfw3.dll`'s import-address- -table slot for `USER32!GetActiveWindow`, after the GLFW library is loaded and -before `glfwInit`/window creation. The replacement returns the real HWND only -when `GetWindowThreadProcessId` says it belongs to the current process; -otherwise it returns zero, GLFW's existing safe "nothing to repair" branch. -There is no system-wide hook and no other module is changed. Four focused -tests cover local, foreign, null and unowned HWNDs. - -The isolated `app-release24` live gate launched two graphical clients, both -logged `GLFW foreign-active-window guard installed (#452)`, entered the world, -and remained responsive through 100 rapid forced cross-process activation -switches — the exact prior trigger — plus a 30-minute combined in-world soak. -The local peer API then passed in both directions: the secondary evaluated the -primary heartbeat and returned `+Acdream`. A two-member fellowship gate also -passed with both canonical rosters populated and the secondary returning `2` -from `getfellowshipcount[]`; both processes remained alive and responsive. - -Evidence: - -- `artifacts/live-gates/mosstank-final23-secondary/` -- `artifacts/live-gates/mosstank-final23-secondary2/` -- `artifacts/live-gates/mosstank-final23-primary5/` -- `artifacts/live-gates/i451-cdb-primary-attach/cdb.log` -- `artifacts/live-gates/i451-cdb-secondary/cdb.log` -- `artifacts/live-gates/i451-guard-primary2/` -- `artifacts/live-gates/i451-guard-secondary/` -- 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. -## #451 — Sanctuary cathedral portal seam leaks exterior world and particles - -**Status:** DONE — OWNER-ACCEPTED 2026-08-27 ("Looks good! Gate pass now!"). -**Component:** Vulkan PView / nested building look-ins / landscape alpha ordering. - -At the open-air Sanctuary cathedral seam between `0xF4180104` and -`0xF4180106`, small player or chase-camera movements could make cathedral -halves, their shadows, or floor textures flap; expose the world background, -trees, a nearby building, and waterfall/steam particles through opaque -geometry; or clip the local player in half. Camera zoom alone reproduced the -failure. The final particle repro was -`0xF4180104 [31.111177 57.648911 169.804993]`. - -This was a renderer contract failure, not bad Sanctuary data. The correction: - -- classifies exterior building seeds with retail's `F_EPSILON` instead of the - ordinary 1 cm EnvCell traversal tolerance and retains the exact accepted - `CBldPortal` aperture; -- appends every nested look-in cell's own `portal_view` to the frame clip - buffer, punches only the accepted seed portal, and clips shells plus static - and particle alpha to that view; -- pairs each look-in with only its own exterior building shell and reproduces - retail's per-building alpha barriers; -- keeps dynamic objects whole after the CPU PortalList sphere test, matching - retail `DrawMesh` and preventing the player-slicing regression; -- submits attached and ownerless exterior particles inside `LScape::draw` for - every PView root, eliminating the late post-world replay that let waterfall - alpha repaint already-drawn cathedral cells; and -- treats authored `SeenOutside` open-air cells as atmospheric outdoor cells so - sky/shadow activation no longer flips merely because the camera acquired an - EnvCell root. - -The fix is renderer-wide: no Sanctuary coordinate or asset special case is -present in production. Installed-DAT regressions retain the reported camera -handoff, lateral transition, and exact steam-seam views. The temporary live -emitter/shell trace was removed at closeout. - -**Acceptance:** owner swept both cells, moved across the seam, and zoomed the -camera through the former trigger positions. Cathedral/world flapping, shadow -toggle, player clipping, exterior geometry, and waterfall/particle bleed were -absent in the accepted build. - -## #450 — Fast character re-entry after logout can remain in portal space at `lb 0/0` - -**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. -**Component:** session reset / streaming-origin retirement / login reveal. - -After Shift+Escape logout to character selection, immediately entering the -character again could leave the client indefinitely in portal space with no -landblocks admitted. The server accepted the second entry; the client title -remained at `lb 0/0`. - -**Root cause/fix:** confirmed logout starts a frame-budgeted retirement of the -old streaming window, but the synchronous session reset ignored its incomplete -result and exposed the fresh Runtime generation. The new world then inherited -the old origin-recenter admission gate. The confirmed-logoff pump now holds the -authored tunnel until old-window retirement converges, then transfers that -completed barrier through the reset callback exactly once. A deterministic -regression proves the character-select handoff cannot execute while retirement -is incomplete and cannot begin a duplicate retirement during reset. - -**Acceptance:** Shift+Escape to character selection, immediately re-enter, and -confirm the destination begins admitting landblocks and exits portal space. -Repeat twice in one process. - -## #449 — Main backpack remains falsely full after an item slot is freed - -**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. -**Component:** inventory drag acceptance / main-pack capacity. - -With a full backpack, a move into it correctly shows the red reject cursor. -After dropping an item to free a slot, later moves from another pack could -remain rejected. - -**Root cause/fix:** main-pack fullness counted every child of the player, -including side bags, even though retail places side bags in a separate -container-selector list governed by `ContainersCapacity`. Capacity fill, -append placement, and drag acceptance now count only visible loose contents. -A regression starts with two loose items plus a side bag at capacity two, -removes one loose item, and proves the next drag changes from Reject to Accept -with a 50% capacity meter. - -**Acceptance:** fill the main pack, observe one rejected move, drop one loose -item, then move an item from a side pack into the freed main-pack slot. It must -accept immediately without reopening the inventory window. Run with the -combined gate in `docs/research/2026-08-26-combined-client-parity-gate.md`. - -## #448 — Outgoing melee hit messages expose a percentage that retail does not print - -**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. -**Component:** combat chat / AttackerNotification presentation. - -Successful outgoing melee hits currently print a percentage in chat, for -example `You hit ... for ... damage (54.0%).` The owner reports that this is -not retail behavior and that the percentage should not be shown. - -**Likely seam:** `CombatChatTranslator.HandleDamageDealt` unconditionally -appends `DamageDealt.DamagePercent`; its tests explicitly pin a template taken -from holtburger rather than the named retail client. Recover the exact -AttackerNotification presentation from the named retail decomp/string tables, -then replace the formatter and its tests. Preserve the wire value in combat -state if it has another legitimate consumer; this issue concerns chat output. - -**Acceptance:** ordinary and critical outgoing melee hit lines match retail -wording and punctuation exactly and contain no acdream-added percentage. - -## #447 — `@acecommands` produces blank lines in the chat window - -**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. -**Component:** ACE server-command responses / chat presentation. - -Running `@acecommands` against the test server produces a series of blank -chat lines instead of the command names and descriptions. The command reaches -ACE, but its multiline response loses its visible text before presentation. - -**Investigation seam:** capture the authoritative response message type and -raw payload, then trace it through the server-command/interface-text parser, -`RuntimeCommunicationState`, and retained chat markup rendering. Do not work -around the defect by printing the static `docs/reference/ace-commands.md` -copy; the live server response must render correctly. - -**Acceptance:** `@acecommands` displays every non-empty server response line -inside retail's retained transcript window with its text intact (newest -complete-line tail when the response itself exceeds the cap), produces no -blank-line spam, and does not regress normal chat or other ACE commands. - -**2026-08-26 fix:** ACE sends the complete command listing as one `0xF7E0` -`ServerMessage` containing embedded newlines. The parser and runtime route -already preserved that payload. The retained transcript budget treated the -whole message as one indivisible log entry, however, so an admin-sized reply -larger than retail's `0x2710`-character cap advanced past the only entry and -rendered nothing. `ChatTranscriptRenderer` now clips an oversized boundary -entry at a newline and keeps its newest complete lines, matching retail's -front-truncation behavior. Ordinary multiline replies below the cap render -every authored line. Parser round-trip, normal multiline, oversized response, -filter, tagged-run, and existing chat regression tests pass. Owner check is in -`docs/research/2026-08-26-combined-client-parity-gate.md`. - -## #446 — Configure Keyboard bindings need an end-to-end retail-parity pass - -**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate, -including connected behavior and persistence. -**Component:** input / Configure Keyboard / binding persistence. - -The owner reports that keyboard binding still does not work reliably or match -retail. Treat this as an end-to-end product gate rather than another isolated -layout fix: display the authored mappings, capture a replacement key or mouse -button, apply the correct retail conflict rules, make the new action fire, -and preserve it across restart. Escape cancellation, Reset/Defaults, scoped -combat bindings, modifier chords, and mouse bindings must also match retail. - -Existing issue #373 is one known concrete defect in this flow: acdream ignores -the DAT `ActionMap.ConflictingMaps` table and can erase valid shared combat -bindings. The fixes recorded under #394-#396 remain pending a complete owner -re-gate and do not establish that binding works end to end. - -**2026-08-26 implementation:** deep audit at -`docs/research/2026-08-26-retail-keyboard-routing-audit.md`. Bare Escape no -longer exits player mode or exposes the orbit/developer bird's-eye camera. It -now follows the complete proven retail ladder: finish jump charge, release -focused UI, stop movement/repeat attack, cancel target mode, clear selection, -then toggle the authored Gameplay Options page. Shift+Escape reaches the real -logout gate. - -All 306 installed ActionMap rows now have distinct live identities and enabled -Configure Keyboard rows. Exact defaults, contexts, activation, DAT conflict -policy, modifier-only and mouse capture, duplicate-chord multicast, explicit -unbinding, dense two-slot insertion, same-row no-op, unsupported-input retry, -priority conflict/non-bindable dialogs with exact DAT text, dirty-only Revert, -Apply/Defaults/OK/Cancel, schema migration, and persistence are implemented. -The complete camera, selection, missile, magic, 87-emote, screenshot/help/ -plugin, quickslot 1–18, panel/chat, and 48 CharacterSettings families reach -concrete consumers. Selection includes retail radar/combat/fellow/vendor/ -environment and session opened-corpse rules. The approved 40 m mouse-wheel -chase zoom remains unchanged and regression-pinned. - -Retail's Load File / Save As path is now live as well: the client parses and -writes the Sept-2013 PFile `.keymap` grammar under -`Documents\Asheron's Call`, remembers the selected profile, presents the -authored type-7 file menu and type-5 filename/overwrite dialogs, loads it on -startup, and rewrites it on graceful shutdown like retail. `keybinds.json` -remains a compatibility mirror for acdream-only commands. AP-202 is retired. - -Automated keyboard-impact evidence is green: App 6,413/6,413, Core -4,713/4,713, Runtime 1,849/1,849, and UI.Abstractions 879/879 (13,854 -tests total). Installed-DAT conformance pins all 306 identities, defaults, the -authored Configure Keyboard mount, and active Load/Save controls. Only the -connected gate below remains. - -**First owner-round findings fixed 2026-08-26:** modifier-only capture now -normalizes LeftShift and consistently raises the retail overwrite prompt when -Move Forward conflicts with Toggle Walk/Run. Regular Enter enters chat without -its raw event immediately submitting the new field; keypad Enter no longer -falls through to the raw chat-focus shortcut. Melee height keys now preserve -the Press→held charge→Release transaction instead of treating the first Hold -tick as release. Map mode transforms both retail's target direction and viewer -offset through the target frame, placing the eye high overhead rather than low -behind the character; the approved mouse-wheel zoom range is unchanged. -Shift+Escape's same-process relog portal stall is tracked and fixed as #450. -Focused App coverage plus the standard Release lane pass. - -**Acceptance:** a connected retail side-by-side covers representative movement, -combat, panel, modifier, and mouse mappings; every rebound action executes, -conflicts match retail, cancellation changes nothing, and applied bindings -survive a fresh client launch. - -## #445 — Stack split errors in inventory; vendor drag ignores selected quantity - -**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. -**Component:** inventory stack splitting / vendor sell staging / shared split -quantity. - -Two live paths fail after selecting a partial quantity with the stack slider: - -1. Splitting a stack within the inventory produces an error instead of moving - the selected quantity into the destination slot. -2. With a stack of 10 and the slider set to 2, dragging the stack into the - vendor window stages all 10 rather than the selected 2. - -**Expected:** the selected quantity is the single shared value consumed by -inventory split operations and by the vendor drop path; the source retains -the remainder. Capture the exact inventory error text/code during the fix -gate. - -**Investigation seam:** trace `StackSplitQuantityState` from selection/slider -changes through the inventory `SendStackableSplitToContainer` request. The -vendor path currently documents and implements full-stack sell staging in -`VendorUiController.EvaluateSellAcceptability`; compare that claim against -named retail and a retail client gate before changing it, then make the -observed behavior and documentation agree. This is distinct from #313, which -only tracks selection transfer to the newly created split result. - -**2026-08-26 fix:** named retail's enclosing -`VendorSellUI::AcceptDragObject @ 0x004C4F00` disproved the old full-stack-only -comment. A partial vendor drop now sends the exact slider quantity through the -canonical inventory transaction owner, stages the source as retail's temporary -row, and replaces that row in place when the server-created stack with matching -WCID/quantity arrives. A matching failure removes the placeholder. Ordinary -inventory splitting now uses that same owner and computes empty main-pack -placement from visible loose items, excluding side bags that live in retail's -separate selector list. Exact quantity, request lifetime, replacement order, -and side-bag placement are regression-tested. Owner check is in -`docs/research/2026-08-26-combined-client-parity-gate.md`. - -## #444 — Vendor alternate-currency balance stays stale after a successful purchase - -**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. -**Component:** vendor UI / alternate-currency purchase refresh. - -At a vendor that accepts an alternate currency (observed with Colosseum -Coins), the purchase succeeds and the server removes the currency, but the -vendor window continues to show the pre-purchase holding. Example: the purse -line says "You have 10 Colosseum Coins" before the purchase and still says 10 -afterward. The displayed holding should update immediately after the -authoritative purchase/inventory update. - -**Likely seam:** `VendorUiController.BuildPurseText` and `BuildCostText` read -the vendor-open snapshot `VendorShopProfile.AlternateCurrencyAmount` -directly. `OnObjectMoneyChanged` repaints the text, but the repainted value is -still that latched profile amount rather than the live alternate-currency -holding (or retail's `trade_num - m_last_sale` equivalent). Add a connected -regression for purchase success followed by the refreshed purse and item-cost -text; cover both the Buying tab and Items tab. - -**2026-08-26 fix:** alternate-currency displays and Buy All affordability now -prefer the authoritative sum of player-owned currency stacks. On a successful -Buy/Buy All dispatch, retail's `m_last_sale` subtraction updates the Items and -Buying/Selling purse text immediately; the next matching currency add/update/ -move/remove clears that optimistic subtraction and repaints from canonical -inventory. The vendor snapshot remains only the pre-observation fallback. -Automated coverage pins the immediate 10→8 display and the subsequent -authoritative 8→8 reconciliation. Owner check is in -`docs/research/2026-08-26-combined-client-parity-gate.md`. - -## #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 -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 -composites could still be pending when the private pass cleared and published -its target, and Vulkan's two concurrently recorded frames reused that same -offscreen image as both a color attachment and a retained-UI sampled texture. -The 2026-08-26 combined client-parity gate passed every #444–#450 row on the -same Release binary while the paperdoll remained missing, confirming #443 is -an isolated private-viewport defect rather than an inventory transaction, -input, relog, chat, combat-text, or vendor failure. -**Component:** private entity viewports (examination clone, inventory -paperdoll — shared `PrivateEntityViewportRenderer`). -**Filed:** 2026-08-25, AS-GF1 gate-fix session. **Narrowed same day at the -gate's probe round:** with `ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT=1` -(probe since DELETED per the probe-dies rule — recover it with -`git show 65f6f584` if this recurs) the live session showed the render -layer HEALTHY from the first probed frames — nonzero texture-table handle, -34 MeshRefs, sane bounds and camera eye, for both the examination clone -and the inventory paperdoll — while the owner initially saw an empty -(black) pane that later popped in ("I can see the paper doll now, after a -while"), and a subsequent fresh session showed it promptly. Leading -suspect: private-clone MESH residency/upload latency in the shared arena -(the pass records draws only for resident meshes; the probe cannot see -residency). The gate's OTHER symptom (the "black rectangle" band over the -extras rows at small window heights) is the authored scroll-less -clipped-list behavior AS-GF1 ruled retail-correct below, plus the clip -line moving with resize — owner-accepted at the gate. - -**Fix:** `PrivateEntityViewportRenderer.EntitySlot` now uses a two-phase -mesh-residency handoff. A replacement first acquires/pins its complete mesh -ownership set, remains pending while each drawable `MeshRef` crosses the -render-thread upload barrier, then atomically replaces the active entity and -texture-owner generation. While a replacement is pending the renderer keeps -the last completed viewport texture; on first open it publishes no texture so -the authored panel art remains visible instead of exposing a black-cleared -render target. The slot also detects GfxObj-id changes made in place by the -animated appraisal/chargen paths and supersedes stale pending owners without -leaking references. Focused paperdoll, appraisal, draw-order, synthetic-owner, -and new residency tests pass 30/30; the App hermetic lane passes 6,358/6,358. -The owner then live-verified repeated inventory and monster/player assessment -opens against the local ACE test server: "Good. works." - -**2026-08-26 recurrence fix:** the shared renderer now advances and gates the -complete private-entity resource set — mesh upload plus original, palette and -clothing-composite textures — before allocating, clearing, or publishing a -new viewport target. It therefore keeps the previous completed image (or the -authored panel art on first use) until the new doll is actually drawable. -`PaperdollFramePresenter` also builds, redresses and prewarms the inventory -doll while its tab is hidden, so opening the tab no longer starts residency -work from zero. The decisive intermittent fault was the shared render target: -one Vulkan flight slot could clear/write it while the other still sampled it. -`PrivateEntityViewportRenderer` now owns a bounded target, sampler and texture -slot per encountered GPU flight slot, and publishes the current frame's exact -handle. The same correction covers inventory paperdoll, creature appraisal and -character-creation preview viewports. Temporary flight-slot colors proved both -slots render the complete textured doll; all probes were then removed. The -clean Release client passed first open plus two repeated close/reopen cycles on -the local ACE server with no missing frame and no runtime error. Focused App, -Runtime and input tests pass 384/384, including the byte-exact production -SPIR-V oracle; the Release solution builds with zero warnings/errors. - -Owner report at the Campaign AS connected gate: the animated 3-D paperdoll -in the examination window (LayoutDesc `0x2100006B` element `0x10000148`) -worked correctly at baseline `974fe88a` (praised the same session) and was -gone by `87e98395` (ten commits later, AS2-AS5). The same gate also -reported "a reserved black rectangle at the window's bottom" and mid-row -clipping in the character extras list at the window's default (minimum -310x400) size, fixed by dragging the window taller. - -**Exhaustive investigation (this session) found NO code bug in the -Campaign AS diff for either symptom:** - -- Every file the AS2-AS5 window touches - (`AppraisalUiController.cs`, `RetailUiRuntime.cs`, - `CreatureAppraisalRows.cs`, `AllegianceRankTitleTable.cs` (new), - `CharacterIdentityText.cs`, `CharacterSheetProvider.cs`, - `InteractionRetainedUiComposition.cs`, plus two unrelated - mechanical `PublicWeenieFlags`-literal refactors) was read in full - against the pre-Campaign-AS baseline. -- A new hermetic regression test, - `AppraisalUiControllerTests.CharacterResponse_WorstCaseExtrasCombination_DoesNotThrowAndViewportGateStaysOpen`, - applies EVERY AS3+AS4 extras-list addition at once (armor-level trio, - society, allegiance cascade, ratings, all seven configurable extras) — - the combination none of the individual AS3/AS4 tests exercise together — - through the REAL DAT-derived examination layout and REAL row templates. - It proves `Apply`/`ApplyCreature`/`RebuildCreatureStats`/`BuildExtra` - never throw and always leave `ActiveView == Character`, - `CurrentObjectId != 0`, and the viewport's full ancestor-visibility - chain (`creaturePanel` → root) `Visible == true`, even in this worst - case. `RetailCreatureAppraisalFrameView.TryGetVisibleTarget`'s first - three gates (ActiveView, windowFrame visible, viewport visible) are - therefore unaffected. -- The SAME test also proves the extras list's clip-then-wheel-scroll - behavior is correct and unaffected: `UiItemList.OnEvent`'s - `UiEventType.Scroll` handler (pre-existing, unmodified) moves the - shared `Scroll` offset, and the next `LayoutCells()` pass reveals every - row, including the very last one of the 20-row / 400px worst case - against the DAT-authored 87px region. Retail's own LayoutDesc authors - NO scrollbar for this listbox either (`ScrollbarElementId == 0`, - verified against BOTH the committed fixture `tests/AcDream.App.Tests/ - UI/Layout/fixtures/examine_2100006B_100005F2.json` and a fresh - `tools/LayoutDump` read of the live installed DAT — no drift). A - scrollbar-less, wheel-scrollable list clipped to its authored region - until the user scrolls or resizes IS retail's actual, already-correctly- - ported mechanism — not a regression. -- `src/AcDream.App/UI/UiViewport.cs:52` draws NOTHING (not black) when its - `TextureSlot` is unassigned (`if (!Visible || !TextureSlot.IsAssigned) - return;`). The creaturePanel's own full-panel backdrop - (`0x10000141`, DID `0x06004CC2`, ZLevel 100 — the furthest-back layer, - spanning the panel's whole `300x365` rect) is what shows through - wherever nothing else paints over it. This is the most likely explanation - for BOTH the missing paperdoll AND the "black rectangle": if the - viewport's `TextureSlot` never gets assigned, this backdrop is what the - owner is actually seeing, and it is genuinely the SAME defect wearing - two descriptions, not two. -- `CreatureAppraisalPresentation.cs` and `LivePresentationComposition.cs` - (the entire render-time viewport pipeline: `TryGetVisibleTarget`'s - fourth gate `CurrentObjectId`, `TrySynchronize`'s live-entity/mesh - lookup, and the dispatcher/composition gate that constructs the - presenter at all) are byte-for-byte UNCHANGED across the whole - `974fe88a..87e98395` window (`git log -p` for both files is empty). - -**Conclusion after live recurrence:** the temporary probe ruled out every -higher-level gate: the target, clone, 34 MeshRefs, camera and nonzero texture -handle were all healthy while the pane was visibly empty. The shared slot had -published the clone immediately after `IncrementRefCount`, but that operation -only schedules asynchronous preparation/upload. The private pass then cleared -its target to black while `WbDrawDispatcher` skipped every nonresident mesh. -Residency/backlog timing explains both intermittent first-open delay and the -same symptom across inventory and monster/player examination. The probe was -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. -**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). - -A `GC.GetAllocatedBytesForCurrentThread()` zero-allocation assertion in -`tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs` -trips intermittently under parallel load — same class as the known -`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate` -flake (full-solution parallel load only) and #439's headroom family. -Review isolation evidence (Release): alone (15-test class) passes; App -hermetic lane run 1 = 6,252/1 failed, run 2 (same command) = 6,253/0. -Last touched by `d78ce100` (a render commit). Candidate fixes when picked -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. - -## #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). -**Component:** reveal generation / death teleport / entity hydration - -Owner observed twice (before probes were armed), then could NOT reproduce in a -probed session the same day: - -1. **Run 1:** died, and the death teleport left the client **stuck in portal - space** (the authored tunnel never released). -2. **Run 2:** arrived at the lifestone while the world was **not ready** — - "missing doors, portals and stuff". Note: doors and portal objects are - server-spawned WEENIES, not dat-baked statics - (`claude-memory/feedback_weenie_vs_static.md`) — so this symptom may be - **entity hydration lagging the viewport reveal** (CreateObject stream not - yet drained when the reveal completed), not a composites/terrain gap. - Possibly related to #433 (stale entities near Holtburg after /ls-style - teleports — the flush direction of the same coin). - -**Owner's repro protocol (worked twice pre-probe, then went cold):** log in, -position far from the lifestone, log off, log on again, get killed. The -fresh-login-then-die sequence appears to matter. - -**Probe recipe (armed and documented, ready for the next occurrence):** launch -with `ACDREAM_PROBE_REVEAL=1` + `ACDREAM_PROBE_REVEAL_TIMING=1` and keep the -log. The always-on `[world-reveal]` lines plus `[reveal-timing]` per-edge -attribution distinguish the two shapes directly: symptom 1 = a readiness edge -that never arrives for the death generation; symptom 2 = `complete` firing -with the object stream still behind. - -**Healthy baseline (2026-08-24, `launch-death-repro-3.log`):** login gen and -two portal gens all converge; readiness edges complete in 83-1400 ms; -`totalMs` ≈ 5.5-5.7 s of which the balance after `materialized` is the -authored portal-tunnel presentation, not a stall. - -## #440 — CLOSED: Trained skill did not move to its section (and raise buttons never visually un-ghosted) until the next click - -**Status:** CLOSED 2026-08-24, found by the owner during the CA5 drive. -**Component:** character panel / row refresh - -**Symptom (owner):** training an untrained skill consumed credits (visible -immediately) and the confirmation text appeared, but the skill stayed in -the untrained section; clicking Train AGAIN (which the server rejects — -"Failed to train", no credit change) made it move. - -**Root cause:** the panel's sheet-changed subscription only refreshed the -captured `currentSheet` — per-frame text pulls (credits, values) updated, -but the ROW STRUCTURE (section buckets, selection, raise-button states) -rebuilt only on clicks (`RefreshAfterRaise` ran as the raise's `completed` -callback — synchronously after SEND, before the server's answer). The -second click was simply the first row rebuild after the record landed. -The same gap kept CA4's awaiting-ghost from visually releasing. - -**Fix:** `CharacterStatController.Bind` now returns the data-changed -refresh (`RefreshAfterRaise(null)` — rebuild + reselect + re-evaluate -buttons), and `RetailUiRuntime.MountCharacter`'s subscription invokes it on -every authoritative sheet change — mirroring retail's quality-change -broadcast (`InfoRegion::OnQualityChanged @ 0x004F0EB0` → -`ListenToElementMessage @ 0x004EFBE0`). Pinned by -`DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick`. - ---- - -## #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) -**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 - -**Symptom:** `LossyTransportDecoratorTests.LossySession_FivePercentSeeded_ZeroMessageLoss_Headroom256` -failed once under the full parallel hermetic suite with `Headroom expected 256, actual 0` -— i.e. the seeded loss-recovery simulation ended before the crypto search -window recovered. Passed in isolation immediately after, and the full -suite passed clean on re-run. The test drives a deadline loop over a -seeded end-to-end loss simulation — the load-sensitive shape -`Lane=Timing` exists for, but this test is not marked. Candidate fix: -either mark it `Lane=Timing` or make its quiescence wait -deadline-independent. Decide deliberately; do not just re-run until -green. - ---- - -## #438 — Launcher crash-report bundles (WER dump capture + local bundle, no upload) - -**Status:** OPEN — designed, ready to pick up as a launcher slice -**Severity:** ENHANCEMENT (post-current-queue; owner-approved as upcoming -work 2026-08-24) -**Component:** launcher / crash diagnostics - -**Problem:** the launcher already records a truthful -`exited{reason:"crashed", code}` with bounded stderr (#405–#407), but for -native fail-fasts — #422's `0xC0000374` heap corruption, three sightings, -zero stacks — no evidence exists anywhere: fail-fast bypasses in-process -exception filters BY DESIGN, so .NET's own `DOTNET_DbgEnableMiniDump` -crash handler never fires for this class. Windows' WER LocalDumps is the -OS-level catcher that still does. Today an alpha user can say "it -crashed" but never why. - -**Design (agreed with the owner):** -1. **Launcher owns the WER key, with consent.** First-run opt-in ("save - crash dumps locally") writes - `HKCU\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\AcDream.App.exe` - (HKCU — no elevation): `DumpFolder` inside the launcher's data dir, - `DumpCount` small (e.g. 3), `DumpType=1` (**minidump**, not full — a - minidump carries the faulting stack #422 needs without carrying the - process's whole memory). Uninstall removes the key. -2. **Bundle on next launch.** The launcher already detects the crash exit; - it gathers minidump + client log tail + version + the Vulkan capability - report into one folder and shows "the client crashed last time — the - report is here" with an open-folder button. -3. **NO auto-upload — this is the deliberate line.** Dumps can contain - account names and the plaintext session password in memory; there is no - upload endpoint and no privacy story. The user choosing to send the - folder is consent by construction. Revisit only with real - infrastructure and an explicit consent flow. -4. **Linux:** set `DOTNET_DbgEnableMiniDump=1` / `DOTNET_DbgMiniDumpType` - from the launcher for the non-fail-fast classes; system `core_pattern` - is out of scope. - -**Prior art / constraints:** the project's stance that the CLIENT never -writes registry keys stands — this is launcher-owned, opt-in, per-user. -The owner's dev machine already has the key armed manually (2026-08-24) -for the #422 hunt; this slice productizes that for alpha users, whose -crashes are otherwise invisible telemetry. - ---- - -## #435 — CLOSED: Probe debt: temporary probes outlived their closed investigations or named no owner - -**Status:** CLOSED 2026-08-24 across two passes (`0c5057c9` part 1, -`c1e6e3da` part 2 + the `35454a9f`-adjacent doc correction): 24 probes -deleted, 8 reclassified as standing tools, 1 deleted-and-restored -(`ACDREAM_DUMP_MOVE_TRUTH`, #437 — the canonical soak hard-gates on it). -End state: 137 flags (from 161), 31 temporary probes, every one attributed -to an owning issue or campaign. The default-off invariant is frozen by -`LaunchOptionsDocumentationTests.OnlyTheFourRetailBehaviorFlagsDefaultOn`. -Part-1 record follows. - -**Status (part 1):** The 17 orphaned probes from part 1 are DELETED (2026-08-24) — -3,493 lines removed, flag count 161 → 144, temporary probes 64 → 47. Build -clean; full hermetic suite 15,321 passed / 0 failed (baseline 15,333 minus -the 12 tests whose only subject was a deleted probe). Four files went -entirely: `WalkMissDiagnostic.cs`, `CollisionMeshWireframe.cs` and two -probe-only test files. `LaunchOptionsDocumentationTests` did its job during -the cleanup — it refused the deletion until the doc's rows moved to Retired -and the frozen direct-read counts were lowered (`PhysicsEngine.cs` to zero, -`TransitionTypes.cs` 3 → 2). - -Notable: `TransitionTypes.SetContactPlane` shed its `CallerMemberName` / -`CallerLineNumber` parameters, which existed only for #337's `cpSrc=` -attribution and were explicitly marked "strip with the probe family". No -call site passed them, so no behavior changed. F2's collision overlay -survives and reverts to the proxy-cylinder form, as intended when -`ACDREAM_WIRE_MESH` went. - -**Part 2 (2026-08-24):** traced each of the 14 then-unattributed rows to -its introducing commit and confirmed 8 belonged to closed investigations — -`ACDREAM_A8_DUMP_PV` (Phase A8.F, closed), `ACDREAM_DUMP_CLOTHING` (#37, -DONE 2026-05-11), `ACDREAM_DUMP_EDGE_SLIDE` (#32, CLOSED 2026-08-07), -`ACDREAM_DUMP_LIVE_SPAWNS` (Phase A8, closed), `ACDREAM_DUMP_MOVE_TRUTH` -(#30/#34, DONE 2026-04-29), `ACDREAM_DUMP_STEPUP` (L.2.3d/e/f, closed), -`ACDREAM_DUMP_VENDOR` (vendor campaign, closed 2026-08-08), -`ACDREAM_DUMP_VITALS` (#5, DONE 2026-04-25). All 8 deleted along with their -call sites (`~130` net lines across 21 files), plus `VendorDiagnostics.cs` -(the `ACDREAM_DUMP_VENDOR` owner class, now fully unreferenced) and -`MovementTruthDiagnosticController`'s internals (kept as a permanent -no-op `IMovementTruthDiagnosticSink` implementation — `PlayerModeController`, -`GameWindow`, `LiveEntityNetworkUpdateController`, and -`SessionPlayerComposition` all still construct/wire it, so the DI graph is -unchanged). `docs/launch-options.md` rows moved to Retired; -`LaunchOptionsDocumentationTests`' `DirectReadDebt` lowered for -`PortalVisibilityBuilder.cs` (1→0, dropped), `GameEventWiring.cs` (1→0, -dropped), `PlayerDescriptionParser.cs` (1→0, dropped), -`TransitionTypes.cs` (2→0, dropped), `WorldSession.cs` (3→2). Build clean, -0 warnings; full filtered suite 15,315 passed / 0 failed / 0 skipped (no -`[Fact]`/`[Theory]` was removed — the small delta from part 1's 15,321 -baseline is pre-existing run-to-run count variance, not a test loss). - -**Found during part 2, not fixed (separate from #435's scope):** see #437 -— `tools/run-connected-r6-soak.ps1` sets `ACDREAM_DUMP_MOVE_TRUTH=1` and -greps its own log for `move-truth OUT` lines as part of its automated -movement-verification gate. That signal is now permanently dead (the flag -is a no-op), but the ps1's own text is unchanged so it and its pinning -contract test (`ConnectedWorldSoakRouteContractTests. -LaunchConfigurationIsDisclosedToTheArtifactDirectoryBeforeLaunch`) both -still pass — they assert the ps1's *text*, not that the mechanism it -describes still works. - -**STILL OPEN — the 6 remaining unattributed probes** (`ACDREAM_DUMP_CELLS_DIR`, -`ACDREAM_DUMP_GFXOBJS_DIR`, `ACDREAM_DUMP_SKY`, `ACDREAM_DUMP_STEEP_ROOF`, -`ACDREAM_HIDE_PART`, `ACDREAM_PROBE_CELL` — all deliberately left alone -this pass per the #435 part-2 scope). They name no owning issue, so nothing -records when they are safe to remove. Deleting them on a guess is how a -future investigation loses apparatus it needed. The right next step is -attribution, not deletion: for each, find the commit that introduced it -(`git log -S ACDREAM_PROBE_X`), record the issue in its -`docs/launch-options.md` row, and only then decide. Deliberately deferred. - -**Original report follows.** - -**Status (original):** OPEN -**Severity:** LOW (no runtime defect; hot-path clutter and measurement noise) -**Filed:** 2026-08-24 (measured during the launch-options audit) -**Component:** diagnostics ownership - -**Measurement (2026-08-24, `docs/launch-options.md`):** the client reads 161 -`ACDREAM_*` variables. 64 are temporary probes. They cite 21 distinct -issues, of which **14 are already closed** — 17 probe rows are apparatus -whose investigation ended without the strip: - -| Closed issue | Probes that outlived it | -|---|---| -| #337 | `ACDREAM_PROBE_SUPPORT`, `ACDREAM_WIRE_MESH`, `ACDREAM_WIRE_RADIUS` | -| #119 | `ACDREAM_DUMP_ENTITY`, `ACDREAM_PROBE_VIEWER` | -| #32 | `ACDREAM_PROBE_REMOTE_LANDING` | -| #42 | `ACDREAM_AIRBORNE_DIAG` | -| #63 | `ACDREAM_PROBE_AUTOWALK` | -| #78 | `ACDREAM_PROBE_SHELL` | -| #83 | `ACDREAM_PROBE_WALK_MISS` | -| #105 | `ACDREAM_PROBE_TEXFLUSH` | -| #113 | `ACDREAM_PROBE_PHANTOM` | -| #131 | `ACDREAM_PROBE_OUTSTAGE` | -| #133 | `ACDREAM_PROBE_LIGHT` | -| #171 | `ACDREAM_PROBE_STICKY` | -| #334 | `ACDREAM_PROBE_REACH` | -| #338 | `ACDREAM_PROBE_STEP_HEIGHTS` | - -A further **14 temporary rows name no owning issue at all**, which is worse: -nothing records when they become safe to delete. - -**Why it matters:** each probe leaves a branch on its hot path even unset, -several re-read the environment per call rather than caching -(`ACDREAM_WB_DIAG` on every `Draw()`, `ACDREAM_DUMP_SURFACES` every render -frame until it fires, `ACDREAM_AIRBORNE_DIAG` per airborne resolve), and -the sheer count makes the real diagnostic surface hard to find. This is -also a correctness risk for headless: `HeadlessStaticStateAudit` -reflects over `PhysicsDiagnostics`' probe flags and refuses a multi-session -host when any is set, but it cannot see the probes that live outside that -owner class. - -**Fix shape:** per probe, confirm its issue is closed and no gate script -references it, then delete flag + read sites + doc row together. The -launch-options row is the checklist — `LaunchOptionsDocumentationTests` -fails if a row survives its read site, so the doc cannot drift during the -cleanup. Do NOT bulk-delete: a few (e.g. `ACDREAM_DUMP_ENTITY`) are -consumed by a second, unrelated probe (`ACDREAM_PROBE_OUTSTAGE` reuses its -id list), so deletion order matters. - ---- - -## #436 — CLOSED: Combat refusal text ("No monster target") is silently dropped - -**Status:** CLOSED 2026-08-24, retail-faithfully. Ghidra decompile of -`ClientCombatSystem::ExecuteAttack` (0x0056bb70) settled both open -questions: (1) retail's exact string is **"You must select a valid combat -target before attacking"**, routed via -`ClientSystem::AddTextToScroll(..., 0x1A, true, 0)` — the ClientLocal -SpewBox channel our chat pipeline already owns; (2) retail has ONE message, -not two — attacking while not in melee/missile mode is silent (that path is -unreachable in retail's dispatch), so the invented "Enter melee or missile -combat first" text is deleted rather than rerouted. Implementation: the -string joined `ClientTextRefusals` with its decomp citation; -`CombatFeedbackSlot` gained the sibling `BindOwned` session-lifetime shape; -`SessionPlayerComposition.CompleteSessionPlayer` binds it to -`RuntimeCommunicationState.AddText(ClientLocal)` with session-owned -teardown. Coverage includes a binding-seam test -(`CompleteSessionPlayerBindsCombatFeedbackToTheClientLocalSpewBoxRoute`) -so the slot can never again pass its unit tests while production leaves it -unbound — the exact failure mode that hid this for months. - -**Original report follows.** - -**Status (original):** OPEN -**Severity:** MEDIUM (missing user feedback on a common action) -**Filed:** 2026-08-24 (exposed by #434's dead-code removal) -**Component:** combat / chat presentation - -**Symptom:** `LiveCombatAttackOperations` produces two refusal messages — -`"Enter melee or missile combat first"` and `"No monster target"` — and -hands them to `CombatFeedbackSlot.Show`. Nothing in `src/` ever binds a -target to that slot, so **both messages go nowhere**. Attacking with no -target, or without a combat mode, gives the player no explanation at all. - -**How it got here:** the slot's binding target used to be the developer -`DebugVM`, which Campaign V slice V11 left unreachable (see #434). Nothing -noticed because the drop is silent — `Show` is a null-conditional invoke. -#434 converted the seam to a plain `Action` so it no longer depends -on deleted code, and pinned the current drop-on-the-floor behavior in -`CombatFeedbackSlotTests.AnUnboundSlotDropsItsMessages` so the day it gets a -real binder, that test is what changes. - -**Fix shape:** route the slot to the chat window, where retail puts this -text. Needs the retail oracle first: confirm the exact strings and their -LogTextType/channel (`claude-memory/project_chat_digest.md` has the color -and channel map) rather than inventing wording — retail's own text may -differ from these two placeholder strings. - ---- - -## #437 — CLOSED: The R6 soak's movement-verification signal died with a deleted probe — resolved by RESTORING the probe - -**Status:** CLOSED 2026-08-24, same day, by restoration — not by choosing a -replacement signal. On review, the deletion premise was wrong: -`ACDREAM_DUMP_MOVE_TRUTH` is not a spent investigation probe but -**automation apparatus** — `run-connected-r6-soak.ps1` (the canonical -nine-stop soak) HARD-FAILS every destination when `$moveTruthDelta < 2`, -with a message that would misdirect the next operator ("production -movement did not deliver outbound records" when in truth the diagnostic -was deleted). Under the no-workarounds rule the honest fix is to restore -the mechanism the gate depends on, not to leave the gate broken with an -IOU. Restored in full: `MovementTruthDiagnosticController`, -`RuntimeOptions.DumpMoveTruth` (now carrying a comment naming the soak -dependency), the GameWindow wiring, the RuntimeOptions tests, and the -gate-script allow-list entries. Its `docs/launch-options.md` row moved to -the Automation section with the dependency spelled out. Process lesson, -recorded on #435: "its issue is closed" is NOT sufficient to delete a -probe — grep `tools/` and the contract tests for consumers first (the -original #435 part 1 did this; part 2's dispatch omitted it). - -**Original report follows.** - -**Status (original):** OPEN -**Severity:** LOW (measurement-tooling gap, not a client defect — the gate -still runs and its OTHER checks still verify real behavior) -**Filed:** 2026-08-24 (found while closing #435 part 2) -**Component:** connected-gate tooling (`tools/run-connected-r6-soak.ps1`) - -**Symptom:** `run-connected-r6-soak.ps1` sets `ACDREAM_DUMP_MOVE_TRUTH=1` -before launch, then during each destination's checkpoint asserts the log -contains at least two fresh `move-truth OUT` lines -(`Wait-ForLogPattern $Client $stdoutLog 'move-truth OUT' ...`) and reports -a `$moveTruthDelta` count — this was one of several signals the script uses -to prove the client actually issued outbound movement during the run. -`ACDREAM_DUMP_MOVE_TRUTH` was retired in #435 part 2: -`MovementTruthDiagnosticController` is now a permanent no-op, so -`move-truth OUT` can never appear in the log again. The script still runs -(its forward/jump/combat input-dispatch checks are independent and still -real), but the movement-specific corroboration silently stops meaning -anything — a soak that broke outbound movement entirely would no longer be -caught by this particular check. - -**Why it wasn't fixed inline:** the fix requires choosing a NEW signal to -verify outbound movement happened (e.g. a different existing log line, a -wire-level assertion, or a purpose-built lightweight counter) — a design -decision outside the scope of a probe-deletion pass, not a mechanical -rename. `docs/ISSUES.md`/CLAUDE.md's "no workarounds without approval" rule -applies: inventing a replacement signal without checking it against what -the script's other checks already prove would risk a false sense of -coverage. - -**Corroborating detail:** `ConnectedWorldSoakRouteContractTests. -LaunchConfigurationIsDisclosedToTheArtifactDirectoryBeforeLaunch` -(`tests/AcDream.App.Tests/Diagnostics/ConnectedWorldSoakRouteContractTests.cs`) -asserts `$env:ACDREAM_DUMP_MOVE_TRUTH = '1'` appears in the ps1's source — -a text-content assertion, not a runtime one, so it still passes and gives -no signal that the mechanism died. - -**Fix shape:** decide on a replacement runtime signal for "the client -issued outbound movement" (candidates: an existing non-diagnostic log line -already emitted by the movement pipeline, or a small permanent counter -exposed through an existing owner class), wire it into the soak script's -per-destination checkpoint in place of the `move-truth OUT` grep, and -update the disclosure-contract test to match. - ---- - -## #434 — CLOSED: The DebugPanel/DebugVM developer surface is unreachable, and ~40 doc comments still advertise it as live - -**Status:** CLOSED 2026-08-24. Deleted `DebugPanel.cs` (340 lines), -`DebugVM.cs` (548) and `DebugVMTests.cs` (327) — 1,215 lines. Converted the -one real dependant (`CombatFeedbackSlot`) to a delegate seam, which exposed -#436. Corrected every false "runtime-toggleable via the DebugPanel" claim in -`PhysicsDiagnostics`, `RenderingDiagnostics`, `CameraDiagnostics`, -`PhysicsEngine` and `GameWindow`. Full hermetic suite 15,333 passed / 0 -failed. **Deliberately left alone:** F1's `AcdreamToggleDebugPanel` binding, -which `GameplayInputCommandController` consumes as a documented no-op on -purpose (so the key does not fall through to a lower scope); and the -`DebugVmRenderFactsPublisher` / `DevToolsRuntimeSources` chain, which is -still wired into production composition and needs its own dead-code pass -rather than being dragged into a documentation cleanup. - -**Original report follows.** - -**Status (original):** OPEN -**Severity:** LOW (no runtime defect; a documentation-truth and dead-code problem) -**Filed:** 2026-08-24 (found during the launch-options audit) -**Component:** UI.Abstractions / diagnostics ownership - -**Symptom:** nothing in `src/` ever constructs `DebugPanel` or `DebugVM` -(`src/AcDream.UI.Abstractions/Panels/Debug/`). Their ImGui frontend was -deleted at Campaign V slice V11 — `SettingsDevToolsComposition.cs:13` -says so explicitly. Only `tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs` -instantiates them. Consequences: - -1. The ~35 `ACDREAM_*` reads inside `DebugPanel.cs`/`DebugVM.cs` are - unreachable in production (they only initialize the mirror state of a - panel that never exists). The flags themselves stay live — every one is - also read by its diagnostics owner — so no launch option is lost. -2. **Every "runtime-toggleable via the DebugPanel" claim in - `PhysicsDiagnostics`/`RenderingDiagnostics` XML docs is false.** Those - flags are startup-only today. `docs/launch-options.md` deliberately does - not repeat the claim; the owner-class comments still do. -3. `DebugVM` is still referenced as a TYPE by - `LiveCombatAttackOperations.Bind/Unbind` and - `DebugVmRenderFactsPublisher`, so those bind paths can never receive a - real instance. - -**Why not fixed with the audit:** deleting the pair is a real refactor -(test project churn plus two live type references), not a documentation -edit. Kept separate deliberately. - -**Fix shape:** either delete `DebugPanel`/`DebugVM` with their tests and -the two dead bind seams, or re-host them on the retained retail UI. Then -sweep the owner-class doc comments for "runtime-toggleable" and either -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 +## #430 — No tooltips on skills and attributes in the character panel **Status:** OPEN -**Severity:** MEDIUM (visibly wrong world state after ordinary travel) -**Filed:** 2026-08-24 (owner report) -**Component:** entity lifecycle / landblock retirement / reveal generation - -**Symptom (owner):** sometimes, after portaling around or using `/ls` -(lifestone recall) while close to Holtburg, monsters and other objects -from OTHER landblocks are visible hanging in the air — as if the old -world's entities were never flushed. Intermittent; "sometimes." - -**Relationship to #432:** NOT its cause — #432 reproduces on a fresh -login parked at town center with no prior travel. But both may share a -residency root (content near Holtburg staying resident/drawn when it -should be retired). The #432 DrawInside attribution probe should also -report WHOSE entities/cells the per-frame cost walks; if dead-generation -leftovers appear there, merge the investigations. - -**Where to look first:** the J6.2 canonical reveal-generation owner -(Runtime owns the sole reveal generation and old-world quiescence — -`docs/plans/2026-07-24-modern-runtime-architecture.md` Slice E/J6.2: -"generation-scoped old-world quiescence"), the entity teardown path at -generation reset (`RuntimeEntityDirectory` tombstones), and the -presentation sidecars (`LiveEntityProjectionStore`) — an entity drawn -without its landblock means the graphical sidecar outlived either its -runtime record or its cell residency. Distinguish: (a) runtime entity -alive but should be dead (server never sent destroy / we dropped it), -(b) runtime dead but presentation sidecar leaked, (c) entity correctly -alive but its OWN landblock geometry retired while inside the Far ring. -"Hanging in the air" + "other landblocks" suggests (b) or (c). - -**Repro lead:** portal arrivals and /ls near Holtburg; intermittent. -Capture: `ACDREAM_PROBE_CELL=1` + entity-ledger counts at the reveal -transition; a screenshot naming one floating guid would pin (a) vs (b) -immediately (F2 overlay shows guids). - ---- - -## #430 — CLOSED: No tooltips on skills and attributes in the character panel - -**Status:** CLOSED 2026-08-24 (found during the CA5 gate follow-up). -Root cause: the TS-85 Batch-B port set runtime `TooltipText` on the -runtime-built rows but never gave them a popup locator, and -`RetailTooltipPresenter.OnTooltipShow` refuses any widget with -`AuthoredTooltipRootElementId == 0` — the tooltip could NEVER mount. -(TS-85's "live-verified on the Character tab" was the OPTIONS panel's -Character tab — authored elements with authored locators.) Fix: rows now -carry the shared popup skin `0x10000395`/`0x21000041` — live-DAT probed as -the ONLY locator pair layout `0x2100002E` references, and the same -inference `UiItemSlot` already ships for runtime-built widgets. Pinned by -`Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText`. Owner visual -re-check owed at the next session (hover a row, hold the mouse still for -the 0.25 s dwell). **Severity:** LOW (information affordance missing) **Filed:** 2026-08-23 (owner report) **Component:** retail UI / character panel @@ -1011,16 +42,7 @@ the LayoutDesc importer does not mount yet. ## #431 — Raising an attribute does not refresh attribute-derived skills or run speed -**Status:** PROMOTED to Campaign CA -(`docs/plans/2026-08-24-character-advancement-campaign.md`, 2026-08-24) -after the owner widened the scope to the whole advancement family: vitals -maxima updating on attribute AND direct vital raises, real-time skill -refresh on both attribute and skill raises, run speed responding to -Quickness, attribute-less skills (Salvaging), plus the untested -train/specialize/respec flows. Root cause confirmed at promotion: the -outbound raise actions (0x0044–0x0047) are fully wired — the INBOUND -private attribute/skill update family is parsed nowhere (only the vitals -pair 0x02E7/0x02E9 is), so the recompute trigger never arrives. +**Status:** OPEN **Severity:** MEDIUM (visible stat incoherence during play) **Filed:** 2026-08-23 (owner report) **Component:** character state / stat chain / movement speed @@ -1052,18 +74,9 @@ resolution (SkillTable/attribute-formula) before porting any recompute. --- -## #429 — CLOSED: Periodic hitch in the LOCAL player's own motion while running (~2-3 s cadence) +## #429 — Periodic hitch in the LOCAL player's own motion while running (~2-3 s cadence) -**Status:** CLOSED 2026-08-24 — owner-accepted in both presentation modes -("Feels good capped too"). Fixed across three commits: `0330fcd0` -(allocation-exact streamed-mesh completion), `4873c106` (presented -player + chase camera share the object clock), `ad695589` -(allocation-free shadow topology rebuild + churn-frame pipelining). -Final measured state: stall frames 5.8/s → ~0.45/s uncapped (0.49/s -capped), median stall 20.3 → 13.7 ms, camera-vs-player decoherence -~1 m → 0.2–1.2 cm median. Residual content-proportional rebuild cost is -the incremental-topology successor campaign; the town-view latch found -during measurement is #432. +**Status:** OPEN **Severity:** MEDIUM (noticeable during ordinary play) **Filed:** 2026-08-23 (owner report during the night-sky session) **Component:** movement / prediction / server reconciliation @@ -1135,367 +148,21 @@ of running per arm (`artifacts/owner-gate/player-present-429-packON.csv` frames spend their extra time (the pack's CPU stage profiler names stages) and where the player position is sampled relative to it. -**ROOT CAUSE FOUND 2026-08-23 (frame-history run, 33,350 frames with stage -attribution — `ACDREAM_FRAME_PROF=1` + `ACDREAM_FRAME_HISTORY`):** of 708 -stall frames (>12 ms), 701 allocate 30-77 MB IN THAT SINGLE FRAME (normal -frames: ~22 KB, p99 94 KB). The time and the allocation sit on the render -path outside the tracked stages. The bursts arrive in ~8-frame clusters -during movement — the streaming MESH COMPLETION path: each frame completes -up to `MaxCompletionsPerFrame` (quality High = 4) newly streamed meshes on -the render thread, and each completion in -`ObjectMeshManager.UploadGfxObjMeshData` (~line 2043) runs LINQ chains — -`TextureBatches.Values.SelectMany(...).Select(b => b.Indices.ToArray()) -.ToArray()`, plus the retained pick-support copies -`CPUPositions = Vertices.Select(v => v.Position).ToArray()` and -`CPUIndices = ...SelectMany(...).SelectMany(...).ToArray()` — megabytes of -enumerator/intermediate-List garbage per mesh, tens of MB per frame. -Gen0 runs 70-145 collections per 5 s during movement (vs ~1/6 s idle). +**Next probes (in order):** +1. `ACDREAM_DUMP_MOTION=1` + a temporary inbound-position log for the + LOCAL guid: does ACE send position sets for the local player every + ~2-3 s while running, and does acdream apply them? (Retail ignores + routine UpdatePosition for the autonomous local player; only + ForcePosition snaps — verify our projection split here.) +2. Compare `PlayerWeenie.InqRunRate(15230)` against ACE's computed + runRate for the same skill — a formula delta is the root cause if the + correction traffic confirms. +3. If no corrections arrive: instrument the local movement controller's + own periodic state (autorun latch, tracker send side-effects). -**Fix shape (a bounded slice, not a quickie — this is the production mesh -pipeline):** -1. De-LINQ the conversion: direct pre-sized loops for the index batches and - the CPU pick copies (sizes are known up front from the batch counts). -2. Consider byte-budgeted completions (4 huge EnvCell meshes is not the - same frame cost as 4 fence posts) and/or moving the CPU-side conversion - onto the existing mesh-preparation scheduler thread so the render thread - only adopts finished arrays. -3. Allocation-gate test in the I1 style: a completion of a representative - mesh set must allocate near its retained-copy size, not multiples of it. -The pack-ON player-jump phase question remains as the second defect but -becomes mostly moot once the stalls themselves shrink. - -**SITE ATTRIBUTION CORRECTED + FIXED 2026-08-23 (implementation session).** -The frame-history correlation (701/708 stall frames at 30-77 MB) was right; -the SITE was wrong. The baseline CSV's own stage columns refute the -mesh-completion theory: in all 701 alloc-correlated stall frames -`upload_us` is ~3 µs — `WbMeshAdapter.Tick` (which contains the entire -`UploadGfxObjMeshData` completion drain AND the mip flush, inside the -tracked Upload stage) did essentially nothing in those frames. A temporary -per-phase allocation probe (`GC.GetAllocatedBytesForCurrentThread` marks -through the render frame, driven by an automated Holtburg running route) -attributed the allocation exactly: - -- **THE stall allocator: `DirectionalShadowPreparedDraws.Complete`'s - `Array.Sort` comparer** (`WbDrawDispatcher.DirectionalShadows.cs`). - `x.Material.CompareTo(y.Material)` / `x.CullMode.CompareTo(y.CullMode)` - bind to `Enum.CompareTo(object)`, which boxes BOTH operands on every - comparison — measured at a constant **38.88 MB per directional-shadow - topology rebuild** (~4M boxes across the N·log N sort of every prepared - caster draw in the resident window). The topology rebuilds whenever - `RenderDataAvailabilityVersion` moves — i.e. on every streaming-churn - frame while the player runs (the Atmospheric pack's shadow prepass is the - consumer, matching pack-ON visibility; sustained churn windows rebuilt - 260 consecutive frames ≈ 10 GB of garbage in seconds). Everything else - in the rebuild (caster copy, classification loop, grouping) measured - ~0.3 MB — the shadow stack's retained-scratch design was already sound; - two enum comparisons were the whole leak. -- Secondary (the original theory, real but ~7 of 708 baseline frames): - the `UploadGfxObjMeshData` LINQ conversion, 8-14 MB on completion - frames. -- Also observed while probing, pre-existing and bounded, NOT #429: - composite-texture warmup (`TickCompositeTextureCache`, 16/frame budget) - and PView reveal churn allocate ~4-30 MB on teleport/reveal frames. - -**Fix landed (pending owner gate):** -1. The sort comparer compares enums through their underlying integers — - allocation-free, identical ordering. Verified live: the 38.88 MB - rebuild signature is gone (big-alloc frames on the automated running - route: 124 → ~0 shadow-rebuild frames; only the pre-existing - composite/pview/reveal allocations remain). -2. The handoff's de-LINQ of `UploadGfxObjMeshData`: one exact-size - retained `CPUIndices` array now feeds both the pick copies and the - arena upload (`GlobalMeshBuffer.UploadMesh` takes (offset, count) - segments of it); `CPUPositions` fills by direct loop; the - `Sum`/`Any`/`FirstOrDefault` transients are gone. Behavior-preserving: - same bytes staged, same batch order, same retained content. -3. Two I1-style allocation gates: a warmed directional-shadow topology - rebuild must allocate < 2 KB - (`DirectionalShadowPreparedDrawTests.AWarmedTopologyRebuildAllocatesNearZero`), - and a warmed mesh completion must allocate near its retained-copy size - (`MeshPipelineDeviceSeamTests.AWarmedMeshCompletionAllocatesNearItsRetainedCopySize`). -4. The temporary `PlayerPresentationProbe` and the attribution probe are - stripped. - -Session note: one automated probe run (of seven) exited with -0xC0000374 (STATUS_HEAP_CORRUPTION) during graceful close AFTER the route -completed, on a diagnostic build; not reproduced since. Watch for it in -future gate runs. - -Remaining owed: the owner's two-sided acceptance (feel gate + the ~45 s -pack-ON measured run against `artifacts/owner-gate/frame-history-429.csv`). -The pack frame-graph ordering question (defect 2) stays deferred unless -residual hitches survive. - -**OWNER GATE ROUND 1 (2026-08-23, same evening): allocation half PASSED, -felt hitch PERSISTS — residual attributed and a second fix round landed.** -The owner's ~45 s pack-ON drive on the fixed build: alloc-correlated stalls -701 → 5, stall-frame allocation median 39.6 MB → 40 KB, GC pressure gone — -but the micro-freeze feel remained. A time-triggered probe round (print any -frame > 12 ms with per-phase attribution) on an owner-driven clean run -measured the residual exactly: **82 stalls in 47 s (1.75/s — the ORIGINAL -pre-investigation stall rate), median 19.1 ms, clusters every ~1.3-2 s**, -near-zero allocation. Composition per stall frame — a rebuild CASCADE all -triggered by one `RenderDataAvailabilityVersion` bump (streaming publish) -and all paid in the SAME frame: -- `pk:casters` 3.5-14 ms — `DirectionalShadowCasterFrame.Build` copies + - classifies every outdoor projection record; -- `wb:sd-topo` 4-28 ms — `DirectionalShadowPreparedDraws.Complete` - (sort + group), allocation-free after the boxing fix but still the CPU; -- `ws:pview` 4-12 ms — the world draw path's own version-keyed work, - elevated on the same frames. -The owner's "introduced with the night-sky change" hypothesis was tested -directly and REFUTED: the sky default-script segment (`b:skypes`) crossed -1 ms once (2.3 ms) in the whole run. Timing note: the VisualMaster -directional-shadow machinery landed immediately before the night-sky -session where the hitch was first noticed — the sky was the nearest -visible change, the shadow prepass the actual newcomer. - -**Second fix round (in tree, uncommitted): packed-key index sorts.** Both -hot sorts — `DirectionalShadowPreparedDraws.Complete`'s draw sort and -`DirectionalShadowCasterFrame.Build`'s caster sort — previously moved -multi-hundred-byte records through interface comparers. Both now sort -4-byte index arrays against one 64-bit key (draw sort: an order-preserving -packed prefix Material|CullMode|FirstIndex|BaseVertex with exact-comparer -tie-break; caster sort: the traversal `SortKey.Value` directly), then -permute once through retained scratch. Total order preserved everywhere -the arena can reach; 131 directional-shadow tests including both #429 -allocation gates pass. Owner re-drive pending at time of writing. - -**Measurement hygiene note:** an A/B (same launch recipe, same position, -with/without `ACDREAM_UI_PROBE_SCRIPT`, and with an idle one-command -script) proved the harness launch recipe and the script runner are BOTH -innocent of the #432 low-FPS mode — both idle arms run 4.5 ms/17 KB -frames. The mode requires the synthetic route's PATH (through the town -view); owner-driven runs avoid it naturally. - -**OVERNIGHT ROUNDS 2-4 (2026-08-23→24): rebuild cascade cheapened but the -felt hitch is defect 2, now MEASURED as camera/player decoherence.** -Owner drives 2-4 each reported the hitch "unchanged" while every attacked -piece shrank (caster copy+classify+sort ≈ 1.3-2 ms each; the draw sort -round-1 index sort actually REGRESSED — instanced duplicates share one -packed key, so the tie-break full-record comparer became the hot path, -6.5 → 14.6 ms avg, caught by the owner's drive-2 data — round-2 replaced -the 100k-draw sort entirely with O(n) hash-grouping over retained chained -arrays plus an O(g log g) sort of the ~few-thousand DISTINCT group keys; -same emitted product, deterministic). Post-everything, owner-terrain stalls -still ~2.1/s at 27-31 ms median: per-frame `pv:frameview` (scene -frame-view build) + `pv:landscape` + the residual topo loop dominate. -Micro-shaving converges too slowly to clear 12 ms — the FEEL lever is -defect 2. - -**Defect 2 objectively measured** (from the ORIGINAL owner captures -`player-present-429-packON/-packoff.csv`, camera-relative analysis): -pack ON, 50 of 85 long moving frames separate the presented player from -the camera by ~1 m in one frame (18x the normal 5.5 cm relative step); -pack OFF, 7 of 83 at half the size. The felt hitch IS this one-frame -camera/player decoherence — the player lurches on screen while the -camera-anchored world stays smooth. - -**MECHANISM LOCATED (2026-08-24 ~00:10, per-update -`ACDREAM_PROBE_CAMERA_TICK` capture, pack ON, running 16 m/s):** on -long (~27 ms) updates, HALF the samples advance the presented player only -~12-16 cm (a third of elapsed time) while the chase camera steps the full -~40 cm; the other half advance both coherently (~42-53 cm each). The two -run on DIFFERENT clocks: the presented player position is -`ComputeRenderPosition` = lerp(prevQuantum, currQuantum, -pending/MinQuantum) on the retail 30 Hz OBJECT CLOCK (alpha CLAMPS at 1 — -near-quantum-length updates alias against the 33.3 ms quantum and the -presented position under-advances or freezes), while the camera's damping -(`ComputeDampingAlpha(stiffness, dt)` in RetailChaseCamera/ChaseCamera) -integrates WALL-CLOCK dt — on a 27 ms update it closes ~half its -accumulated ~1 m chase lag regardless of the target having barely moved. -Camera and player cross → the lurch. High-FPS updates (pack OFF, ~4 ms) -glide through the quanta, which is why OFF feels smooth with the same -stall count — and why shrinking the stalls below ~quantum length would -also mask it, but the CLOCK MISMATCH is the root cause. - -**DEFECT-2 FIX IMPLEMENTED + MEASURED (2026-08-24 ~00:20, in tree, -uncommitted).** `PlayerMovementController.PresentedDeltaSeconds` now -reports how far the presented position's own clock advanced each tick -(quanta simulated x MinQuantum + clamped-pending delta; wall dt on a -Discarded/teleport batch so the camera snaps along), and -`CameraFrameController` integrates the chase-camera damping with THAT -delta instead of wall dt (manual zoom/pitch stays on wall dt — an input -rate, not target chasing). This RESTORES retail's semantics — the camera -updates on the physics clock via PlayerPhysicsUpdatedCallback -(0x00452d60) — so no divergence-register row: it retires an unregistered -wall-clock deviation. Verified on the automated route, pack ON: -- per long update (28-38 ms): cam/player step ratio 0.75-1.19 (was - 2.6-3.4x with ~27 cm crossing); |cam-player| med 2.2 cm, p90 7 cm, - max 16.7 cm (~12x tighter); -- per frame: typical camera-relative player step 5.5 cm → 0.3 cm; the - baseline's 50-of-85 ~1 m long-frame lurches → 3 frames above 25 cm - (max 31 cm) — better than the old pack-OFF arm the owner perceived as - smooth. -Runtime suite 1,818/0, hermetic App suite 6,082/0. - -**FEEL GATE ROUNDS 2-3 + THE FINAL TWO FIXES (2026-08-24 00:20-01:10).** -Round 2 ("still there") caught that the camera-clock fix alone leaves a -coherent whole-view freeze: with camera and player now in lockstep, the -remaining artifact was the presented position itself under-advancing. -Root cause: `ComputeRenderPosition` normalized its lerp by the FIXED -MinQuantum while the retail clock simulates VARIABLE-length quanta -(everything above MinQuantum in one step, split at MaxQuantum=0.2 s) — a -long host frame fired a >33 ms quantum, alpha reset across the bigger -gap, and presentation froze then replayed fast. Fixed by normalizing by -the actual last-quantum interval (`_lastQuantumSeconds`), with -`PresentedDeltaSeconds` accounting continuous presented time (the camera -consumes the same delta, so both stay coherent by construction). Two -Runtime tests that pinned the old fixed-quantum lerp were updated to the -continuous-rate contract (Update_SubQuantumFrame_..., -Update_LeftoverAboveMinQuantum_... — renamed -...InterpolatesAcrossTheActualQuantumInterval). - -Round 3 landed the owner-approved (A) **shadow rebuild pipelining**: on a -frame where the shadow inputs just changed (streaming churn — the same -frame already pays the frame-view/landscape rebuilds), the caster and -prepared-draws topology rebuilds defer to the next quieter frame, capped -at 2 consecutive deferrals (inside the GPU fence depth, so retained draws -can never reference a released-and-reused arena range). Deferral is -best-effort with hard safety rails: first build, generation change, -transform-journal overflow, and any caster rebuild force the full path -immediately; stale-topology refreshes skip identity-mismatched journal -rows instead of throwing. Implemented across -`DirectionalShadowCasterFrame.Build(allowTopologyRebuild)`, -`WbDrawDispatcher.PrepareDirectionalShadowDraws(allowTopologyRebuild)`, -and the policy in `AtmosphericPostProcessGraph.RenderDirectionalShadows`. - -**Measured outcome (owner feel gate 3, ~210 s drive incl. pack -switching): median stall 20.3 → 13.7 ms; automated route: med 13.9 ms -(was 27-31), max 33, frames >16 ms at 1.4/s. Owner verdict: "almost -gone."** Residual composition (deep marks): frame-view build ~4.7 ms + -early landscape slices ~3.9 ms per churn frame, plus the pipelined shadow -rebuild ~8 ms on its own frame — content-proportional work with no -pathological defect left; further reduction is the incremental-topology -campaign already described above. OWED: the owner's final morning -confirmation, then strip the probe families (RenderFrameAllocProbe + -marks incl. fv:/pl:, PlayerPresentationProbe, CameraTickProbe) and -commit on request. - -**Tree state at pause (uncommitted, on the worktree branch):** four landed -optimizations (enum-boxing comparer fix, upload de-LINQ + arena segment -API, caster-frame index sort, draw hash-grouping) + two allocation-gate -tests; TEMPORARY apparatus still wired: `RenderFrameAllocProbe` (env -`ACDREAM_PROBE_FRAME_ALLOC`, time-or-alloc triggered, 4/s print sampling) -with ~30 phase marks, `PlayerPresentationProbe` -(`ACDREAM_PROBE_PLAYER_PRESENT`), `CameraTickProbe` -(`ACDREAM_PROBE_CAMERA_TICK`). All env-gated, off by default; strip all -three families with the defect-2 fix. Hermetic App suite 6,082/0 (one -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 -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, -scaled by the resident entity count of the whole streaming window. - -**Root cause (three links, each verified):** -1. `ACDREAM_AUTOMATION_ARTIFACT_DIR` (+ retained-UI screenshots) - constructs `CurrentRenderSceneOracle` - (`FrameRootComposition.cs:349`). Ordinary play never constructs it — - the owner was never affected. -2. The oracle's presence as partition observer flips - `LegacyPartitionDiagnosticsEnabled` - (`RetailPViewRenderer.cs`), so the G5-retired legacy - `InteriorEntityPartition` runs EVERY frame with per-entity - fingerprint observation. -3. `CurrentRenderSceneOracle.Complete` sorts the per-frame fingerprints - of every resident entity; the comparer's first key compared enums via - `x.ProjectionClass.CompareTo(y.ProjectionClass)` → - `Enum.CompareTo(object)` → boxes BOTH operands per comparison. The - 3-value enum almost always ties, so the boxing executes on - essentially every one of the sort's ~n·log n comparisons — the #429 - `ad695589` boxing-comparer defect class, second instance. - -**Evidence:** [pview-alloc] per-phase probe (`ACDREAM_PROBE_PVIEW_ALLOC=1`, -TEMPORARY, in `RetailPViewRenderer.DrawInside`) attributes the steady -mode to the partition phase: `part=6199KB` at the login spot -(`0xABB20030`) AND `part=6189KB` at town (`0xA9B4001E`) — near -content-independent because the resident far-window entity total is -similar (~60k) in both areas; this also explains the "latch" (the -resident set, not the view, drives it). Natural experiment across the -2026-08-23/24 runs, same town route: artifact-dir NULL runs -(`d2-packon/packoff`) average 48–67 KB/frame with ~50–74 churn frames ->3 MB out of ~18–20k; artifact-dir SET runs average 2.3–4.6 MB/frame -with thousands. Hermetic repro -(`OracleObservedPartitionAllocationTests.AWarmedObservedPartitionAllocatesNearZero`): -one warmed observed partition of 20,000 entities allocated -**15,876,088 bytes**; with the one-line non-boxing compare -(`((int)x.ProjectionClass).CompareTo((int)y.ProjectionClass)`) it passes -the <64 KB gate. - -**Consequences for past measurements:** any capture taken with -`ACDREAM_AUTOMATION_ARTIFACT_DIR` set carries this ~6 MB/frame + ~14 ms -diagnostic tax. The #429 acceptance data is CLEAN — the owner drives and -the A/B + d2 arms ran with the artifact dir null. - -**Not related to #433:** the stale-entity sighting happens in ordinary -play (no artifact dir), so it cannot share this mechanism. - -**Remaining (with the fix):** the oracle still costs real CPU per frame -(fingerprint walk + sort over ~60k entities) even allocation-free — -acceptable for a diagnostics-only path, but automation-gate FPS numbers -remain diagnostics-loaded; compare like-for-like only. The intermittent -~4.26 MB "post-world diagnostics" satellite (RenderSceneShadow -comparison, same construction condition) was not separately chased — -re-measure after this fix lands and file separately if it survives. - -**Previous (superseded) framing follows for the record:** -**Severity:** MEDIUM (halves frame rate and allocates ~300 MB/s while it holds) -**Filed:** 2026-08-23 (found while measuring the #429 fix; NOT caused by it — -reproduces identically on the pre-fix binary) -**Component:** rendering (untracked render path — attribution not yet done) - -**Symptom:** with the player at/near Holtburg town center (observed at cell -`0xA9B40019`; NOT at `0xA9B40036` a few cells away), every frame allocates a -near-constant ~6.3 MB and costs ~20 ms CPU (~45-50 FPS from a ~270 FPS -baseline), indefinitely, with Gen0 at ~6/s. `update_us` ~1.8 ms and -`upload_us` ~0 — the time and allocation sit in the untracked render path -(same measurement seam as #429). The mode begins the frame the view reaches -the spot (after a `/teleloc` there, or immediately at login when parked -there) and held for 80+ s of continuous running in a loop around town. - -**Evidence:** frame-history CSVs + stdout under the 2026-08-23 session -scratchpad (`frame-history-postfix-224749.csv` — healthy 7 ms/20 KB frames -for 20 s until the teleport, then 6.1-6.3 MB/frame for the rest; -`frame-history-postfix-225154.csv` — the mode active from login onward; -`probe-429-223228.out.log` — the SAME tail on the PRE-#429-fix binary). -The #429 owner baseline (spawn `0xA8B4002A`, running loops near-but-not-in -town) never shows it: normal frames ~22 KB at ~247 FPS. - -**Partial attribution + latch behavior (from the #429 probe runs):** the -per-phase lines that crossed the probe's 8 MB print floor split the mode as -a near-constant **~6.0 MB/frame in the PView draw -(`WorldSceneRenderer` → `DrawInside`)** plus an intermittent ~4.26 MB in -the post-world diagnostics phase. Once triggered it LATCHES: a 70 s -straight-line run ~300+ m away from town held EXACTLY ~6,187 KB/frame the -whole way (the town stays inside the Near ring at that distance, so -whatever content drives it stays resident). Trigger observed at town -center `0xA9B40019` but NOT at `0xA9B40036`, and NOT on the owner's -`0xA8B4002A`-spawn loops. Candidate families, unverified: the town's -buildings entering the PView nearby-building/cell set; an animated static -(the windmill, #426-adjacent) keeping a per-frame path hot. Re-add the -#429 attribution probe (one level deeper, inside DrawInside) and measure — -do NOT guess. - -**Gate caution:** a post-#429 measurement run that strays into this latch -shows every frame as a ~20 ms "stall" at ~6.3 MB — that is THIS issue, not -#429 residue. Compare only non-latched segments (normal frames ~22 KB), or -route away from Holtburg town center. - -**Next probes:** re-add the #429-style per-phase allocation probe ONE -level deeper — inside the PView draw (`WorldSceneRenderer` → -`DrawInside`): per-cell / per-stage bytes with identity (cell id, entity -guid, draw family), so the ~6.0 MB/frame names its owner instead of the -whole pass. Report WHOSE cells/entities the walk touches (also serves -the #433 stale-entity question). Measure at the trigger cell -`0xA9B40019` vs the clean `0xA9B40036`. (An earlier revision of this -entry carried #429's disproven run-rate/UpdatePosition probe list here — -removed; that theory died with `ca4bae77`.) +**START at** `claude-memory/project_physics_collision_digest.md` (per-cell +DO-NOT-RETRY rules) and `claude-memory/project_retail_motion_outbound.md` +(TS-33 AutoPos tracker semantics) before instrumenting. --- @@ -1727,7 +394,7 @@ kinds. Same class as the VM6 fix; do not guess the values. ## #422 — Intermittent heap-corruption exit (0xC0000374) at process exit after an offline capture (pack on OR off) -**Status:** OPEN — filed 2026-08-22 at Campaign VM VM3; **characterised at VM7 (2026-08-23) — rare, pack-INDEPENDENT, exit-time, not yet caught with a stack.** Facts: (1) it fired once more, on the **retail (pack-off)** row of `tools/run-atmospheric-performance-matrix.ps1` at 1920×1080 / 45 s warm-up / uncapped on `621a0edf` — the first launch after a fresh build — so "retail/off never reproduced it" is withdrawn and the title's "pack-on" is wrong: the exit is in the common teardown. (2) It did not fire in 16 runs with cdb attached (High, 720p, 12 s), 24 runs launched under cdb's debug heap (High, 720p, 12 s), 6 runs under the debug heap with the exact matrix recipe, or 10 plain runs with the exact recipe and a forced non-incremental rebuild before run 1 (`tools/i422/loop-debugheap.ps1`, `tools/i422/loop-plain.ps1`) — 1 in ~57 offline runs today, ~2 %. (3) The fail-fast leaves NO Application event-log entry and NO WER report on this machine (WerSvc is in its normal on-demand state, nothing disabled), so there is no dump to read; a per-user `HKCU\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\AcDream.App.exe` key (`DumpType=2`, `DumpFolder`) is the one-time user action that turns the next occurrence into a full dump — the project does not set registry keys itself. (4) The pre-campaign binary (`6c79d35c` + VM0 patches) CANNOT be tested with this tool: it predates the gate's in-process close verb, so every run ends in the gate's forced kill and never reaches the graceful-exit path where the fault lives (10/10 "automation close timed out") — whether the fault predates Campaign AR is therefore unknown, not disproven. Evidence: `docs/research/evidence/vm7/i422-*.txt`, `artifacts/vm7-matrix/uncapped-retail-1920x1080/` (the crashing run's log — managed shutdown complete, `MossTank disabled` last). **Owner decision 2026-08-23: accepted as carried; Campaign VM merged with it open.** Next step when it recurs: the LocalDumps key above, then `tools/i422/loop-plain.ps1` / `loop-debugheap.ps1`. **Recurred 2026-08-24:** the #432 attribution run (`probe-432-094728`, connected live session, graceful WM_CLOSE, managed shutdown complete) exited `-1073740940` — third sighting, first on a CONNECTED (non-offline-capture) run; still no dump (the LocalDumps key remains unset). One earlier #429-round sighting was also at graceful close (~1 in 10 diagnostic runs that day). +**Status:** OPEN — filed 2026-08-22 at Campaign VM VM3; **characterised at VM7 (2026-08-23) — rare, pack-INDEPENDENT, exit-time, not yet caught with a stack.** Facts: (1) it fired once more, on the **retail (pack-off)** row of `tools/run-atmospheric-performance-matrix.ps1` at 1920×1080 / 45 s warm-up / uncapped on `621a0edf` — the first launch after a fresh build — so "retail/off never reproduced it" is withdrawn and the title's "pack-on" is wrong: the exit is in the common teardown. (2) It did not fire in 16 runs with cdb attached (High, 720p, 12 s), 24 runs launched under cdb's debug heap (High, 720p, 12 s), 6 runs under the debug heap with the exact matrix recipe, or 10 plain runs with the exact recipe and a forced non-incremental rebuild before run 1 (`tools/i422/loop-debugheap.ps1`, `tools/i422/loop-plain.ps1`) — 1 in ~57 offline runs today, ~2 %. (3) The fail-fast leaves NO Application event-log entry and NO WER report on this machine (WerSvc is in its normal on-demand state, nothing disabled), so there is no dump to read; a per-user `HKCU\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\AcDream.App.exe` key (`DumpType=2`, `DumpFolder`) is the one-time user action that turns the next occurrence into a full dump — the project does not set registry keys itself. (4) The pre-campaign binary (`6c79d35c` + VM0 patches) CANNOT be tested with this tool: it predates the gate's in-process close verb, so every run ends in the gate's forced kill and never reaches the graceful-exit path where the fault lives (10/10 "automation close timed out") — whether the fault predates Campaign AR is therefore unknown, not disproven. Evidence: `docs/research/evidence/vm7/i422-*.txt`, `artifacts/vm7-matrix/uncapped-retail-1920x1080/` (the crashing run's log — managed shutdown complete, `MossTank disabled` last). **Owner decision 2026-08-23: accepted as carried; Campaign VM merged with it open.** Next step when it recurs: the LocalDumps key above, then `tools/i422/loop-plain.ps1` / `loop-debugheap.ps1`. **Component:** rendering / render packs (Campaign AR) — native teardown **Description:** `tools/run-offline-pixel-gate.ps1 -RenderPackPreset high` (shipped @@ -1823,9 +490,7 @@ 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 -faceted outer rim, and destination lower-viewport hole are gone in the live -owner-pak gate. +**Status:** OPEN (filed 2026-08-17, user screenshot + FOV experiment). **Symptom:** the tunnel's low-poly mouth shows as a faceted polygon silhouette against black, scaling with the Config FOV slider (barely visible at minimum FOV); a brief ring flash remains at exit even at @@ -1923,69 +588,6 @@ interior there ⇒ our pipeline, rings there ⇒ shared dat interpretation); (4) a retail side-by-side screenshot for ground truth (brightness included). Fix only against that evidence. -**2026-08-25 apparatus closeout:** step (1) is implemented as -`ACDREAM_PROBE_TUNNEL_FREEZE=1` (authored frame 72) or `=N` (frame 2–120). -The diagnostic withholds the TAS_TUNNEL transition even after world-ready, -then stops the tunnel sequence and roll at the requested frame and emits one -`[tunnel-freeze] ... state=held` marker. It intentionally prevents placement, -reveal, and login completion until cancellation/process exit; it is a capture -tool, not a shipping behavior or a performance mode. Step (2)'s frozen -RenderDoc capture (`portal-frozen-frame1178.rdc`) confirmed the authored finite -mesh and production portal pass were actually drawing; it did not support a -cull/material/asset substitution. Step (3) was attempted, but ACViewer was not -a trustworthy renderer oracle for this path and was discarded rather than used -as fix evidence. Step (4)'s direct retail capture showed the radial tunnel -field swapping straight to the destination world, with no small centered disk. - -**Root cause and final fix (2026-08-25):** the exit defect was a two-part -presentation-boundary error, not tunnel content. First, our outgoing viewport -remained eligible through retail animation-table level 1022 even though paired -initial 40 fps captures appeared to establish level 1013 as the last sample -whose radial field covered the measured center rings; level 1016 and later -exposed a small finite-mesh disk. `TeleportAnimSequencer` initially retired the -outgoing viewport after quantized level 1013. Second, -`LocalPlayerTeleportPresentation.Tick` -published the `WorldFadeIn` terminal projection while the tunnel could still be -visible, and both teleport/login event handlers called the synchronous -destination-release suffix before `ExitTunnel`. A slow suffix could therefore -hold the invalid tunnel/projection combination on screen. The presentation now -hides portal space before publishing any snapshot whose `ShowTunnel` is false, -and both handlers enforce retail's order: hide portal, reveal/release world, -then play `Sound_UI_ExitPortal`. The later `ExitTunnel` call remains idempotent. - -**Acceptance evidence:** the 40 fps frame-sequence verifier fails the pre-fix -owner-pak capture on the exact disk (`radial coverage=0.053`). Three consecutive -fixed launches, using `artifacts/owner-gate/acdream-v5.pak` with the installed -DATs and the same ACE login route, swap directly to world and pass at minimum -tail coverage `0.800`, `0.747`, and `0.736`. Focused regressions cover the 1001 -table boundary, tunnel-retire-before-view-plane publication, and both -teleport/login host-call orders. No portal mesh, shader, sampler, lighting, or -camera-position change is part of the fix. - -**16:9 perimeter correction (2026-08-25):** the center-ring verifier missed -the same mesh boundary crossing the sides of a full-width viewport. The owner -capture and the automated 1280x720 sequence agree: level 1001 is the last -fully covered tunnel sample, level 1006 begins exposing the outer perimeter, -and the old level-1013 handoff can hold the complete faceted rim while the -world viewport is installed. The sequencer cutoff is therefore 1001, still in -the retail quantized table domain, and the gate now measures an unobstructed -left-edge rail in addition to center rings. This changes only which authored -tunnel sample is held for the atomic swap; it does not enlarge or repaint the -mesh. - -**Residual found by the owner:** the direct swap still leaves a large blue -lower-viewport region during the first `WorldFadeIn` samples. This is not a -missing tunnel texture and the title's `lb 0/0` is not a terrain-residency -count. The destination is resident; its finite terrain is projected with -`M22=0.001` and `znear=0.1`, so lower-screen ground rays meet the terrain before -the Vulkan near plane and only a thin horizon strip survives. The legacy retail -landscape visibly supplies coverage at this singular endpoint. The modern -renderer adaptation scales the near plane with view-plane distance only while -the world viewport owns `WorldFadeOut`/`WorldFadeIn`; tunnel and ordinary-world -near planes remain unchanged. The lower-viewport coverage gate passes the -owner-pak live capture, and the owner accepted the final in-game transition on -2026-08-25 as "Perfect!". - ## #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 @@ -2151,19 +753,6 @@ attributed to the cold render-thread barrier); portal-hold gate-ready render-thread upload/registration phase (t≈1–8 s, concurrent) — budgets are exonerated three times over.** -**2026-08-25 attribution checkpoint:** `ACDREAM_PROBE_REVEAL_TIMING=1` now -pairs each reveal timing run with low-frequency `[reveal-resource]` snapshots -at begin, readiness edges, one-second progress intervals, and summary. The -snapshots borrow the canonical render owners and report mesh/atlas residency, -global and per-frame upload counts/bytes, buffer/texture/copy work, staging -high-water, mesh-arena capacity/migration, prepared-package probe/read results, -composite backlog, CPU mesh cache, and managed/committed/tracked GPU memory. -Use it with `ACDREAM_FRAME_PROF=1` and -`ACDREAM_FRAME_HISTORY=`; the next cold login comparison can now -distinguish decode/cache fill, upload/staging, -registration/composite debt, arena growth, and process-memory growth without -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 @@ -2598,15 +1187,6 @@ porting. ## #410 — Client-wide VJustify (vertical text justification) enum mapping + unauthored default are wrong (retail default is Top, not Center) -**HJustify sibling evidence (2026-08-24, CA5 tooltip re-check):** the SAME -wrong-default class exists horizontally — `ElementInfo.HJustify` defaults -to Center while retail's unauthored default is Left. Observed live: the -tooltip popup text (whose authored skin sets no justification) rendered -centered where retail left-aligns; point-fixed in -`RetailTooltipPresenter.ApplyTooltipText` (the chat transcript carries the -same point-fix). When this issue's client-wide default sweep runs, fix H -and V together. - **Status:** OPEN **Severity:** MEDIUM (silently mispositions every DAT-imported `UiText` that relies on the unauthored default, or that authors a raw vertical- @@ -4379,7 +2959,8 @@ switching stays #376. ## #373 — Configure Keyboard: DAT `ActionMap.ConflictingMaps` not consulted — the combat cluster raises false conflict prompts -**Status:** DONE 2026-08-26 — fixed as the first #446 keyboard-parity slice. +**Status:** OPEN — filed 2026-08-11 at Campaign OP slice OP8's re-review +round 2 (R1's scope boundary). The DAT ActionMap (DID `0x26000000`) carries a `ConflictingMaps` table retail's `UIOption_ActionKeyMap` consults when deciding whether two rows @@ -4396,22 +2977,17 @@ new action to one) prompts "overwrite N bindings?" where retail prompts for fewer or none. Accepting the prompt then strips retail-default bindings that should have survived. -The OP8 round-2 fix originally excluded store-only rows (`MappedAction is -null`) from the conflict universe. Campaign KB later mapped and enabled every -one of the 306 installed rows, eliminating that tier; retail cross-context -sharing still needs the real table. **Fix:** parse `ConflictingMaps` in +The OP8 round-2 fix already excluded store-only rows (`MappedAction is +null`) from the conflict universe — those cannot collide because they +never reach the InputDispatcher — but retail-mapped cross-context +sharing needs the real table. **Fix:** parse `ConflictingMaps` in `RetailActionMap` (the reader already round-trips the field — `RetailActionMapReaderTests` constructs it), and make `FindConflicts` consult it: two rows sharing a chord conflict only if their contexts' ConflictingMaps entries say so. Conformance-test against the combat cluster's authored defaults (five keys, multi-row each, zero prompts on -a no-op rebind). - -**Fix landed:** `RetailActionMapSnapshot` now owns the copied DAT conflict -sets and `KeyboardConfigController.FindConflicts` consults them before -offering reassignment. Hermetic tests pin permitted cross-combat sharing and -declared cross-map conflicts; an installed-DAT test pins that melee, missile, -and magic are pairwise non-conflicting. Same-context conflicts remain active. +a no-op rebind). The gate script's §OP8 warns the user off treating the +false prompts as new breakage until this lands. ## #372 — Options panel: Character/Chat/Config tabs render BLANK on screen and most Gameplay buttons do nothing (connected-gate failure) @@ -19073,13 +17649,6 @@ DrawDynamicsParticles only sees dynamics-last cone survivors. **Gate:** stand inside, look out the doorway at the town portal — the swirl renders through the door. -**2026-08-27 ordering correction (#451):** the old "once per frame after the -look-ins" placement was sufficient for this gate but did not preserve the -installed `outside_view` and could repaint an already-drawn open-air building. -Ownerless emitters now submit once per outside-view slice with that slice's -clip slot. When building look-ins exist they enter the pre-building alpha -barrier; otherwise they drain at the end of `LScape::draw`. - --- ## #132 — Candle flame disappears when the through-opening background is behind it @@ -19120,13 +17689,6 @@ against interiors). The owner-id filter carries over; cell-pass and dynamics-pass emitters keep their own passes (owners never in the outdoor-static set → no double-draw). -**2026-08-27 correction (#451):** the post-frame placement fixed this narrow -flame-overpaint case but was too late for nested open-air cathedral cells: an -exterior waterfall could repaint their completed opaque floor. Outdoor-static -and ownerless particles now submit inside `LScape::draw` under the exact -outside-view clip slot, with retail's pre-building/per-building alpha barriers; -the post-world PView replay is deleted. - **Gate:** both sides — indoors with the opening behind the candle, and outdoors at the angle that previously erased it. diff --git a/docs/README.md b/docs/README.md index 71558c70..d8ab77b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -86,12 +86,6 @@ document in the same change; do not leave both claims standing. pipeline, the self-hosted runners, and how alpha releases are published. Load-sensitive tests live in `Lane=Timing`; see [`release-gate.md`](release-gate.md) before adding to it. -- [`launch-options.md`](launch-options.md) is the SSOT for every environment - variable and command-line argument the client reads, including what each one - changes about the run beyond its obvious effect. Read the side-effects column - before trusting any measurement. Enforced by - `LaunchOptionsDocumentationTests`: a flag without a row fails the build, and - so does a row whose read site was deleted. - [`audit/`](audit/) contains completion and conformance audits. - [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local ACE server's complete in-game command catalog and points to the authoritative diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 544d92d3..184e26e4 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -137,68 +137,6 @@ loads none). The headless adapter projects entity snapshots on demand from the canonical Runtime view, subscribes to Runtime's ordered events, and borrows the exact Runtime selection owner; it does not mirror gameplay state. -Graphical plugin panels are first-class retained windows. A plugin calls -`IUiRegistry.AddPanel` with a BCL-only `PluginPanelDescriptor`; Core's scoped -host authenticates the owner from the loaded manifest, and App derives the -stable identity `plugin:{pluginId}:{windowId}`. The host, not the plugin, owns -window geometry, z-order, persisted visibility, minimize/restore chrome, and -the shared right-edge plugin shelf. Minimizing only hides the presentation: -the plugin session, event subscriptions, automation policy, and binding object -remain live. Legacy `AddMarkupPanel` registrations are enriched into the same -first-class path, so API-v1 plugins keep working without a second lifecycle. -The markup vocabulary includes nested groups plus retained tab, toggle, -slider, editable-field, and retail-menu controls. Fields bind live -`Action` change/submit callbacks and menus bind an -`IEnumerable` plus selection callback, so plugin-owned profile/rule -editors stay behind the BCL contract instead of importing App widgets. These -are presentation bindings only and never become parallel gameplay owners. - -Durable plugin data uses the BCL-only `IPluginHost.Storage` contract. Core's -manifest-authenticated scoped host prefixes every logical key with the loaded -plugin id; graphical composition writes atomically beneath the per-user config -root (`plugins/{pluginId}/...`). Plugins receive neither another plugin's -namespace nor a machine-specific path. Hosts without durable storage expose -`NoOpPluginStorage` and report the capability unavailable. -The additive `List(prefix)` operation enumerates only keys inside that same -authenticated namespace, allowing plugins to discover explicit import/export -files without receiving a filesystem path or crossing plugin ownership. - -`IPluginHost.Automation` is the additive gameplay-automation projection. Its -character, spell, magic, chat, combat, equipment, item, loot, fellowship, -enchantment-observation, and navigation -groups contain BCL-only immutable snapshots plus attempt-style commands; the -graphical implementation borrows the exact `GameRuntime` -character/action/entity/object/vendor/fellowship owners. Item projections also -carry Virindi's stable ObjectClass plus ordered ObjDesc subpalette samples; -the graphical host resolves each representative RGB directly from portal DAT -using VTank's sample-index formula. MossTank owns all -macro policy (buff planning, target rules, selection scoring, corpse policy, -loot-rule ordering and action timing). In particular, `ICombatAutomation` -does not create a plugin combat model: each hostile capture is a detached -point-in-time projection of `RuntimeHostileTargetQuery`, and physical commands -enter the canonical `RuntimeCombatModeState` / `RuntimeCombatAttackState` -press-charge-release state machine. Item and loot commands similarly enter -App's one `ItemInteractionController`: appraisal, use/apply, pickup, -move/split/merge/drop/give, retail 0x027D salvage, and current-vendor sale -reuse the same readiness checks, reservations, wire sends, and authoritative -completion/object-table signals as retained retail UI. Plugins never hold an -optimistic inventory or vendor shadow. Navigation similarly projects live and -server-accepted position, portal/object state, and semantic movement levels; -the App host applies those levels through Runtime's one command interpreter. -Route sequencing, steering cones, follow breadcrumbs, checkpoint policy, -door/lockpick decisions and portal retry behavior remain plugin-owned. The -shared enchantment-observation group is deliberately a confirmed-cast timer -ledger rather than another authoritative spellbook: the host records successful -local duration casts and cooperating plugins can report their own confirmed -casts, matching VTank's `LogSpellCast` contract. It resets at session detach; -dispel/debuff policy remains plugin-owned. The -inert default remains -`NoOpAutomationSurface`, preserving one plugin code path on hosts without a -live gameplay session. -`ICharacterInfo.Name` projects the canonical local `ClientObject.Name` (empty -when unavailable) solely for per-character plugin profile scoping; it does not -introduce a second identity owner. - Core `SelectionState` is the sole selected-object owner for world, radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins; `IPluginHost.Selection` exposes that same state and retail-style old/new callback. @@ -394,19 +332,7 @@ src/ Installation/ -> portable four-DAT validation, Windows retail path discovery, versioned JSONL bake-process orchestration, and atomic SHA/size/tool-version - install-record verification and recovery; - startup discovery begins only after the desktop - window opens, and exceptional whole-pak hashing - reports its long-read status in that window; - content recipes resolve through one compiled - None/Overlay/FullRebuild/Verify migration ledger; - bounded changes build one cumulative filtered - overlay and publish `pak/content.current.json`, - while full rebuilds bake beside the live base and - swap only after candidate verification; a tiny - `pak/content.client-pending` gate survives a - crash/restart until the active client is - confirmed compatible; one + install-record verification and recovery; one OS-handle lease serializes recovery/install per DataDirectory; a second OS-held publication lock plus durable per-transaction nonce makes @@ -436,9 +362,7 @@ src/ is never persisted, and permits HTTP only for a loopback fixture; production remains pinned HTTPS ViewModels/ -> thin MVVM projection over Launcher.Core, - including the first-run DAT/bake wizard, explicit - world-data work confirmation (kind, reason, - free-space guidance, progress/cancellation), and + including the first-run DAT/bake wizard and nonfatal startup/manual update state, actions, progress, cancellation, rollback, and errors -> references Launcher.Core only (Platform transitively); it never owns @@ -448,28 +372,6 @@ src/ -> Linux launcher/probe/headless flows remain portable; graphical-client actions are explicitly disabled until Modern Runtime Slice L resumes - Prepared-content launch contract - -> `install.json` remains the strict backward-compatible base-pak authority - -> format 2 / recipe 6 is the current full-package contract: globally - shared texture payloads, independent adaptive blob compression, and - source-native BC1/2/3 for surfaces that require no pixel-local edit - -> format changes always use an explicit confirmed full rebuild; the - launcher preflights 2 GiB free space, shows long-work progress, validates - the candidate beside the active package, and promotes atomically - -> the prepared texture LRU is bounded to 64 MiB / 1,024 entries; GPU atlas - ownership remains in App and the whole-file mmap is virtual, not a - committed-copy cache - -> optional `pak/content.current.json` binds one cumulative overlay to the - base SHA; there is never an unbounded overlay chain - -> `pak/content.client-pending` prevents newly migrated content from - becoming launchable before the matching client check/install succeeds, - including across launcher restart - -> launcher session config carries base + optional overlay paths and both - recipe identities only for layered launches - -> App and Headless construct one `LayeredPreparedAssetSource`; overlay - Missing falls through to base, while overlay Corrupt is authoritative - for both render and collision reads - AcDream.Headless/ Linux/Windows no-window production host Program.cs -> CLI entry only Configuration/ -> strict versioned process/session config @@ -491,10 +393,6 @@ src/ IGameState.cs -> done IEvents.cs -> done ISelectionService.cs -> done - IPluginStorage.cs -> manifest-scoped durable text profiles - Automation.cs -> character/spell/magic/chat automation groups - CombatAutomation.cs -> hostile snapshots + retail combat attempts - EnchantmentAutomation.cs -> shared confirmed duration-cast timer ledger AcDream.App/ Layer 1 + Layer 4 wiring Platform/ diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0fec343b..6874bb33 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-22 **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) — 86 active rows (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- -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 @@ -111,8 +111,6 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| AD-115 | **Filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16), classification: intentional.** `AppraisalUiController.BuildCharacterTitleDisplay` composes examination element `0x10000151` (Profession/title): when Int 261 `CharacterTitleId` is absent/unresolvable AND String 5 `Template` is also absent, it returns an empty string, and `ClearCreatureText` has already blanked the element for this `ApplyCreature` call, so the element stays cleared. Retail never clears `0x10000150`/`0x10000151`/`0x10000152` anywhere — neither `CharExamineUI::Show @0x004AB5D0` nor `BasicCreatureExamineUI::Init @0x004AB9C0` writes an empty string to those elements — so in this exact case retail would keep showing the PREVIOUS assessed target's title text on screen instead of clearing it. | `src/AcDream.App/UI/Layout/AppraisalUiController.cs` (`BuildCharacterTitleDisplay`, `ClearCreatureText`) | Deliberate improvement over retail's quirk: a stale leftover title from a prior target reads as more confusing/wrong to a player than a blank line for the current one; review F16 (2026-08-25) accepted the clear-on-no-source behavior as intentional. | None expected — this is a deliberate, reviewed divergence, not a game-feel regression; a future retail-faithfulness audit assuming `0x10000151` always mirrors retail's persistent stale-text behavior would be surprised to see it clear instead when the current target's title can't be resolved. | `CharExamineUI::Show @0x004AB5D0`; `BasicCreatureExamineUI::Init @0x004AB9C0` | -| AD-114 | **Filed 2026-08-25 at Campaign AS slice AS2, owner-ruled 2026-08-25 (verbatim "we animate it, and I like it").** acdream's examination-window preview (`CreatureAppraisalFramePresenter` / `RetailCreatureAppraisalCloneFactory`) shares the assessed target's already-resolved live MeshRefs and re-synchronizes them every frame, so the preview clone plays the SAME current animated pose the live target is actually doing right now (attack, cast, run, idle, ...). Retail's `BasicCreatureExamineUI::Init @0x004AB9C0` instead clones the selected physics object ONCE, fixes its heading at 191.367905°, and lets its own private `CreatureMode` animate that clone independently — decoupled from whatever the live target is currently doing. | `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs` (`CreatureAppraisalFramePresenter`, `RetailCreatureAppraisalCloneFactory`) | Explicit owner direction, 2026-08-25 (`docs/plans/2026-08-25-assess-window-parity-campaign.md`: "The animated 3D paperdoll is an INTENTIONAL acdream deviation... Keep it"), noted alongside the owner's own observation that retail's static-clone colors are buggy — porting the decoupled-motion clone would not even be a faithfulness win here. | None expected — a deliberate, user-approved visual improvement over retail's decoupled clone motion, not a game-feel divergence; a future faithfulness audit assuming the preview mirrors retail's independent `CreatureMode` cycle would be surprised to see it track the live target's pose instead. | `BasicCreatureExamineUI::Init @0x004AB9C0`; `docs/plans/2026-08-25-assess-window-parity-campaign.md` | | AD-112 | **Filed 2026-08-23 with the sky default-script port (issues #28/#2, phase C.1.5c).** Retail plays the sky carriers' aurora/lightning/thunder PES by creating real `CPhysicsObj`s in the viewer-centered sky cells (`GameSky::MakeObject @0x00506EE0` → `CPhysicsObj::makeObject @0x00513970`; the Setup's `DefaultScript` marks `state \|= 0x80000` and `CPhysicsObj::animate_static_object @0x00513DF0` ticks `ScriptManager::UpdateScripts` + the ParticleManager). acdream plays the same scripts through `PhysicsScriptRunner` synthetic owners anchored at the camera (`SkyPesFrameController`), pass-routed by `props & 1` into the existing SkyPreScene/SkyPostScene particle draws — no sky-cell physics objects exist. Slot persistence keys on (index, gfx id, properties), the `CreateDeletePhysicsObjects @0x005073C0` identity law. The heading/rotation → pose axis mapping uses `UnitY` where retail's `GameSky::CalcFrame @0x00506F80` runs `set_heading` + `grotate`; every current PES carrier authors 0°/0° so the axis is unexercised. | `src/AcDream.App/Rendering/SkyPesFrameController.cs`; `src/AcDream.Core/World/SkyDescLoader.cs` (`SkyObjectData.DefaultScriptId`) | Retail's sky cells are camera-centered, so a camera-anchored world-space owner is the same geometry; the script/emitter engines are the production ones shared with entity effects; `SkyPesFrameControllerTests` pins the identity/persistence/window lifecycle. | A sky carrier authored with nonzero heading/rotation angles would orbit around the wrong axis; a PES whose hooks depend on real part frames (part_index targeting) would find only the synthetic owner's root pose. | `GameSky::MakeObject @0x00506EE0`; `GameSky::CreateDeletePhysicsObjects @0x005073C0`; `CPhysicsObj::animate_static_object @0x00513DF0`; `docs/research/2026-08-23-sky-default-script-port.md`; `SkyPesFrameControllerTests` | | AD-111 | **Filed 2026-08-17 at the systemic escape-normalization round (commit 967b9c57).** The appraisal report's WIRE-string shaping (`ItemAppraisalTextLayout.Shape`) converts a literal two-character ` ` in server-sent fragment text (long description, use text) into a real line break. Retail does NOT: `ItemExamineUI::AddItemInfo @ 0x004AC050` hands wire text straight to `UIElement_Text::AppendTextWithFont` with no `StringTableMetaLanguage::UnescapeString` pass (that decode belongs to `StringInfo` resolution — DAT/authored strings — which the same round ported to `DatStringResolver`/`RetailStringEscapes` as the single source decode), so retail renders a wire backslash-n literally. Pre-existing behavior documented as wire-domain at the same round (it shipped inside the user-accepted Slice-3 assessment surface); the sibling inscription path (`IndicatorDetailText.Shape`) was returned to retail-verbatim in the same commit. | `src/AcDream.App/UI/Layout/ItemAppraisalReport.cs` (`ItemAppraisalTextLayout.Shape`'s domain-commented replace) | Accommodates literal " @@ -211,40 +209,17 @@ readiness/requeue adaptation. See | AD-100 | **Filed 2026-08-15 at the Campaign CC CC2 review, finding F2 (unrequested `0xF643` handling).** When a `0xF643` (`CharGenVerificationResponse`) arrives with NO outstanding create/restore request, acdream DROPS the message with a once-per-session stderr log. Retail has no such gate: `Handle_CharGenVerificationResponse @0x0055E8B0` processes whatever arrives, discriminating create-vs-restore by its OWN persistent verification state (case 1 branches on `GetVerificationState() == PENDING` → new `CharacterIdentity` + `AddIdentity`, else unpacks into the existing identity at `slot`) — an unsolicited reply would be applied against whatever that state happens to be. acdream's transport-level latch (`PendingCharGenVerificationRequest`) is the equivalent discriminator, but when it is `None` there is no state to apply the reply against, so the honest move is drop-and-log rather than guessing a family. | `src/AcDream.Core.Net/WorldSession.cs` (the `CharGenVerificationResponse.ResponseOpcode` arm in `ProcessDatagram`; `_loggedUnexpectedCharGenVerificationResponse`) | Processing an unsolicited reply requires retail's persistent chargen verification state, which lives in CC3's Runtime owner, not the transport. Until then a reply with no outstanding request is either a server bug or a latch-lifecycle bug on our side — surfacing it in the log beats silently misrouting it to an arbitrary event. Pinned by `WorldSessionCharacterCreationTests.ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed`. | A server that sends a spontaneous/duplicate `0xF643` (ACE can double-send NameInUse — see the CC2 review's F3 note) has its second copy dropped here, where retail would re-process it. If CC3's verification gate ever needs retail's re-process semantics, this drop must move behind that owner's state. | `Handle_CharGenVerificationResponse @0x0055E8B0`; `CharGenState::GetVerificationState`; CC2 review F2 (2026-08-15) | | AD-102 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Heritage page's Viamontian button and the Town page's Sanamar button).** Retail gates BOTH controls behind `CPlayerSystem::AccountHasThroneOfDestiny`: `gmCGHeritagePage::ListenToElementMessage @ 0x00483860` shows `MakeToDWarningDialog` instead of selecting Viamontian (element `0x100003c3`) for a non-ToD account, and `gmCGTownPage::ListenToElementMessage @ 0x0047c480` does the same for Sanamar (element `0x1000040b`, `startArea` index 3 — also the reason `CharGenState::RandomizeStartArea`'s ToD-aware `RandInt(3 or 4)` bound exists). acdream's `ChargenOptions` (CC1) carries no account/DLC-ownership signal anywhere in the model, so both controls ship WITHOUT the gate — every installed heritage/town in `Options.HeritagesById`/`Options.StarterAreas` is always selectable, matching what a ToD-owning account would see. | `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`HeritageByButtonId[0x100003C3u]`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`StartAreaByButtonId[0x1000040Bu]`, `Randomize`) | ACE's server-side `CharacterCreate` handler never checks ToD ownership either (the field is purely a retail-client UI gate), so accepting the selection unconditionally never produces a request the emulator would reject; adding an account-ownership model to CC1's DAT-only `ChargenOptions` is out of this slice's scope and would need its own design (where does the "ToD owned" bit come from — account service, launcher config, a new env flag?). | None observable against ACE. A future retail-parity gate that specifically checks "does a non-ToD account get warned off Viamontian/Sanamar" will fail until an account-ownership signal exists to gate on. | `gmCGHeritagePage::ListenToElementMessage @ 0x00483860`; `gmCGTownPage::ListenToElementMessage @ 0x0047c480`; `gmCGTownPage::SetTown @ 0x0047c360`; `CharGenState::RandomizeStartArea` (DoRandom case 4, `RandInt(hasToD ? 4 : 3)`) | | AD-99 | **Filed 2026-08-15 at Campaign LA gate round 2 finding 1 (character-select Exit button).** On a confirmed Exit, acdream closes the client through the existing graceful window-close path (`d.Window.Close`, the same seam `GameplayInputCommandController`'s in-world Escape fallback already uses) instead of retail's real post-confirm behavior: `RecvNotice_CloseDialog`'s case-1 arm queues UI mode `0x10000009`, which `gmEpilogueUI::Register` claims — a brief epilogue/farewell screen — before the process actually terminates. The confirmation dialog itself (`MakeConfirmExitDialog`, its exact `ID_CharacterManagement_ConfirmExit` text, and the `m_confirmExitDialogContext != 0` re-entry guard) IS ported faithfully; only the post-confirm destination differs, the same shape as AD-74's Options-panel exit. | `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (`RequestExit`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`CharacterSelectionRuntimeBindings.RequestExit`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (`d.Window.Close` binding) | acdream has no `gmEpilogueUI` port (out of scope this round); reusing the ONE existing graceful-shutdown seam keeps `disconnected`/`exited` status events firing through `GameWindow.OnClosing` → `CompleteShutdown` rather than inventing a second shutdown path, per explicit direction for this finding. | A user confirming Exit sees the window close immediately instead of retail's brief epilogue screen; a future feature wanting to reproduce that screen (or an intermediate "logged off, returned to character select" state) has no seam yet — same gap class as AD-44. | `gmCharacterManagementUI::MakeConfirmExitDialog @0x004ed250`; `RecvNotice_CloseDialog @0x004ed760` case 1; `gmEpilogueUI::Register(0x10000009)` @0x0047a680; `gmCharacterManagementUI::OnAction @0x004ed410` (Escape key, unported — button-only this round) | -| AD-113 | **Filed 2026-08-25 at Campaign CT slice CT-GF1 (client-wide retained-UI ancestor clip).** Porting retail's `UIRegion::DrawHere @0x0069FA30` ancestor-clip intersection (an element's screen rect is intersected against the FULL inherited clip-rect chain and the subtree is skipped when the intersection is empty — the `var_24` gate @0x0069FB8E) as `UiElement.ClipsChildren`'s new client-wide default (true, threaded through the pre-existing `UiRenderContext.PushClip`/`PopClip`) needed one deliberate opt-out: retail spawns a menu's dropdown popup as a SEPARATE top-level region (`UIElement_Menu::MakePopup`), clipped only by the screen, while acdream's `UiMenu` draws its popup INLINE from the owning button in a second traversal (`OnDrawOverlay`, pre-existing, "regardless of this element's position in the tree" by its own doc comment). Without an escape, the new ancestor clip would wrongly cut off a popup that legitimately extends outside its own (possibly short) owning window — e.g. a channel dropdown opened upward past a short chat window's top edge. `UiElement.ExpandsClipForPopup` (default false) resets the accumulated clip to the full CANVAS rect (0,0,ScreenSize) — SCREEN-clipped, not truly unbounded, matching retail's own popup region (`UIElement_Menu::MakePopup` spawns a top-level region bounded by the screen) — for exactly the `OnDrawOverlay` call of an opted-in element (`UiRenderContext.PushClipUnbounded`, sharing the existing clip stack; corrected from an earlier `null`/unbounded clip at the CT-GF1 fix round); `UiMenu` overrides it true, paired with `ClipsChildren => false` so its own out-of-bounds `OnHitTest` union (the popup occupies `ly < 0` or `ly >= Height` depending on open direction) stays reachable through the same early-bounds gate that now defaults on for every other element. | `src/AcDream.App/UI/UiElement.cs` (`ClipsChildren`, `ExpandsClipForPopup`, `DrawOverlays`); `src/AcDream.App/UI/UiRenderContext.cs` (`PushClipUnbounded`); `src/AcDream.App/UI/UiMenu.cs` (the two overrides) | The popup is the ONLY overlay-drawing widget in the tree today (grep-confirmed: exactly one `OnDrawOverlay` override client-wide), and it already renders on top of the whole UI by construction (the overlay pass beats even rect backgrounds), so exempting it from the ancestor clip matches its existing "regardless of tree position" contract rather than introducing new behavior. | A future `OnDrawOverlay` override that is NOT a screen-anchored popup (e.g. an in-place highlight meant to stay window-clipped) would silently escape every ancestor's clip if it left `ExpandsClipForPopup` at its default; the opt-in default direction makes that the exception rather than the rule, but a widget that WANTS window-clipped overlay content has no dedicated seam beyond simply not overriding the escape. | `UIRegion::DrawHere @0x0069FA30`; `UIElement_Menu::MakePopup`; the register's own AP-201 retirement note (the FIRST `ClipsChildren`/`PushClip` port, for `UiScrollablePanel`'s viewport) | --- -## 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) +## 3. Documented approximation (AP) — 163 active rows (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-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; 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) 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. -**AP-161 correction and narrowing (2026-08-26, #444/#445):** the old F6 -full-stack conclusion and the row's `m_last_sale` conclusion are superseded. -`VendorSellUI::AcceptDragObject @ 0x004C4F00` splits the live selected -quantity, temporarily stages the source, then substitutes the new matching -WCID/quantity object; acdream now ports that flow through the canonical -inventory owner. `gmVendorUI::BuySingleItem @ 0x004C2820` and Buy All both -assign the purchase value to `m_last_sale`; purse/cost/affordability now -subtract it immediately and reconcile to authoritative owned-currency -objects. Those two residuals are closed. The closed-dropdown arrow-cap -cosmetic is AP-161's only live item; the long row below remains as historical -research chronology and must be read through this correction. - -**Vendor double-click correction (2026-08-26):** direct reading of -`gmVendorUI::HandleMousePresses @ 0x004C40D0` disproved the earlier -absence-of-symbol inference embedded in AP-161 and AP-171. Retail directly -buys a browse row on double-click and removes staged Buying/Selling rows in -that same mouse dispatcher. AP-171 is retired; the old AP-161 sentence saying -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. - | # | 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` | | AP-234 | **Filed 2026-08-23 at the #426 solid-face extraction fix.** Cell-wall (EnvCell/CellStruct) geometry approximates retail's "skip untextured subsets inside a cell interior" with the polygon's own `Stippling.NoPos` flag rather than resolving the Surface's own `Type` (`Base1Image`/`Base1ClipMap`) before the per-polygon draw decision — the same NoPos-vs-surface-type conflation #426 fixed for ordinary GfxObj extraction (`PrepareGfxObjMeshData`/`GfxObjMesh.Build`), deliberately LEFT in place here | `src/AcDream.Core/Meshing/CellMesh.cs:45`; `src/AcDream.Content/MeshExtractor.cs`'s `PrepareCellStructMeshData` `hasPos` gate carries the identical rule | Cells are the one retail context that genuinely skips untextured subsets (`DrawEnvCell`), so approximating "untextured" with NoPos is directionally correct for the common case — a solid-colour polygon always carries NoPos since it has no UVs to carry; resolving Surface.Type first would need a per-polygon dat lookup this code doesn't currently perform before the emit/skip decision | A textured polygon whose author left NoPos set (no positive UVs authored despite a real texture) would be wrongly skipped as if untextured, or an untextured polygon whose author left NoPos unset would wrongly draw — either edge case shows as a cell wall gaining or losing a face relative to retail | `RenderDeviceD3D::DrawEnvCell` @0x0059f170 → `D3DPolyRender::DrawMesh(..., arg4=1)`; `RetailUntexturedSurfacePolicy`/`RetailUntexturedSubsetPolicy` (`src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs`) | | 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` | @@ -259,9 +234,9 @@ heading text. | AP-206 | **Filed 2026-08-11 at Campaign OP gate 4 (#382).** `UiButton.TrySetRetailState`'s DirectStateId branch now requires REAL `""`-keyed media (`HasStateMedia("")`) before accepting a DirectState transition; a `_mediaInfo.States` entry that exists ONLY as a property bag (every button carries one, holding ToggleBehavior/RolloverEnabled/etc regardless of whether it authors blank media) no longer counts. A reference-identity-verified live-DAT probe found the chat window's four floating-window indicator buttons (`0x10000522`-`0x10000525`) resolve their own correct `ActiveState="Normal"` at construction, then get blanked to `""` moments later in the SAME `LayoutImporter.Build` call: the indicator column's backing panel (`0x10000600`) authors `PassToChildren=true` on its own empty DirectState (confirmed live: `States[0xFFFFFFFF].PassToChildren == true`), and `LayoutImporter.BuildWidget`'s post-attach state reapply (needed so retained PassToChildren TABS get their authored Open/Closed child media) cascades that DirectState to every `IUiDatStateful` child — including these already-correctly-resolved buttons. Retail's own decompiled `UIElement::SetState @0x00464e70` commits its `m_curStateDesc`/`m_state` unconditionally once `ElementDesc::AccessStateDesc` finds ANY StateDesc (media or not) and does the exact same blind per-child cascade; retail avoids this exact bug purely through construction TIMING — `UIElement::Initialize`'s `SetState(m_defaultState)` call is the SECOND operation in the function, before any child-tree construction, so a PassToChildren cascade fired during import always iterates zero children in retail. Our port's `LayoutImporter.BuildWidget` deliberately reapplies AFTER children are attached (the opposite order), so this literal 1:1 state-machine port needed a compensating guard rather than a full reapply-ordering rewrite (out of scope for this fix; `CharacterStatController`'s own three-chrome-children PassToChildren cascade depends on the current ordering and is left untouched). | `src/AcDream.App/UI/UiButton.cs` (`TrySetRetailState`'s `stateId == UiStateInfo.DirectStateId` branch) | Scoped to `UiButton` only — `UiDatElement.TrySetRetailState`'s parallel DirectStateId branch (and the cascade mechanism itself) are UNCHANGED, so every existing PassToChildren consumer keeps its current behavior; the fix only stops an UNRELATED ancestor's cascade from overriding a button's OWN already-resolved, independently authored state with an empty one it never asked for. | If a future button is EVER meant to render literally blank at rest via a cascaded DirectState with no authored `""` media, this guard would reject that transition (falls back to its previous `ActiveState`) — no such button is known to exist today; `UiButtonTests.DirectStateTransition_WithRealMedia_StillSucceeds` documents that an AUTHORED blank state still works. | `UIElement::SetState @0x00464e70` (cascade + unconditional commit); `UIElement::Initialize @0x00462c90` (SetState call precedes child construction) — both in `docs/research/named-retail/acclient_2013_pseudo_c.txt` | | AP-205 | **Filed 2026-08-11 at Campaign OP gate 4 (#381).** The Apply/Reset/Defaults footer on the Character/Chat/Config tabs draws an opaque, borderless backing field (`UiSolidSpriteFill`, tiling `RetailChromeSprites.CenterFill` — the SAME panel-background sprite the Options window's own `UiNineSlicePanel` chrome already tiles behind everything) behind the three buttons. A live-DAT probe (scratch console app against `DatCollectionAdapter`, 2026-08-11) found retail authors NO such element: each page root (`0x100001F9`/`0x100001FF`/`0x1000050A`) has EXACTLY five children — the row ListBox, its scrollbar, and the three physical buttons — with zero direct-state media on the root itself. Scrolled row content therefore bled through visibly between/behind the buttons before this fix. | `src/AcDream.App/UI/UiSolidSpriteFill.cs`; `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (`AddFooterBacking`) | Reusing the SAME sprite the rest of the window's chrome already draws keeps the synthesized field visually indistinguishable from an authored one rather than inventing a new color; the field is `ClickThrough=true` and z-ordered strictly behind every other child, so it cannot intercept input or occlude the buttons themselves. | A reviewer comparing a byte-exact retail screenshot to acdream will see one extra opaque rect retail never authors — cosmetically invisible (it exactly matches the surrounding chrome), so the only observable difference IS the fix (content no longer bleeding through). If a future page's footer strip ever needs a DIFFERENT background (a themed panel, a translucent tab), this hardcoded `CenterFill` reuse would need revisiting. | Live-DAT probe, 2026-08-11 (page-root child-count/direct-state-media dump against `client_local_English.dat`, LayoutDescs `0x21000028`/`0x21000029`/`0x2100005C`) — no retail element to cite since none exists | | ~~AP-201~~ | **RETIRED 2026-08-11 at the Campaign OP gate-3 fix round (closes #371).** UiScrollablePanel now marks ClipsChildren=true (the draw walk and hit-test both route through UiRenderContext.PushClip, which existed by retirement time) and its cull predicate keeps any INTERSECTING row visible - a straddling row renders its visible slice instead of vanishing whole. The user-observed symptom this row predicted (the Chat tab per-window filter blocks reading as MISSING at the default scroll offset, gate 3) is the exact acceptance evidence. Original filing follows for the record: filed at the OP5 review-fix round (S2), predates OP5 but was made user-visible by it. `UiTemplateListBox`'s internal row viewport (`UiScrollablePanel.LayoutScrollableChildren`) culls a child WHOLE — `child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f` — rather than clipping the visible portion of a row that straddles the viewport edge, because the UI renderer has no scissor stack. Retail's own `UIElement_ListBox`/scroll-region rendering clips partially-visible rows at the pixel boundary, same as any native scroll view. Every row in this viewport was 8-36px until Campaign OP slice OP5 added five self-sized filter blocks (12x20=240px / 13x20=260px, AP-195) to the Chat tab's ~560px viewport; a 240-260px block straddling the viewport edge at a given scroll offset now disappears ENTIRELY (a visible "pop") instead of clipping, where the pre-OP5 8-36px rows made the same all-or-nothing cull read as ordinary row-granular scrolling. | `src/AcDream.App/UI/UiScrollablePanel.cs:69` (the cull predicate); consumed by `src/AcDream.App/UI/UiTemplateListBox.cs` (`Viewport`) — the Character/Chat/Config Options-panel tabs and any other controller-built row list sharing this viewport | A scissor stack does not exist anywhere in the retained-UI renderer yet (class's own doc comment, `UiScrollablePanel.cs:8-12`, predates this row); whole-row culling is a correct, cheap stand-in for every list whose rows are small relative to the viewport, which was true for every consumer before OP5. | A tall block (any future row taller than roughly the viewport's own height, not just OP5's filter blocks) can vanish completely for a range of scroll offsets instead of showing a partial view — the OP5 gate script's own step 2 documents the exact symptom so it is not mistaken for a self-sizing regression (`docs/research/2026-08-11-campaign-op-test-script.md`). Scrolling further always restores the block whole; no data or state is lost, only the presentation pops. | No scissor-stack retail oracle needed — this is a stand-in for ordinary native clip-rect rendering every GUI toolkit (including retail's own) provides; issue #371 tracks adding a real per-row clip rect to `UiScrollablePanel` | -| ~~AP-202~~ | ~~**Keyboard .keymap file import/export remains intentionally deferred.**~~ **RETIRED 2026-08-26 by #446.** Load File and Save As now use retail's authored type-7/type-5 dialogs and the Sept-2013 PFile grammar; profiles live under `Documents\Asheron's Call`, the selected profile loads at startup and rewrites on graceful shutdown, overwrite/read-only handling is live, and all 306 user-bindable identities round-trip. `%LOCALAPPDATA%\acdream\keybinds.json` remains only the host-command compatibility mirror. | ✅ RETIRED — codec, profile-store, controller, and installed-DAT gates landed. | -| ~~AP-203~~ | ~~**Configure Keyboard exposed rows without production consumers.**~~ **RETIRED 2026-08-26 by #446.** All 306 installed-DAT ActionMap rows resolve to distinct `InputAction` identities and concrete production consumers; the compatibility sibling store is retained only for unknown future-DAT rows. The installed-DAT identity, default, mount, and routing gates pin 306/306. | ✅ RETIRED — evidence: `docs/research/2026-08-26-retail-keyboard-routing-audit.md`. | -| ~~AP-204~~ | ~~**Configure Keyboard capture/conflict behavior diverged from retail.**~~ **RETIRED and corrected 2026-08-26 by #446.** Capture instructions, unsupported-input retry, same-row no-op, dense two-slot insertion, exact priority conflict/non-bindable dialogs, overwrite behavior, dirty-only Revert state, Defaults, Apply/OK, Cancel, and persistence now follow the named retail routines. `idMessage 0x19, dwParam1=7` is the authored button action/release event; the decomp supplies no right-click evidence. | ✅ RETIRED — named-retail conformance and controller tests landed. | +| AP-202 | **Filed 2026-08-11 at Campaign OP slice OP8 (D4).** Configure Keyboard persists every rebind to `keybinds.json` only. Retail's own storage is a `\Asheron's Call\.keymap` text file (`CInputManager_WIN32::SaveKeyMap @0x00686C20`, `PFileParser`), with Load-File/Save-As buttons for NAMED keymap profiles and a `keymap` key in `UserPreferences.ini` selecting which one loads at startup (research doc §5.7). D4 chose the existing, tested `keybinds.json` schema over building a second `PFileParser`-compatible text codec + named-profile management; this row's the Load File/Save As buttons on the Configure Keyboard screen (`0x10000027`/`0x10000029`) are wired but INERT. | `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`WireScreenButtons`'s Load/Save-As no-op); `src/AcDream.UI.Abstractions/Input/KeyBindings.cs` (`SaveToFile`/`LoadOrDefault`) | `keybinds.json` already round-trips every retail action this screen can bind (identity table + the DAT-defaults conformance test), so the ONLY capability lost is exchanging `.keymap` files with a real retail client or another acdream install by named profile — a real feature gap, not a correctness gap. | A user who expects to export/import a named `.keymap` profile (e.g. to share a control layout with a retail-client friend) cannot; every rebind still works and persists locally. | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.7-§5.8; `CInputManager_WIN32::SaveKeyMap @0x00686C20`; `gmKeyboardUI::SaveKeymap @0x004DCF90` | +| AP-203 | **Filed 2026-08-11 at Campaign OP slice OP8.** Of the DAT ActionMap's 306 user-bindable rows, `RetailActionIdentityTable` (`src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs`) resolves roughly half to a live acdream `InputAction`; the rest render, bind, conflict-check, and persist (via `RetailUnmappedKeyBindings`, a sibling `*-unmapped.json` file) exactly like any other row, but have no live gameplay consumer to dispatch through. The two largest classes: 82 of 87 Emote rows (only Cry/Laugh/Cheer/Wave/PointState dispatch an animation today — acdream has no general emote-animation player), and all 48 CharacterSettings hotkey rows (ctx `0x10000008` — these are hotkeys for the SAME `PlayerOption`/`CharacterOptions` preference bits OP1's `CharacterOptionTable` and OP4's Character-tab checkboxes already model; wiring "press this key, flip that same server-synced bit" is a real feature, a hotkey-to-option-toggle dispatcher, that does not exist anywhere in acdream yet). Smaller residuals: Spell Slot 10-12, Quickslot 10-13 (both hit a PRE-EXISTING `InputAction` enum gap this slice did not introduce), and roughly twenty UI-panel-toggle rows for panels acdream has no analog for (Vitae/Link Status/House/Map/Character Info/the two Magic panels/...). | `src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs` (class doc has the full accounting); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`CurrentForUnmapped`/`SetForUnmapped`) | Guessing a mapping for an ambiguous row risks silently misrouting a rebind to the wrong gameplay action (worse than an honest "not wired yet" — the identity table's own class doc states this directly); every mapping that WAS added was cross-verified two ways (label match + DAT-default-vs-`KeyBindings.RetailDefaults()` byte match, see `RetailActionIdentityRoundTripTests`). | A user rebinds e.g. an emote or a CharacterSettings hotkey on the Configure Keyboard screen and the binding persists but has no observable in-game effect — matches retail's OWN screen shape (the row exists, is bindable) while honestly lacking retail's gameplay behavior behind it. ADDENDUM (2026-08-11, OP8 re-review round 2): this row's scope EXPLICITLY includes the ten CameraAlternateControls (InputMap 0x6) rows the M2 de-alias narrowed to store-only — a case the generic wording understated because their SIBLING rows (InputMap 0x5, the same verbs) ARE live on the same screen: the 0x6 rows display their DAT-default arrow keys (display-only seeding), persist user edits, and drive nothing; only the 0x5 scheme reaches the InputDispatcher. Store-only rows are also EXCLUDED from the conflict universe (they cannot actually collide) — mapped cross-context sharing remains ISSUES #373. | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.1-§5.3; live-DAT probe 2026-08-11 (306-row/six-ActionClass accounting, `RetailActionMapReaderTests`) | +| ~~AP-204~~ | **RETIRED 2026-08-11 at the OP8 rework (M3, combined review).** Originally filed for two narrowings: (1) silent auto-reassign on a cross-row conflict instead of retail's modal `OpenOverwriteBindingDialog`, and (2) OK/Cancel wired as left-click instead of retail's right-click-release gesture. (1) is FIXED — `KeyboardConfigController.BeginSlotCapture` now opens a real confirm dialog through `RetailDialogFactory.MakeConfirmation` (the SAME seam `GameplayConfirmationController` uses) BEFORE reassigning, listing every conflicting row (N-way), and only applies on accept; decline leaves every row untouched. (2) is NOT fixed and does not warrant its own row: it is authored-input-only with zero observable difference to a user (retail's own right-click-release on just this pair of buttons carries no distinguishing visual cue either, and every other Campaign OP button already uses left-click) — noted as a code comment at the OK/Cancel wiring site instead of a register row, matching this register's convention of reserving rows for divergences that could produce an observable symptom. | `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`FindConflicts`/`BeginSlotCapture`; `WireScreenButtons`'s OK/Cancel `OnClick` comment); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountKeyboardConfig`'s `ConfirmOverwrite` wiring) | — | — | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.4 (`UIOption_ActionKeyMap::KeyHitHandler @0x00489570`, `OpenOverwriteBindingDialog @0x00488BF0`, `OpenCantOverwriteBindingDialog @0x00489300`) and §5.5 (OK/Cancel `idMessage 0x19` gesture) | | AP-194 | `CharacterOptionTable`'s `ClientDefault` column (what the Character tab's Defaults button restores) disagrees with the raw constructor default word for three ids: `ConfirmVolatileRareUse` (`0x2D`), `ShowHelm` (`0x2F`), and `ShowCloak` (`0x32`) are all ON in retail's constructor default `CharacterOptions2 = 0x00948700` (`PlayerModule::PlayerModule @0x005D51F0`, byte-verified literal write) but report default-OFF via `PlayerModule::GetDefaultOptionValue @0x005D2A30`, whose own per-option table stops at id `0x2A` and returns `false` for everything past it. This is retail's OWN behavior, reproduced deliberately — the Defaults button does not reproduce a fresh `PlayerModule`. **CONFIRMED 2026-08-11 at Campaign OP slice OP4**: `CharacterOptionsPageController` seeds every `BoolOptionRow`'s default directly from this column (`EveryRow_DefaultValue_MatchesCharacterOptionTableClientDefault`, `tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs`); the directive below was followed, not re-litigated. OP4 also independently traced retail's OWN mechanism for the Character tab specifically — `UIOption_Checkbox::SetPlayerOption @0x00486e80` (pseudo-C line 147375) sets `m_default` directly from `GetDefaultOptionValue`, confirming this column (not the separate `DBPropertyCollection`/`InqDefaultGameplayOptionProperty` mechanism that governs the Chat/Config tabs' `m_propName`-bound rows) is the correct and ONLY source for this tab. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`ClientDefault` column; see the type's XML doc); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` | Byte-verified at both addresses (wire research §2.5 for the constructor literals, §8.2 for `GetDefaultOptionValue`'s own table and bounds check) — this is not a guess, it is retail's documented quirk. "Fixing" it to match the constructor default would make acdream's Defaults button MORE correct than retail's own, which is the opposite of this project's goal. | A future OP-campaign slice (OP4, the Character tab's Defaults button) must consult THIS column, not the constructor default word, or a future reader may "fix" this back and silently diverge from retail. | `PlayerModule::GetDefaultOptionValue @0x005D2A30`; `UIOption_Checkbox::SetPlayerOption @0x00486e80` (N-4 anchor-column correction, OP4 review-fix round 2026-08-11 — was mislabeled `PlayerModule::SetPlayerOption`, same address, wrong class); `PlayerModule::PlayerModule @0x005D51F0`; `docs/research/2026-08-10-set-character-options-wire.md` §8.2 | | AP-193 | Character option id `0x34` (`ListenToPKDeathMessages` / "Listen to PK death messages") is mapped to `CharacterOptions2` bit `0x02000000` and modeled as a batched (non-auto-save) option purely on ACE's own enum — the id does not exist in the 2013 EoR PDB (`PlayerOption` there terminates at `TotalNumberOfPlayerOptions_PlayerOption = 0x34`), so neither the mask nor its `IsAutoSaveOption`/`GetDefaultOptionValue` classification is byte-verifiable against our binary. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`HearPkDeathMessages` row) | The user's retail memory (and ACE's own `CharacterOption` enum) both carry this option; shipping wire+store coverage for it is strictly better than omitting the row the Character tab's screenshots show, and ACE never actually reads the bit server-side (`PlayerFactory.cs:659-660` — "possibly was added to Defaults post PDB we have"), so a wrong id/mask/auto-save guess here has zero server-observable consequence either way. | If the final EoR client's real id/mask/auto-save classification ever surfaces (a later PDB, or a byte-level trace against a 2015+ binary), this row's values may be wrong and need correcting — until then treat them as ACE-sourced, not retail-verified. | ACE `PlayerFactory.cs:659-660`, `CharacterOptions2.cs` (`ListenToPKDeathMessages = 0x02000000`); `named-retail/acclient.h:4162-4218` (2013 `PlayerOption` terminates at `0x34`); `docs/research/2026-08-10-set-character-options-wire.md` §8.1 | | ~~AP-196~~ | **RETIRED 2026-08-11 at Campaign OP slice OP9.** Filed at the OP4 review-fix round (MUST-FIX 3/blast M2) recording that OP4's Group-C re-point deleted only three of the eight re-pointed `GameplaySettings` fields (`AutoTarget`/`AutoRepeatAttack`/`ViewCombatTarget`), leaving `VividTargetingIndicator`/`CoordinatesOnRadar`/`LockUI`/`AcceptLootPermits`/`ToggleRun` behind as WRITE-BEHIND `settings.json` persistence/draft mirrors of the now-authoritative server bit (plus a "two writable copies" default-source change, ADDENDUM historical only). OP9 verified all remaining `GameplaySettings` members — those five plus `ShowTooltips`/`SideBySideVitals`/`SpellDuration`/`AllowGive`/`ShowHelm`/`ShowCloak`/`AdvancedCombatUI`/`UseMouseTurning`, 13 total — already had a live server-bit home in `RuntimeCharacterOptionsState` (11 as OP4 Character-tab rows through `CharacterOptionTable`/`CharacterOptionsPageController`; `LockUI` through `/lockui` + the PlayerDescription `SetUiLocked` convergence, deliberately not a Character-tab row; `UseMouseTurning` through the Gameplay-tab mouse-macro button + the Config tab's Use-Mouse-Turning row — OP9 review NIT 6's channel-attribution correction) and deleted the `GameplaySettings` record outright — the type, the `SettingsStore.LoadGameplay`/`SaveGameplay` plumbing, and `RuntimeSettingsController`'s `Gameplay` property/`SetAcceptLootPermits` write-behind method — closing the "two writable copies" gap for good: there is no longer a second store to diverge from server truth. | `src/AcDream.App/Settings/RuntimeSettingsController.cs`; `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs`; `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | — | — | `docs/research/2026-08-10-character-options-map.md` §7.1/§7.2 (Group C re-point directive); `CharacterOptionTable.cs` | @@ -352,7 +327,7 @@ heading text. | AP-129 | **NARROWED 2026-07-30 (P4 Opus review fix) — `CanMoveInto`/`IsAllowedIn` are now ported and fed; two narrow gaps remain.** `ObjectInfo.CheckEntryRestrictions` resolves the cell's `RestrictionObj` via `PhysicsEngine.Objects` (a `ClientObjectTable`, acdream's `GetObjectA` equivalent) and evaluates the real owner IID / `HouseRestrictionRecord` (open flag, allegiance monarch, guest table) fed from CreateObject's `HouseOwner`/`HouseRestrictions`/`Monarch` PWD-tail fields and live `House_UpdateRestrictions (0x0248)` refreshes — see `RestrictionObjPrevalenceInspectionTests` (103,766 of 729,888 installed EnvCells, 1,293 landblocks, carry a baked `RestrictionObj`; this is the whole housing estate, not a rare case, which is why the OLD unconditional-fail-closed row was upgraded to FIX-FIRST rather than shipped). Remaining gaps: (1) `House_UpdateRestrictions`'s `Sequence` byte is parsed but not used for staleness/reordering rejection — a lost-then-late UDP delivery could transiently apply an older restriction snapshot over a newer one (low-probability; the next full CreateObject or another update self-corrects). (2) Outdoor `CLandCell` restriction (`LandblockInfo.RestrictionTables`, a separate per-landblock packed hash table) remains entirely unported — unaffected by this fix, since the gate only reads the indoor/EnvCell `CellPhysics.RestrictionObj` field. `HouseData (0x0225)`/`HouseStatus (0x0226)` and the guest-management opcode family (`House_AddPermanentGuest`, `House_UpdateHAR`, etc.) remain unparsed but are NOT consulted by this entry gate (they carry rent/ownership-transfer UI data, not the owner-iid/guest-list pair `CanMoveInto` needs) — noted for future house-UI work, not a residual of this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ObjectInfo.CheckEntryRestrictions`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`Objects`); `src/AcDream.Core/Items/{ClientObject,ClientObjectTable,HouseRestrictions}.cs`; `src/AcDream.Core.Net/{Messages/CreateObject.cs,Messages/GameEvents.cs,GameEventWiring.cs}`; `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (production wiring) | A reordered `House_UpdateRestrictions` pair could transiently apply the older snapshot; self-corrects on the next update or CreateObject. An outdoor restricted cell (if that content ever exists) is not gated at all. | `ACCWeenieObject::CanMoveInto` 0x0058da40 (pc:407982-408056); `RestrictionDB::IsAllowedIn` 0x005ae8f0 (pc:444493-444516); `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/RestrictionDB.generated.cs`; `references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventHouseUpdateRestrictions.cs` | | AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 | | AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values | -| AP-73 | **NARROWED 2026-08-24 (Campaign CA CA4)** — the optimistic mutation this row filed is DELETED: `ApplyLocalRaise` and the six LocalPlayerState optimistic apply/debit methods are gone, and the raise flow now matches the pinned retail mechanism (one request in flight, raise controls ghost while awaiting, displayed state changes only when the authoritative quality records land — which CA2's inbound 0x02E3/0x02DD parsers now deliver; release on any quality-change event mirrors `gmStatManagementUI::ListenToElementMessage @ 0x004EFBE0`). REMAINING OPEN POINT (the reason this row narrows instead of retiring): retail's release behavior on a rejection that produces NO quality change is statically unverifiable (the pseudocode doc's own §5 caveat), and ACE sends chat-only for a failed Raise* and NOTHING for a rejected RaiseSkill/TrainSkill — acdream holds the gate until the panel remounts (retail's per-instance flag lifetime), which may differ from retail's live behavior. Verify at the CA5 connected gate (deliberately provoke the vital raise-10-with-1-affordable client bug ACE's own comment documents). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`HandleRaiseRequest`/`ReleaseAwaitingRaise`); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (AwaitingRaise ghosting) | A silently-rejected request leaves the raise controls ghosted until the panel is reopened | Raise buttons stuck ghosted after a failed raise until panel close/reopen — visible only on server-rejected requests | `gmStatManagementUI @ 0x004F03F0`; `gmAttributeUI::RaiseSelection @ 0x0049D020`; `gmSkillUI::RaiseSelection @ 0x0049C8C0`; `InfoRegion::OnQualityChanged @ 0x004F0EB0`; `ListenToElementMessage @ 0x004EFBE0`; pinned in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` §5 | +| AP-73 | **Character raises mutate optimistically, contrary to retail's server-authoritative flow** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits XP/credits. Named retail permits one request in flight, ghosts the clicked button, and waits for an authoritative quality-change element message before changing displayed state (**#199**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` | ACE usually accepts client-affordable raises, so its later property echoes conceal the incorrect prediction; Wave 8 removes local mutation and owns one awaiting request | A rejected/reordered raise can display invented state until a later full refresh, and repeated clicks can create multiple speculative spends | `gmAttributeUI`/`gmSkillUI` raise and quality-change paths, pinned in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | --- | AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification | @@ -381,8 +356,8 @@ heading text. | AP-105 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | -| AP-109 | **NARROWED FURTHER 2026-08-25 (Campaign AS slice AS5) — the 17-function heritage×gender `AllegianceSystem::GetTitle @0x005B8DD0` table and `AllegianceData::GetFullName @0x005B6950` are now ported VERBATIM** (`AllegianceRankTitleTable`, `src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs` — every one of the 17 `Get*Title` functions transcribed string-for-string from the decomp, including the PE-byte-recovered data-literal indirections in the Sho/Gearknight/Tumerok tables) **and wired to BOTH windows this row named as open**: the examination window's title bar (`AppraisalUiController.BuildCharacterTitleBarName`, called from the `character` branch of `ApplyCreature` — rank/heritage/gender read LIVE off the APPRAISAL bundle, `props.GetInt(0x1E)`/`0xBC`/`0x71`, ruling R8, never `RuntimeAllegianceState`) and the character panel's NAME line (`CharacterSheetProvider.BuildSheet`, the exact `props.GetInt(0x1Eu)` read this row's own CT4 text already prescribed as the correct future port). Both call sites were independently re-verified against the decomp at this slice: `CharExamineUI::SetAppraiseInfo`'s local `AllegianceData` (BN name `var_a8`, proven by its `CAllegianceData::CAllegianceData`/`~AllegianceData` ctor/dtor pair) never shows an explicit field WRITE for `_rank`/`_hg`/`_gender` — a Binary Ninja struct-flattening artifact, not a missing read — while `gmStatManagementUI::UpdateCharacterInfo` shows the same three `CBaseQualities::InqInt(0x71/0xbc/0x1e)` calls as plain, unambiguous locals, independently confirming the property ids this row's CT4 text already named. This closes the row's stated risk (a ranked character's Name line showing plain-name-only). **NOT closed by this slice, and the reason this row survives NARROWED rather than RETIRED:** the CT4 narrowing's own `FormatXp` caveat, immediately below — the Luminance pair's number formatting remains a `.ToString("N0", CultureInfo.InvariantCulture)` approximation of retail's `XPToString`→`GetNumberFormatA` Win32 call, unverified on an exotic negative/overflow input. That caveat is now this row's ONLY open item.** **NARROWED FURTHER 2026-08-25 at the Campaign CT4 fix round — the luminance pair's TEXT is now bound and the PK classification now reads the live PWD bits, closing both out of this row.** `CharacterSheetProvider.PkStatusText` classifies off `ClientObject.PublicWeenieBitfield` bits `0x20` (IsPK) / `0x02000000` (IsPKLite) — the exact `ACCWeenieObject::IsPK @0x0058c8b0` / `IsPKLite @0x0058c8a0` PWD-bitfield reads, ported already at `PlayerKillerStatusBitfield.Apply` (#297) — instead of the CT4-landed bitwise test against raw PropertyInt 134 (a non-retail mapping: PropertyInt 134 carries ACE's own `PlayerKillerStatus` enum values, not the PWD bit layout). `CharacterStatController`'s luminance pair (`0x100005C5`/`0x100005C6`) now binds real text: caption `"Luminance:"` (UTF-16, PE-byte-recovered from the `gmStatManagementUI` vftable-adjacent data region at `@0x007c3dd4`) and value `" / "` (narrow `"%s / %s"` format, PE-byte-recovered at `@0x007c3dcc`, args in that order per `UpdateExperience`'s call sequence — `ExperienceSystem::XPToString(AvailableLuminance, ...)` then `XPToString(MaximumLuminance, ...)`), both numbers formatted through the same shared `FormatXp` helper the Total XP / XP-to-level fields use (`.ToString("N0", CultureInfo.InvariantCulture)` — the C# equivalent of retail's `XPToString`→`GetNumberFormatA` locale-grouped-decimal call; not a byte-identical Win32 port, so an exotic edge case, e.g. negative/overflow, is this row's own residual sliver if one is ever found). The hide path is retail's own `UIElement_Text::ClearAllText` (`@0x004f0e31`/`@0x004f0e3c` — empties `LinesProvider` content, leaves layout) rather than `Visible = false`. (CT3's Titles-page narrowing, restored here verbatim after CT4's edit compressed it to a bare pointer phrase, still stands:) **CT3's narrowing (2026-08-24), verbatim:** `CharacterTitlesController` binds the Titles page (`gmCharacterTitleUI`, LayoutDesc `0x2100002E` element `0x10000539`) through the standard `UiTemplateListBox`/`UiScrollbar`/`UiButton` classes — no bespoke widgets: the earned-titles list sorted by resolved display string (`FindSortedInsertPosition @0x0049A760`), the current display-title text (`Refresh @0x0049abc0`, including its hardcoded `"Unknown"` fallback, refreshed on both the table-replace and display-change notices), row selection using the row template's own authored Highlight state (the same `InfoRegion::SetState(6)` mechanism CT1 confirmed for the stat rows), the "Set as Display Title" button's Ghosted-unless-a-differing-selection gate (`UpdateButtons @0x0049A500`, CORRECTED direction per the CT campaign plan's CT1 fix round — no selection is the Ghosted case), and the `TitleSet (0x002C)` wire send through CT2's `RuntimeCharacterTitleState`/`IRuntimeCharacterCommands.SetTitle` (no local mutation). Campaign CT slice CT4 (2026-08-24) then put the header identity block live: `CharacterStatController`'s Name/Heritage/PkStatus/Level labels use `LabelAuthoredColor` (the widget's own DAT-set `DefaultColor`/Outline, matching CT1's live-DAT pin — the former hardcoded `Body`/`Gold` runtime constants are deleted); the heritage line appends CT2/CT3's resolved display title VERBATIM (`CharacterIdentityText.StatHeaderLine`, CT4-fix-round-corrected 2026-08-25 to stop stripping a leading "The " — retail `AppendText`s the resolved title unmodified at `@0x004f0990`, and 26 real ACE `CharacterTitle` entries begin with "The"); the level shows `"%d"`-formatted `InqInt(0x19)` or the literal `"???"` when absent (both PE-recovered). One item remains open, registered rather than silently dropped: the NAME line ships the PLAIN-NAME case only — retail's allegiance-rank prefix (`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle @0x005b8dd0`) needs a ~170-string, **17-function** [CORRECTED 2026-08-25 from CT4's original 22-function/~200-string estimate — `GetTitle`'s own dispatch switch (`@0x005b8dd0`) was read directly: Gearknight and Tumerok author only a MALE `Get*Title` function, reused for both genders' dispatch branches, and Lugian authors only a FEMALE one, reused for both — 11 heritages produce 17 functions, not 22 (2 each for Aluvian/Gharu'ndim/Sho/Viamontian/Shadowbound/Empyrean/Undead, 1 each for Gearknight/Tumerok/Lugian); Olthoi/OlthoiAcid (heritage ids 12/13) have no title function at all — `GetTitle`'s own range check `(heritage-1) <= 0xa` (unsigned) excludes them, and heritage id `0xa` (Penumbraen) aliases to the Shadowbound functions] heritage×gender title table (verbatim in the decomp, e.g. `GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/.../"High King") that CT4 judged out of "reasonable size" for this slice. The RANK value is PropertyInt `0x1E` (`AllegianceRank`) read LIVE off the qualities bundle (`CBaseQualities::InqInt(qualities, 0x1e)` — ACE actively pushes this property on every allegiance-rank change) [CORRECTED 2026-08-25 — CT4's original text claimed `RuntimeAllegianceState` "already carries the local player's own rank," conflating this row's context with `SocialAllegiancePageController`'s OWN, DIFFERENT, already-documented substitution (that controller has no qualities-bundle access, so it renders `RuntimeAllegianceSnapshot.Rank` — same `0x0020 AllegianceUpdate` wire message, numerically equivalent in every observed case — as its own accepted stand-in). `CharacterSheetProvider.BuildSheet` already reads every other header property straight off `props.GetInt(...)` from the qualities-equivalent `PropertyBundle`, so the correct future port reads `props.GetInt(0x1Eu)` directly, not `RuntimeAllegianceState` — only the STRING table is missing, not the data]. | `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`LabelAuthoredColor`, `RefreshLuminanceVisibility`, `FormatXp`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`PkStatusText`); `src/AcDream.Core/Items/ClientObject.cs` (`PlayerKillerStatusBitfield`); `src/AcDream.App/UI/Layout/CharacterSheet.cs`; `src/AcDream.App/UI/Layout/CharacterIdentityText.cs`; `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`; `src/AcDream.App/UI/Layout/CharacterTitlesController.cs` (Titles page, CT3, unchanged); `src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs` (AS5, new — the 17-function title table + `GetFullName` port); `src/AcDream.App/UI/Layout/AppraisalUiController.cs` (AS5, examination window title-bar overwrite) | Attributes/skills core output and the Titles-page binding seam are user-accepted; evidence for the header-identity block is synthetic-layout binding tests plus a small number of InstalledDat string/DID pins (the three PK strings, the gender/heritage EnumMapper chain at AP-235); AS5 adds hermetic golden-value conformance tests for all 17 `Get*Title` functions (`AllegianceRankTitleTableTests`) plus fixture-layout binding tests for both the examination title bar and the character panel name line — still not a connected/live gate | The title-table gap is CLOSED (was: a ranked-allegiance character's Name line showing plain name only). The row's ONLY remaining risk: an exotic negative/overflow Luminance value could format differently than retail's byte-exact `GetNumberFormatA` — `FormatXp`'s `.ToString("N0", CultureInfo.InvariantCulture)` is a documented approximation, not a byte-identical Win32 port, and this has never been observed or reproduced | `gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770`; `UpdatePKStatus @ 0x004F00A0`; `UpdateExperience @ 0x004F0A70`; `UIElement_Text::ClearAllText @ 0x004F0E31`/`0x004F0E3C`; `ACCWeenieObject::IsPK @ 0x0058C8B0`; `IsPKLite @ 0x0058C8A0`; `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`; `AllegianceData::GetFullName @ 0x005B6950`; `AllegianceSystem::GetTitle @ 0x005B8DD0` | -| AP-110 | **NARROWED 2026-08-25 (Campaign AS, the assess/examination-window retail-parity campaign) — "exhaustive character detail regions" retired from the still-lacks list.** AS2 ported the player header identity block (composed gender+heritage, current display title, PK status from the local weenie's PWD bits, allegiance name); AS3 ported the per-bodypart armor-level trio (with the `*` unenchantable sentinel), the ratings-family spacer discipline, and the unconditional `* = Unenchantable` legend; AS4 ported the remaining extras-list rows — society/faction (rank bands, local-vs-target faction color rule), the Monarch/Patron/Followers cascade, and the seven configurable extras (Fellowship, Arrived in Dereth, Time in Dereth, Chess Rank, Fishing Skill, Deaths, Titles Earned) — closing the character-path extras list end to end. See `docs/research/2026-08-25-campaign-as-ground-truth.md` for the full row-by-row decomp citations; the row's OTHER residuals (item-object preview, effective shield projection, cooldown-remaining, augmentation-cost `StringInfo`, creature FontInfo-list selection) are untouched by this campaign and remain open below. **NARROWED 2026-08-09 (Slice 5.4, vendor browse panel) — "vendor" retired from the absent-panels list; see AP-161 for the precise successor (Buy/Sell transaction UI, Slice 6).** Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, the Slice 5.4 vendor "Items" browse panel (category-filtered stock list, retail's quantity-correct pricing — `ItemHolder::GetObjectSplitSize`'s split-exempt mask, not a flat per-unit price), and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | +| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | +| AP-110 | **NARROWED 2026-08-09 (Slice 5.4, vendor browse panel) — "vendor" retired from the absent-panels list; see AP-161 for the precise successor (Buy/Sell transaction UI, Slice 6).** Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, the Slice 5.4 vendor "Items" browse panel (category-filtered stock list, retail's quantity-correct pricing — `ItemHolder::GetObjectSplitSize`'s split-exempt mask, not a flat per-unit price), and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | AP-161 | **REVIEW CORRECTIONS 2026-08-09 (Opus review of `92ea3977`, findings F1-F13):** thirteen further fixes, mostly bug-fixes-to-already-claimed-behavior rather than new divergences, so no new AP row is filed for most of them; the exceptions are called out below. F1 ports Buy All's four retail pre-send guards (pyreal/alt-currency affordability, container-slot/item-slot capacity) — see AP-162's narrowing. F2 corrects `AddToBuyList` from upsert to retail's actual ACCUMULATE-with-5000-cap semantics and ports `RemoveFromShop`'s shop-row hide/restore as staging consumes limited vendor supply. F3 corrects `VendorSellAcceptability`'s too-valuable branch to the byte-verified bitwise-complement form (`(~(itemTypeMask >> 16)) & 4`), exempting `PromissoryNote` items. F4 wires `BF_RETAINED` end to end, RETIRING AP-164 below. F5 ports `UpdateDragOver`'s auto-switch-to-Selling-on-hover. F6 corrects sell staging to always record the FULL stack (never the live split slider) and ports `SellSingleItem`'s partial-stack refusal plus its literal amount-1 send. F7 corrects the X-close confirmation string's missing trailing question mark. F8 disposes a live confirmation dialog on session Close/Reset. F9 repaints the Buying/Selling strips' own selection highlight on every selection change, not just a staging change. F10 unstages a sell entry that leaves `ClientObjectTable` and a buy entry whose shop row is retired, the latter with retail's exact notice. F11 reorders `RequestUse`'s eligibility check ahead of `BeginApproach` so an ineligible far target no longer speculatively approaches. F13 makes Sell Item act on the global selection unconditionally, matching retail — a prior version of this port required a staged entry first. New approximations this pass introduced are filed as AP-167 (`SellSingleItem`'s non-empty-container refusal branch not ported) and AP-168 (Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container`). **NARROWED 2026-08-09 (Slice 6b/6c, staging+sell arc) — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES.** `VendorUiController` now fully wires both tabs: Buying (`Add to List`/`Buy Item`/`Buy All`/`Clear Item`/`Clear List`, backed by `VendorStagingList`) and Selling (drag-to-sell via `IItemListDragHandler`, `VendorSellAcceptability`'s port of `VendorProfile::InqAcceptability`, `Sell Item`/`Sell All`/`Clear Item`/`Clear List`), plus the X-close staging confirmation dialog (`RetailDialogFactory`, the exact retail string recovered from the decompiled binary's data segment at `0x007b5bd8`). Sell (`0x0060`) is wired end to end (`VendorRequests.BuildSell`/`WorldSession.SendSell`/`ItemInteractionController.TrySell`). Three narrow residuals from this pass are filed separately rather than folded in here: `InqAcceptability`'s non-sellable bitfield check is unmodeled (AP-164), the Buying tab's stackable-removal-amount test substitutes `VendorShopItem.DescStackSize` for retail's `_maxStackSize` (AP-165), and the Buying/Selling tabs' own per-row/purse count text plus the cross-panel "pending sell" inventory highlight are not wired (AP-166). The two PRE-EXISTING residuals below (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED by this pass — see the ORIGINAL text below for their citations. **REVIEW CORRECTIONS 2026-08-09 (Opus review of `97cf8738`, findings F1-F9):** none of these are NEW divergences from retail — they are bug fixes that make this row's own claims actually true, so no new AP row is filed for them. F2 fixed the priced/named quantity freezing at a selection-time seed while the Buy button separately read the LIVE slider — both now share one `ResolveBuyQuantity` computation, so the displayed price always equals what a purchase actually charges (retail: `gmVendorUI::RecvNotice_StackSliderChanged` re-runs the SAME display update on every slider change, `pc:203262-203278`). F6 corrected an unauthored "preserve the prior selection if it survives the filter" rule to retail's actual UNCONDITIONAL reselect-to-first-item on every rebuild this controller reaches (`VendorItemsUI::UpdateItemsList`'s notify=1 path, `pc:201180-201184`, confirmed reached by a fresh open AND a same-vendor refresh via `VendorItemsUI::OpenVendor`'s unconditional `SetSelectedItem(...,1)`, `pc:201022`). F7 ported `BuySingleItem`'s stack-size-1 quantity clamp (`pc:201674-201681`) so a stale slider value left over from a previously-selected, DIFFERENT stackable item cannot leak into a non-stack purchase. F8 is recorded inline below, where it corrects this row's own stale claim about the Add-to-List button. **NARROWED 2026-08-09 (Slice 6.1-6.3, buy arc) — TWO of the four consciously-deferred residuals below CLOSE.** Private per-panel selection is GONE: `SelectionState` gains a `Vendor` change source (`SelectionChangeSource.Vendor`) and is now the AUTHORITY — row clicks, the F4 auto-select-first-item fallback, and right-click examine all call `SelectionState.Select`/`Clear`; `VendorUiController` is a CONSUMER (`OnSelectionTransition`) exactly like every sibling panel, matching retail's global `ACCWeenieObject::selectedID`. The examine gap (F7c) is GONE too: `VendorShopItemMaterializer` (`src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs`, Slice 6.1) registers every `ApproachVendor` shop item into `ClientObjectTable` (guid, `ContainerId = vendorGuid`, merge-upserted via the ordinary `Ingest` path, retired on session Close/Reset/vendor-replace via the SAME `VendorState.Changed` subscription) so `AppraisalUiController.Apply`'s lookup now succeeds; `VendorUiController.ExamineItem` wires `UiItemList.ExamineItemRequested` to `ItemInteractionController.ExamineSelectedOrEnterMode`, mirroring `ExternalContainerController`. **Double-click-to-buy was investigated (research doc `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §B.2) and confirmed ABSENT from retail** — no `gmVendorUI::CheckForDoubleClick`/`VendorItemsUI::CheckForDoubleClick` symbol exists anywhere in the 18,366-function named table, unlike sibling panels (`gmContractsUI::CheckForDoubleClick`, `gmPageListUI::CheckForDoubleClick`) that DO have one; acdream intentionally does NOT add a double-click shortcut — a user request for it as a deliberate acdream-only UX addition would need its own AP row, per CLAUDE.md's no-invented-mechanisms discipline. The remaining two residuals (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED — see below. New approximations this pass introduced are filed separately as AP-162 (no client-side Buy pre-check) and AP-163 (shop-item guid-collision policy). **Original REWRITTEN text follows, retained for the two still-open residuals:** `VendorUiController` mounts LayoutDesc `0x21000012`/root `0x100000B7` and fully wires only the authored "Items" tab (`0x100000B9` — `VendorItemsUI`: category-filtered browse list with retail's quantity-correct pricing, `ItemHolder::GetObjectSplitSize`'s `0xDC41CB0` split-exempt mask ported locally rather than a flat per-unit price). The other two authored tabs render and switch pages (so the layout looks complete) but are otherwise INERT: "Buying" (`0x100000BA`, `VendorBuyUI` — staged-purchase review/confirm, buttons `0x100000C9`/`CA`/`CB`/`CC`) and "Selling" (`0x100000BB`, `VendorSellUI` — staged-sale review/confirm, buttons `0x100000D2`/`D3`/`D4`/`D5`) have no data binding at all. The "Items" page's own `Buy` button (`0x100000C2`) correctly enables/disables with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) and Slice 6.3 wires it to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`). **Review correction 2026-08-09 (F8):** `Add to List` (`0x100000C3`, staging) does NOT enable/disable with selection — it is PERMANENTLY disabled (`VendorUiController.SetActionButtonsEnabled`), because it has no wired `OnClick` at all; an enabled-but-dead button is a worse affordance than a disabled one, so it stays disabled until the "Buying" tab's staging list is actually implemented. The Buy opcode exists on the wire now; Sell (`0x0060`) does not. `VendorProfile::InqAcceptability` (sell-eligibility filtering) is unread — moot without a sell UI. Two divergences remain of the four the F1-F8 fix pass originally recorded — the other two (private per-panel selection, unwired shop-item examine) CLOSED at Slice 6.1/6.2, see the NARROWED note above: (1) the closed-dropdown button face reuses the row template's own two sprites (`0x060012B3` normal/`0x060012B4` open) through `UiMenu`'s existing single-texture 3-slice `DrawButtonFace` instead of retail's authored two-piece label+arrow-cap assembly (label `0x1000034D` + a separate 17x19 arrow cap `0x1000034E` with its own `0x060012B1`/`0x060012B2` states) — a cosmetic gap only; the popup panel and its rows render with the exact authored geometry and sprites; (2) the alt-currency "you have" holding reads `VendorShopProfile.AlternateCurrencyAmount` directly instead of tracking retail's `gmVendorUI.m_last_sale` purchase debit — moot until a sell path exists to actually debit it, since `m_last_sale` only changes on a completed SALE (retail's own `m_last_sale == 0` case, `pc:204091`/`OpenVendor`'s `this->m_last_sale = 0` reset at `pc:203790`/`203800`); Slice 6.3's buy path does not touch `m_last_sale` either (retail's own buy flow never writes it), so this residual is unaffected by the buy arc landing. The "Buying"/"Selling" staging tabs (`VendorBuyUI`/`VendorSellUI`) and the full Sell wire remain unwired — unchanged Slice 6b/6c territory per contract decision 6, not a residual of THIS row. | `src/AcDream.App/UI/Layout/VendorUiController.cs`; `src/AcDream.Core/Items/VendorState.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` | Slice 6 (`docs/plans/2026-07-23-world-interaction-completion.md`) owns the authoritative buy/sell transaction command, quantity/stack-split selection, drag-to-sell consumption, and `InqAcceptability`-gated sell UI — Slice 5.4's charter was browse only. Buy (6.3), the global `ACCWeenieObject::selectedID` coupling (6.2), and shop-item `ClientObjectTable` registration (6.1) are now DONE, landing exactly the seam this row's original filing fenced off; drag-to-sell consumption and `InqAcceptability`-gated sell UI remain Slice 6b/6c territory. | A player can browse, select, examine, and BUY (Slice 6.3) — the only remaining unbuilt transaction is Sell. Clicking "Buying"/"Selling" still shows an empty page with no error or explanation, matching "present but does nothing" rather than a disabled/hidden affordance. The dropdown's closed-state button face is missing its separate arrow-cap glyph — a minor visual gap, not a functional one; the open popup itself is pixel-faithful to the authored template. | `gmVendorUI::OpenVendor` pc:203650 (`m_itemsUI`/`m_buyUI`/`m_sellUI` construction, `PostInit` pc:199906, `m_last_sale` reset pc:203790/203800); `VendorBuyUI::VendorBuyUI` pc:199717; `VendorSellUI::VendorSellUI` pc:199753; `VendorProfile::InqAcceptability` pc:484768-484797; `UIElement_Menu::MakePopup` pc:120705-120764, `::Initialize` pc:120789-120828; `VendorItemsUI::UpdateItemsUI` pc:202539-202820; `VendorItemsUI::UpdateItemsList` pc:201029-201190; `ItemHolder::GetObjectSplitSize` pc:401465-401477; `gmToolbarUI::HandleSelectionChanged` pc:198740-198790 (mask `0xDC41CB0` at pc:198784); `ACCWeenieObject::GetObjectName` pc:409056-409132; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4, §D | | AP-162 | **NARROWED 2026-08-09 (Opus review of `92ea3977`, finding F1) — the "Buy All" half of this row CLOSES.** `VendorUiController.BuyAllButtonPressed` now ports all four of retail's client-side pre-send guards (pyreal affordability `pc:204017`, alt-currency affordability `pc:204032`, container-slot capacity `pc:204053`, item-slot capacity `pc:204067`) — see `ComputeBuyTransactionValue`/`ComputeBuySlotsNeeded`/`CountPlayerContents`, each guard returning with staging fully intact and retail's own exact notice string (`"You don't have enough money"` at `0x007b57b4`, `"You must empty some slots in your backpack first"` at `0x007b5750`, both byte-recovered). The container-vs-item slot CLASSIFICATION this port uses (`ItemType.Container` instead of retail's bitfield/capacity test) is its own new, narrower approximation — filed separately as AP-168 rather than folded in here. Only `TryBuy`'s single-item Buy path (Items tab's own Buy button, and the Buying tab's "Buy Item") remains WITHOUT a client-side pre-check — the risk/oracle columns below now describe that one remaining case, not both. **EXTENDED 2026-08-09 (Slice 6b) — the same omission now also covers "Buy All".** `ItemInteractionController.TryBuyAll` (the batched-send path `VendorUiController.BuyAllButtonPressed` calls) sends unconditionally too, without porting retail's `pc:204017/204032/204053/204067` affordability/pack-capacity pre-checks for the MULTI-item case either — the same latency-not-correctness tradeoff this row already documents for the single-item path, extended rather than duplicated into a second row; retiring this row should port both the single- and batched-send pre-checks together. **Filed 2026-08-09, Slice 6.3 (buy wire + button).** Retail's `BuySingleItem` (`pc:201661`) performs TWO client-side pre-checks before ever sending `CM_Vendor::Event_Buy`: (a) an affordability check against `this->m_totalValue` (pyreal) or `shopVendorProfile->trade_num - m_last_sale` (alt-currency), showing a LOCAL string via `ECM_UI::SendNotice_DisplayStringInfo` and returning without sending anything on failure (`pc:201686-201717`); (b) a pack/container-capacity pre-check (`pc:201730-201746`) mirroring the server's own check. acdream's `ItemInteractionController.TryBuy` sends unconditionally once the shared use/inventory gate is free — no client-side affordability or capacity check runs before dispatch. Every refused purchase pays a full round-trip (send → server rejects → `UseDone`/`GameEventInventoryServerSaveFailed`) instead of failing instantly and silently client-side. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `src/AcDream.App/UI/ItemInteractionController.cs` (`TryBuy`) | The research doc's own open question 1 (`docs/research/2026-08-08-slice6-vendor-transactions-research.md`) recommends deferring this: the server is authoritative either way (ACE re-validates both affordability and capacity server-side — `Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`), so omitting the client pre-check is a LATENCY/UX gap, not a correctness one — a refused purchase still fails cleanly, just one round-trip later than retail. | A player attempting to buy something they cannot afford or have no room for sees the failure arrive after a network round-trip instead of instantly; against a well-behaved ACE server no purchase can succeed that retail's pre-check would have blocked, so no transaction outcome differs — only its latency. Retiring this row means porting `BuySingleItem`'s two pre-check branches (`pc:201686-201746`) into `TryBuy` before dispatch. | `gmVendorUI::BuySingleItem` pc:201661/0x004C2820 (affordability pc:201686-201717, capacity pc:201730-201746); `Vendor.BuyItems_ValidateTransaction` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:431-571`); `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §D point 4, Open question 1 | | AP-163 | **REVIEW CORRECTION 2026-08-09 (Opus review of `97cf8738`, finding F1):** this row's ownership discipline is now COMPLETE on both halves, not just the add-time collision guard described below. The retire pass (`OnVendorTransition`'s loop over guids missing from the new `ApproachVendor` snapshot) previously deleted ANY such guid unconditionally — a plain bug, not a documented divergence, since buying a UNIQUE vendor item re-containers that SAME guid into the buyer's own pack (`Player_Commerce.cs:86-108`) BEFORE the post-buy refresh that drops it from the shop's own list arrives; the old retire pass would have stripped the just-purchased item straight back out of the buyer's inventory. **The exact rule now enforced:** each owned guid remembers the vendor id it was registered under (`Dictionary`, guid -> vendorId), and the retire pass calls `ClientObjectTable.Remove` ONLY when the live object's CURRENT `ContainerId` still equals that recorded vendor id; when it differs (or the object is already gone), the tracking entry is dropped silently and the object itself is left completely untouched — the SAME skip-not-clobber discipline the add-time collision guard below already used, now applied symmetrically on the way out. This is a bug fix, not a new divergence, and does not change this row's still-open scope: retail's actual `ClientObjMaintSystem`/`CObjectMaint` collision behavior on a guid collision remains untraced. **Filed 2026-08-09, Slice 6.1 (shop-item materialization).** `VendorShopItemMaterializer` registers each `ApproachVendor` shop item into `ClientObjectTable` keyed by its own server guid. ACE's `UniqueItemsForSale` (`Vendor.cs:34,638`) can list the EXACT guid a player last held (an item sold to this vendor keeps its original guid), so a guid collision against an existing, differently-owned `ClientObjectTable` entry is a real, if rare, possibility. No retail behavior for this exact case was traced (retail's `ClientObjMaintSystem`/`CObjectMaint` guid-keyed registration internals were not decompiled for this pass). acdream's policy is a conscious, conservative default: a guid this materializer did NOT itself add to the table on a previous cycle is treated as owned by something else and is left completely untouched — never overwritten, never later removed by this class. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`OnVendorTransition`'s collision guard) | Skip-not-clobber is the safe default absent a traced retail mechanism: silently reparenting a live entity's or another container's item into the vendor's `ContainerId` would corrupt real ownership state (equipment tracking, burden, radar) for a guid this code does not own, which is strictly worse than a single shop row's status-bar/appraisal projection staying blank. The vendor list itself is unaffected either way — `VendorUiController` reads display fields straight off `VendorShopItem`, never through `ClientObjectTable`. | If retail's actual behavior differs (e.g. it always overwrites, or a real `UniqueItemsForSale` collision is more common than assumed), the one colliding shop row's status-bar/appraisal projection stays stale/blank instead of showing the vendor listing — a narrow, single-row display gap, never a corrupted non-vendor object. Retiring this row requires tracing retail's `ClientObjMaintSystem` registration behavior on a guid collision, which was out of scope for this pass. | No direct retail citation traced this pass — `Vendor.cs:34,638` (`UniqueItemsForSale`, ACE) establishes the collision is POSSIBLE, not what retail does about it; `docs/research/2026-08-08-slice6-vendor-transactions-research.md` (task brief: "study how ACE guids vendor stock and state your collision policy with evidence") | @@ -393,7 +368,7 @@ heading text. | AP-168 | **NARROWED 2026-08-08 (grand-gate finding G1) — the player's-OWN-pack half (`CountPlayerContents`) is FIXED; only the shop-stock half (`ComputeBuySlotsNeeded`) remains approximated.** Live testing surfaced the risk this row already predicted: "Buy All" false-blocked a container purchase while the player visibly had free container slots. Root cause was NOT the theoretical corner case originally described here — it was that the old dual-heuristic (`ItemType.Container` bit OR nonzero `ItemsCapacity`/`ContainersCapacity`) could over-classify an ordinary non-container object as an occupied container slot, undercounting free space. `CountPlayerContents` now reads `ClientObject.ContainerTypeHint` first — retail's actual wire `ContainerProperties` (`Item_ServerSaysContainId` 0x0022's `ContainerType`; also carried by `ContentProfile`/`PlayerDescription`'s per-entry container-kind byte), already threaded onto every owned object by `InitializeInventoryManifest`/`ApplyConfirmedServerMove`/`ReplaceContents` and already used for this identical question by `ClientObjectTable.IsContainerListMember` — falling back to `ItemType.Container` alone (the capacity-field legs were dropped) only for the rare object that never received a hint. This matches retail's real `_itemsList`/`_containersList` bucketing (`ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` @0x0058beb0/0x0058bec0 just report already-bucketed `IDList` lengths; the bucketing happens once, at insert time, in `ServerSaysContainID` @0x0058be40, from that same wire field) rather than reconstructing it from the item's own type/capacity fields. Original text: **Filed 2026-08-09, Opus review of `92ea3977`, finding F1 (Buy All's client pre-send capacity guard).** Retail's `gmVendorUI::InqListSlotCount` (`pc:200038-200065`, `0x004c0c10`) classifies each staged item as needing a CONTAINER slot or an ITEM slot by testing a bitfield bit (a decompiler string-misattribution artifact not yet decoded) ORed with the item's own nonzero `_itemsCapacity`/`_containersCapacity`. `VendorUiController.ComputeBuySlotsNeeded`/`CountPlayerContents` approximate this with `(item.ItemType & ItemType.Container) != 0` instead — correct for the ordinary case (an authored backpack/pouch DOES carry the `Container` type bit) but not byte-identical for the theoretical case of a non-`Container`-typed item that still authors nonzero pack/side capacities (or vice versa, a `Container`-typed item with zero capacity of its own, e.g. a locked/sealed decorative chest never meant to be carried). | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ComputeBuySlotsNeeded`, `CountPlayerContents`) | `VendorShopItem`'s wire shape (Slice 5's deliberately narrow browse-scope subset) genuinely does not carry `PublicWeenieBitfield`/`ItemsCapacity`/`ContainersCapacity`/`ContainerProperties` the way `ClientObject` does for an ordinary `CreateObject`/membership-sourced item, so `ComputeBuySlotsNeeded` (the shop-stock side, staged-but-not-yet-owned items) cannot read a wire-truth hint the way the fixed `CountPlayerContents` (the already-owned side) now does; extending the DTO was out of scope for this fix. The server remains authoritative and re-validates real pack-space regardless (`Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`) — the residual failure mode stays UX/latency, not correctness. | A vendor selling a `Container`-typed item with zero authored capacity (rare/decorative) would still be misclassified as needing a container slot instead of an item slot, or vice versa for a non-`Container`-typed item that DOES author capacity (also rare) — the pre-check could still reject a purchase retail's own guard would have allowed, or allow one retail would have blocked, purely on the CLIENT side for the SHOP-STOCK item being bought; the player's-OWN-pack accounting that drives the free-slot count is no longer the source of that risk. | `gmVendorUI::InqListSlotCount` `pc:200038-200065`/`0x004c0c10`; `ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` `0x0058beb0`/`0x0058bec0`; `ACCWeenieObject::ServerSaysContainID` `0x0058be40`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` | | AP-169 | **Filed 2026-08-08, grand-gate finding G2 (vendor toolbar split-slider absent live). CORRECTED 2026-08-08 (re-gate finding R1). CORRECTED AGAIN 2026-08-08 (live vendor-diag evidence) — both earlier stories mis-identified the operand; this row now records the third and evidence-pinned shape.** The G2 fix fell back to the packed ItemProfile supply-count dword (unusable: a standard listing has UNLIMITED stock, `-1`). The R1 fix preferred the wire `PublicWeenieDesc::_stackSize` (`VendorShopItem.DescStackSize`) on the claim that ACE never populates it for a browse row — the live vendor-diag run REFUTED that claim: ACE serializes `descStackSize=1` for EVERY browse row (`[vendor-diag] ApproachVendor wire-item[...] descStackSize=1 stackSizeMax=100`), so desc-first resolved every vendor stack to 1 and the split bar never appeared (`ApplySelection ... failingPredicate=stackSize<=1u stackSize=1`). The named decomp settles what retail actually reads at its VENDOR-owned quantity sites: `pwd._maxStackSize` DIRECTLY — `VendorItemsUI::UpdateItemsList` (`0x004c1ea0`, `pc:201085-201133`) displays each browse row's quantity as `min(remaining, _maxStackSize)` (plain `_maxStackSize` for an unlimited listing, via `VendorSubUI::SetObjectStackSize`); `gmVendorUI::InqListSlotCount` (`0x004c0c10`, `pc:200052`) classifies rows on `pwd._maxStackSize <= 1`; the Buy cases (`gmVendorUI::HandleButtonClicks` `0x100000c9` @`pc:203996` / `0x100000cb` @`pc:204086`) gate the stackable-buy path on `pwd._maxStackSize > 1`. `VendorSplitPolicy.ResolveAuthoredStackSize(descStackSize, maxStackSize)` is therefore **max-first** (desc fallback, then 1), consumed only by the vendor-owned paths (`VendorShopItemMaterializer.ToWeenieData`, `VendorUiController.ResolveBuyQuantity`); player-inventory stacks never route through it. Matches the live retail screenshot ("1000 Prismatic Tapers", ceiling 1000 = the taper's authored max stack size). The toolbar-side `gmToolbarUI::HandleSelectionChanged` does read `pwd._stackSize` (`pc:198688`/`198744`/`198774`/`198791`) — on a REAL retail server the two agree for a browse row (the vendor UI stamps the displayed stack from `_maxStackSize`); against ACE (desc always 1) the `_maxStackSize` operand is the one that carries retail's meaning. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`ToWeenieData`); `src/AcDream.Core/Items/VendorSplitPolicy.cs` (`ResolveAuthoredStackSize`); `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ResolveBuyQuantity`) | This is an ACE-server-constraint adaptation on the toolbar leg only: retail's vendor UI reads `_maxStackSize` literally (ported as-is); the toolbar seed's `_stackSize` read is satisfied through the materialized `ClientObject.StackSize`, which this resolution stamps from `_maxStackSize` exactly as retail's own `UpdateItemsList` stamps the displayed stack — not an arbitrary substitute. | A vendor stocking a bounded but non-unit quantity shows a ceiling of `min` semantics only on a real retail server; against ACE the client-side slider ceiling is the authored max stack size, not the bounded stock count — the server remains authoritative and rejects an over-large Buy regardless (a latency/UX gap, not a correctness one — see AP-162). If ACE ever starts serializing a REAL per-listing `_stackSize` (not the constant 1), the max-first preference would hide it; the desc fallback fires only when no authored ceiling exists. | `VendorItemsUI::UpdateItemsList` `0x004c1ea0` `pc:201029-201133`; `gmVendorUI::InqListSlotCount` `0x004c0c10` `pc:200052`; `gmVendorUI::HandleButtonClicks` `pc:203996`/`204086`; `gmToolbarUI::HandleSelectionChanged` `pc:198688-198791`; live vendor-diag wire capture + live retail screenshot (2026-08-08) | | AP-170 | **Filed 2026-08-08, grand-gate finding G3 (out-of-range vendor Use lost silently).** Retail's `ItemHolder::UseObject @ 0x00588A80` has no client-side range check and sends Use immediately regardless of distance — this port's ORIGINAL `RequestUse` faithfully mirrored that shape. Live testing against the user's local ACE server showed it does not hold: walking to a vendor and using it from out of range plays the vendor's cosmetic greeting (a distance-only reaction, independent of Use) but never opens the shop panel — `ApproachVendor` never arrives. ACE's `Player.HandleActionUseItem` (`references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-215`) explains why: an out-of-range target routes through `CreateMoveToChain(item, (success) => TryUseItem(item, success))` (`Player_Move.cs:37-96`), which polls every 0.1s for the player to reach `WithinUseRadius` and only then calls `ActOnUse` — it does not teleport or server-move the player; it waits for the CLIENT's own walk to land, and a Use that arrives before that poll ever starts observing an in-range player is simply never followed by the vendor's `ApproachVendor` send (`Vendor.ActOnUse`'s own doc comment: "the player will have been commanded to move using `DoMoveTo` before `ActOnUse` is called... it should be assumed that the player is within range" — a precondition our immediate send violated). `SelectionInteractionController.RequestUse` now arms the out-of-range case on the SAME arrival-gated shape `SendPickup`'s close-range (turn-only) branch already used (`RuntimeInteractionTransactionState.TryArmPostArrivalUse`/`TryResolveUseApproachCompletion`, mirroring `TryArmPostArrivalPickup`/`TryResolveApproachCompletion` field-for-field) — the wire Use dispatches only once the local approach naturally completes. An already-in-range Use (a turn at most, or no approach concept applies) is unaffected and still sends immediately, matching ACE's own "already within use distance" synchronous callback. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`RequestUse`, `HandleApproachCompletion`, `HandleUseApproachCompletion`, `CancelPendingApproach`, `OnEntityHidden`, `OnEntityRemoved`); `src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs` (`RuntimePendingUse`, `TryArmPostArrivalUse`, `TryResolveUseApproachCompletion`, `TryCancelPendingUse`) | This is an ACE-server-constraint adaptation, not a retail redesign: retail's REAL server walks the player itself before the target's `ActOnUse` ever sees the request, so the client's immediate send never races anything there. ACE does not do this for a player-initiated Use — it only polls and waits — so arming on arrival is required for correctness against the only server this port can test against, not a stylistic preference. | An interaction path that still calls `TryDispatchUse` directly without going through `RequestUse`'s approach gate (none identified at this fix) would keep the original race. The armed reservation is a live busy-count reference until arrival/cancellation resolves it; `ResetCore` releases it unconditionally on any reset/dispose so a teardown that runs without a preceding `CancelPendingApproach()` (e.g. a headless/no-window host with no `SelectionInteractionController`) cannot leak it. | `ItemHolder::UseObject` `0x00588A80`; `Player.HandleActionUseItem` `Player_Use.cs:176-215`; `Player.CreateMoveToChain`/`MoveToChain` `Player_Move.cs:37-153`; `Vendor.ActOnUse` `Vendor.cs:223-266` | -| ~~AP-171~~ | **RETIRED 2026-08-26 — the original filing was false.** Direct named-retail evidence in `gmVendorUI::HandleMousePresses @ 0x004C40D0` calls `BuySingleItem` from the Items-list double-click branch. Browse-row double-click purchase is retail behavior, not an acdream modernization. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (shop cell DoubleClicked) | — | — | `gmVendorUI::HandleMousePresses @ 0x004C40D0`; `docs/research/2026-08-26-retail-inventory-interaction-audit.md` | +| AP-171 | **Filed 2026-08-08 (user-approved modernization).** Double-clicking a vendor shop item buys it (select + the Buy button's exact quantity/price path). Retail has NO double-click-to-buy — the full named function table was swept at the Slice 6 research and the user chose the addition explicitly after being told. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (shop cell DoubleClicked) | Deliberate QoL divergence, user-directed; trivially removable. | None — additive input affordance; the single-click and Buy-button paths are unchanged. | User direction 2026-08-08 ("When I double click, I should buy it") | | AP-173 | **Filed 2026-08-08 (Campaign A slice A2).** Retail pans with `IDirectSoundBuffer::SetPan`, which attenuates ONE output channel by \|pan\| decibels — so full deflection is a 15 dB inter-channel level difference, never full separation. OpenAL exposes no per-channel gain for a mono source, so acdream expresses the same pan as a source-relative AZIMUTH (`MaxPanAzimuthDegrees = 30`, scaled by pan/15) and lets OpenAL's constant-power panner turn it into channel gains. Everything about the pan's SHAPE is retail's and byte-verified: the value is `(int)(-15·sin(Δbearing))` in whole decibels from retail's compass convention, it is forced to dead centre when `(int)distance < 5`, it distinguishes neither front from back nor elevation, and it is frozen for the voice's lifetime. Only the mapping from a 15 dB channel difference to an azimuth under OpenAL's own pan law is approximate. | `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (`MaxPanAzimuthDegrees`, `ApplyPan`) | The exact alternative is to pre-mix a stereo buffer per (wave, pan) pair, which multiplies AL buffer memory by up to the 31 distinct pan values and would fight the 48 MiB LRU; OpenAL's stereo pan law is also driver-dependent, so a measured mapping would not be portable. The audible quantity (inter-channel difference) is preserved in shape and bounded in magnitude. | Stereo image at full deflection may be somewhat wider or narrower than retail's 15 dB; direction and the centre deadzone are correct. Sounds are never hard-panned to silence in one ear the way an uncompressed azimuth would do. | `SoundManager::PlaySoundInternal @ 0x00550170`; `SoundBuf::Play` SetPan call; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §1 (pan decode) | | AP-174 | **Volume-knob taxonomy differs from retail's, filed 2026-08-08 (Campaign A slice A2).** Retail has exactly three float knobs — `effect_sound_volume`, `ambient_sound_volume`, `interface_sound_volume` — **no master and no music knob**, and the interface one is registered and then never read (interface sounds are scaled by the EFFECT knob). acdream keeps an extra `MasterVolume` on top of `SfxVolume`, which A2 folds into the mixer's single master multiply (`EffectMaster = MasterVolume * SfxVolume`) rather than publishing as an AL listener gain — so the −50 dB no-allocate floor, the audible radius, and the whole-decibel quantisation all move with the slider the way they would if retail had one. `MusicVolume` is dead (retail has no music system at all; slice A6 deletes it) and `AmbientVolume` is unread until slice A5 wires the ambient path. No Interface knob exists yet; slice A4 adds the UI bus and will scale it by the effect knob, matching retail's dead-knob behaviour rather than implementing a working one. | `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (`EffectMaster`); `src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs` | A master slider is a modern nicety users expect and costs nothing once it is inside the one retail multiply; implementing retail's dead interface knob as a working control would be a divergence in the other direction, so it stays dead. | At Master 1.0 (the default) behaviour is bit-identical to a retail single-knob mix. Below 1.0 the mix is quieter than retail's would be at the same effect setting, because retail has no such knob to turn down. | `SoundManager::InitPrefs @ 0x005503F0`; `SoundManager::GetAttenuation @ 0x00550020`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §3 D11/D13 | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | @@ -448,9 +423,7 @@ heading text. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. **F12 correction (night-round review, 2026-08-17): this is 17 sites, not 15** — the original tally dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` (mentioned two sentences below as its own closed row) and undercounted by one more besides. The 17 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger — **TIMING CORRECTED at the 2026-08-17 morning gate round (user finding: retail world tooltips "lag"; ours popped instantly): the original "edge-fired (no dwell)" reading was a misread — the notice's immediate `StartTooltipAtMouse @0x004E5DFB` sits inside `if (s_pInstance->m_dragElement != 0)` (`@0x004E5D8E`; `m_dragElement` is a real, distinct PDB field in `acclient.h`'s `UIElementManager`, separate from the `m_pTooltipElement` family), so the immediate mount is DRAG-ONLY; the ordinary hover path merely STAGES the name (`SetTooltip @0x004E5D74` + `|= 0x20`) and the display rides `CheckTooltip @0x0045B6E0`'s mouse-idle dwell (`m_lastMouseMoveTime` stamped on EVERY move `@0x0045e736` + `m_tooltipDelay` 0.25 s `@0x0045f75d`), with found-object changes under an idle mouse swapping the popup same-frame via `SetTooltip`'s own text-change teardown (`@0x004617FF` → `ResetTooltip @0x0045C360` tail-calling `CheckTooltip`) and the 10 s `m_tooltipDuration` expiry requiring a fresh mouse move before re-arming (`SwitchMouseOver(null) @0x0045b7b2`) — all now ported, including the drag-immediate branch**, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. **BATCH B (2026-08-17) CLOSED the spellcasting and character-panel rows of this list. BATCH C (2026-08-17, Map/House toolbar panel) CLOSES THE LAST REMAINING ITEM: `gmMapUI::AddMapNote @0x004A1C51`'s 53 town-hotspot tooltips are now ported via `MapPageController.BuildTownMarkers` (`src/AcDream.App/UI/Layout/MapPageController.cs`), setting `UiButton.TooltipText` (retail's RUNTIME `m_TTText`/`SetTooltip` mechanism, not the DAT-authored `P0x49` path an earlier same-day cut mistakenly used and which never rendered live during verification); literal town names from `MapLocations.cs` (a verbatim port of `s_rgLocations`), not a DAT string-table lookup, matching `AddMapNote`'s own `StringInfo::SetLiteralValue` call. CORRECTED at the same-day morning gate round (user finding 3 — the retail screenshot's green hover highlight + special-font parchment tooltip): Batch C's "the town-marker template authors no locator of its own" claim was WRONG — the template (`0x100001F0` in `0x21000026`, `MapNoteLiveDatTests`) authors its OWN `P0x47=0x10000398`/`P0x48=0x21000041` (the fourth popup skin, whose incorporated text child `0x10000396` fonts `0x40000015` where the other three skins font `0x40000002`), a zero per-element delay `P0x50=0.0`, `P0x4B` TooltipOn, and `P0x13` RolloverEnabled with PassToChildren `Normal`/`Normal_rollover` states flipping the highlight child `0x100001F1`'s per-state `P0x3B` (the green `0x06004CC9` frame, byte-decoded A=FF R=00 G=FF B=00); the hardcoded shared-skin override was removed (the built marker's authored locator wins) and the rollover highlight + per-state-`P0x3B` + button PassToChildren cascade are now ported (`UiButton.CascadeStateToChildren`, `UiDatElement.TrySetRetailState`'s 0x3B honor). Sub-mechanism (1)'s `SetTooltip`-call-site enumeration is 16 of 17 known sites PORTED — `UIElement_Text::RecalculateTruncation @0x00466F80` (the headline, highest-volume site named at the top of sub-mechanism (1)) remains the ONE open item, exactly as this row's own sub-mechanism (1) text above already scoped it out (its own "Sized as genuinely disproportionate... deferred here" note). The prior "all 15 known sites accounted for" close (F12 correction, night-round review) was wrong twice over: the count is 17, not 15, and RecalculateTruncation was never actually ported — it was always the one deliberately-deferred item, not a closed one.** Batch B audit findings: the endowment icon `@0x004C63A1`, favorite `@0x004C7206`, and submenu `@0x004C67D8` sites turned out to be ALREADY CORRECT — all three are `UiCatalogSlot`-based and the pre-existing `Label`-driven `GetTooltipText` already carried retail's exact text (`SpellCastSubMenu::AddFavorite @0x004C7060`/`UpdateFromPlayerModule @0x004C6570` both build a single-arg `Formatted` PStringBase — plain spell name, no wrapper — for the favorite-bar/submenu case; `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` mirrors the cast button's confirmed `"%s (%hs)"` literal at `data_7b64d8` — item name, then spell name in parens — via the identical two-value narrow/wide prep sequence, one address away in the same function family). The cast button `@0x004C6FE8`/`ClearTooltip @0x004C6AAE` was the one real gap (`UiButton` had no tooltip wiring at all): now ported via `SpellcastingUiController.UpdateCastAvailability`/`ComputeEndowmentCastState`, sourced from `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30`. Fully verified literal text: the no-selection states (`"Select a spell to cast"` @ `data_7b64ec`, `"You have no spells ready to cast"` @ `data_7b6520`) and the complete endowment-item branch (`"USE the %s"` @ `data_7b64c0`, `" on %s"` @ `data_7b6464`, `"You must select a target for the %s"` @ `data_7b6478`; `ItemUses::IsUseable_SelfTarget @0x004fcd30` is exactly `ItemUseability.AllowsSelfTarget`). NOT ported: the endowment branch's incompatible-target sub-state (`"You must select an appropriate\ntarget for the %s"` @ `data_7b6400`, gated by `ItemHolder::TargetCompatibleWithObject @0x00587520` — a ~400-line function with its own chat-message side effects, out of scope for a tooltip batch; a present target is optimistically treated as compatible, same text as the confirmed-compatible case). **CORRECTED at the night-round review (F3/F4, 2026-08-17): the plain-spell branch's wording is NOT unrecoverable — the "genuine `gmNoticeHandler` vtable SLOTS" claim above was itself the artifact. `PStringBase::sprintf`'s second argument at those three call sites is a raw pushed literal (a plain `push 0x7bXXXX; call sprintf`); Binary Ninja's pseudo-C rendering of that operand as `&gmSpellcastingUI::\`vftable'.RecvNotice_XXX` was a spurious symbol match, not the true operand — a direct capstone disassembly of the raw bytes at `0x4c6e48`/`0x4c6ea4`/`0x4c6f18`/`0x4c6f5d` resolves the actual constants: `"CAST %hs"` @0x7b63a4 (untargeted/self-cast at `0x4c6f35`, and targeted+compatible at `0x4c6e57` — both enabled, the latter appending `" on %s"` @0x7b6464 with the target's name), `"You must select an appropriate target for %hs"` @0x7b6348 (targeted+incompatible, disabled), `"You must select a target for %hs"` @0x7b63b8 (no target, disabled); `%hs` is the spell's own name in all four call sites (`CSpellBase::InqName`, the same call `0x5bbee0` throughout). Now ported: `RuntimeSpellCastState.EvaluateCastGate` (the four-state gate) + `SpellcastingUiController.ComputeSpellCastState`. Also corrected the endowment branch's "USE the %s" operand: it was NOT the bare item name (F4) — the vararg to `"USE the %s"`/`"You must select a target for the %s"`/the still-unported incompatible-target string is the SAME composed `"%s (%hs)"` string (item name, spell name) built once at `@0x004c6bb6-ef` from format literal `data_7b64d8`, byte-confirmed by all three sprintf call sites (`0x4c6c7f`/`0x4c6ca4`/`0x4c6d46`) reading the identical `[esp+0x18]` slot — now ported via `SpellcastingUiController.ComposeEndowmentName`.** **CA5-GATE CORRECTIONS (2026-08-24, owner retail-render oracle): (a) `SkillInfoRegion::GetTooltip`'s compose is `formula + " -" + description` — the row below has the operand order backwards (" -"+formula, "no separator"); the operator+ left operand is the InqSkillFormula output, and retail renders formula-first-line/description-below. (b) The "grow further if vertical scroll overflow" branch this row calls "a structural no-op" is NOT one — it is retail's SECOND sizing pass (`StartTooltip @0x0045DE90`: measure-wrap at max width → clamped root resize → `RecalculateGlyphList` RE-WRAP at the final clamped width → height growth for the extra lines), which is what makes long tooltips multi-line; both now ported in `RetailTooltipPresenter.ApplyTooltipText`/`RetailSkillFormula.BuildTooltip`.** **#430 CORRECTION (2026-08-24): the character-panel port below set the TEXT but omitted the popup LOCATOR on the runtime-built rows, so these tooltips never mounted until the CA5-adjacent fix gave rows the shared 0x10000395/0x21000041 skin (live-DAT probed; UiItemSlot precedent).** The character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors are now fully ported through a new `UiClickablePanel.TooltipText` (the same settable-string seam as `UiButton.TooltipText`): the six hardcoded attribute descriptions (`SkillSystem::InqAttributeDescription @0x005c8e30`) and three hardcoded, pair-shared vitals descriptions (`SkillSystem::InqAttribute2ndDescription @0x005c8f70`) were byte-decoded from the retail string pool (the pseudo-C dump truncates them with "…"); skill tooltips compose `SkillInfoRegion::GetTooltip @0x004f1fe0`'s exact `"\n" + formula + description` (ported verbatim, including the confirmed lack of any separator between the formula and description text) from the ALREADY-DAT-parsed `DatReaderWriter.Types.SkillBase.Description`/`.Formula` fields (portal `0x0E000004`, the same resource `CharacterSheetProvider.SkillTable` already reads for skill names/costs) rather than hand-transcribed literals — no guessing was needed for the ~30+ skill description strings. The formula-to-text algorithm itself (`SkillSystem::InqSkillFormula @0x005c89b0`, e.g. producing `"( (Strength + Coordination) / 2 )"`) was fully recovered by byte-decoding six short literal fragments (`data_7e7930`/`7e7934`/`7e7940`/`7e7950`/`7e7954`/`797584`) the pseudo-C dump left completely unlabeled — they sit between two `gmSpellcastingUI` vtable declarations and Binary Ninja's type inference never recognized them as strings, so the raw hex had to be read directly as narrow ASCII (confirmed against the function's own directly-visible `" / %u"` and `"(%u x %s)"` literals, which needed no such recovery). Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row); Batch B (2026-08-17) additions: `src/AcDream.App/UI/UiPanel.cs` (`UiClickablePanel.TooltipText`/`GetTooltipText`); `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`UpdateCastAvailability`, `ComputeEndowmentCastState`); night-round review (F3/F4, 2026-08-17) additions: `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`ComputeSpellCastState`, `ComposeEndowmentName`); `src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs` (`EvaluateCastGate`, `SpellCastGate`); `src/AcDream.App/Net/RetailSkillFormula.cs` (`AttributeName`, `FormatFormula`, `BuildTooltip`); `src/AcDream.App/UI/Layout/CharacterSheet.cs` (`CharacterSkill.TooltipText`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`BuildLiveCharacterSkills`'s tooltip compose); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`AttributeDescriptions`, `Attribute2ndDescriptions`, `BuildAttributeRows`/`BuildSkillRows` row wiring) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`); Batch B (2026-08-17): `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30` (cast-button state machine); `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` (endowment-icon `"%s (%hs)"` confirmation); `SpellCastSubMenu::AddFavorite @0x004C7060` / `UpdateFromPlayerModule @0x004C6570` (favorite/submenu plain-name confirmation); `ItemUses::IsUseable_SelfTarget @0x004fcd30`; `AttributeInfoRegion::AttributeInfoRegion @0x004f1530` / `Attribute2ndInfoRegion::Attribute2ndInfoRegion @0x004f1680` / `SkillInfoRegion::SkillInfoRegion @0x004f2140` / `SkillInfoRegion::GetTooltip @0x004f1fe0`; `SkillSystem::InqAttributeName @0x005c8d90` / `InqAttributeDescription @0x005c8e30` / `InqAttribute2ndName @0x005c8ed0` / `InqAttribute2ndDescription @0x005c8f70` / `InqSkillFormula @0x005c89b0` | +| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. **F12 correction (night-round review, 2026-08-17): this is 17 sites, not 15** — the original tally dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` (mentioned two sentences below as its own closed row) and undercounted by one more besides. The 17 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger — **TIMING CORRECTED at the 2026-08-17 morning gate round (user finding: retail world tooltips "lag"; ours popped instantly): the original "edge-fired (no dwell)" reading was a misread — the notice's immediate `StartTooltipAtMouse @0x004E5DFB` sits inside `if (s_pInstance->m_dragElement != 0)` (`@0x004E5D8E`; `m_dragElement` is a real, distinct PDB field in `acclient.h`'s `UIElementManager`, separate from the `m_pTooltipElement` family), so the immediate mount is DRAG-ONLY; the ordinary hover path merely STAGES the name (`SetTooltip @0x004E5D74` + `|= 0x20`) and the display rides `CheckTooltip @0x0045B6E0`'s mouse-idle dwell (`m_lastMouseMoveTime` stamped on EVERY move `@0x0045e736` + `m_tooltipDelay` 0.25 s `@0x0045f75d`), with found-object changes under an idle mouse swapping the popup same-frame via `SetTooltip`'s own text-change teardown (`@0x004617FF` → `ResetTooltip @0x0045C360` tail-calling `CheckTooltip`) and the 10 s `m_tooltipDuration` expiry requiring a fresh mouse move before re-arming (`SwitchMouseOver(null) @0x0045b7b2`) — all now ported, including the drag-immediate branch**, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. **BATCH B (2026-08-17) CLOSED the spellcasting and character-panel rows of this list. BATCH C (2026-08-17, Map/House toolbar panel) CLOSES THE LAST REMAINING ITEM: `gmMapUI::AddMapNote @0x004A1C51`'s 53 town-hotspot tooltips are now ported via `MapPageController.BuildTownMarkers` (`src/AcDream.App/UI/Layout/MapPageController.cs`), setting `UiButton.TooltipText` (retail's RUNTIME `m_TTText`/`SetTooltip` mechanism, not the DAT-authored `P0x49` path an earlier same-day cut mistakenly used and which never rendered live during verification); literal town names from `MapLocations.cs` (a verbatim port of `s_rgLocations`), not a DAT string-table lookup, matching `AddMapNote`'s own `StringInfo::SetLiteralValue` call. CORRECTED at the same-day morning gate round (user finding 3 — the retail screenshot's green hover highlight + special-font parchment tooltip): Batch C's "the town-marker template authors no locator of its own" claim was WRONG — the template (`0x100001F0` in `0x21000026`, `MapNoteLiveDatTests`) authors its OWN `P0x47=0x10000398`/`P0x48=0x21000041` (the fourth popup skin, whose incorporated text child `0x10000396` fonts `0x40000015` where the other three skins font `0x40000002`), a zero per-element delay `P0x50=0.0`, `P0x4B` TooltipOn, and `P0x13` RolloverEnabled with PassToChildren `Normal`/`Normal_rollover` states flipping the highlight child `0x100001F1`'s per-state `P0x3B` (the green `0x06004CC9` frame, byte-decoded A=FF R=00 G=FF B=00); the hardcoded shared-skin override was removed (the built marker's authored locator wins) and the rollover highlight + per-state-`P0x3B` + button PassToChildren cascade are now ported (`UiButton.CascadeStateToChildren`, `UiDatElement.TrySetRetailState`'s 0x3B honor). Sub-mechanism (1)'s `SetTooltip`-call-site enumeration is 16 of 17 known sites PORTED — `UIElement_Text::RecalculateTruncation @0x00466F80` (the headline, highest-volume site named at the top of sub-mechanism (1)) remains the ONE open item, exactly as this row's own sub-mechanism (1) text above already scoped it out (its own "Sized as genuinely disproportionate... deferred here" note). The prior "all 15 known sites accounted for" close (F12 correction, night-round review) was wrong twice over: the count is 17, not 15, and RecalculateTruncation was never actually ported — it was always the one deliberately-deferred item, not a closed one.** Batch B audit findings: the endowment icon `@0x004C63A1`, favorite `@0x004C7206`, and submenu `@0x004C67D8` sites turned out to be ALREADY CORRECT — all three are `UiCatalogSlot`-based and the pre-existing `Label`-driven `GetTooltipText` already carried retail's exact text (`SpellCastSubMenu::AddFavorite @0x004C7060`/`UpdateFromPlayerModule @0x004C6570` both build a single-arg `Formatted` PStringBase — plain spell name, no wrapper — for the favorite-bar/submenu case; `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` mirrors the cast button's confirmed `"%s (%hs)"` literal at `data_7b64d8` — item name, then spell name in parens — via the identical two-value narrow/wide prep sequence, one address away in the same function family). The cast button `@0x004C6FE8`/`ClearTooltip @0x004C6AAE` was the one real gap (`UiButton` had no tooltip wiring at all): now ported via `SpellcastingUiController.UpdateCastAvailability`/`ComputeEndowmentCastState`, sourced from `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30`. Fully verified literal text: the no-selection states (`"Select a spell to cast"` @ `data_7b64ec`, `"You have no spells ready to cast"` @ `data_7b6520`) and the complete endowment-item branch (`"USE the %s"` @ `data_7b64c0`, `" on %s"` @ `data_7b6464`, `"You must select a target for the %s"` @ `data_7b6478`; `ItemUses::IsUseable_SelfTarget @0x004fcd30` is exactly `ItemUseability.AllowsSelfTarget`). NOT ported: the endowment branch's incompatible-target sub-state (`"You must select an appropriate\ntarget for the %s"` @ `data_7b6400`, gated by `ItemHolder::TargetCompatibleWithObject @0x00587520` — a ~400-line function with its own chat-message side effects, out of scope for a tooltip batch; a present target is optimistically treated as compatible, same text as the confirmed-compatible case). **CORRECTED at the night-round review (F3/F4, 2026-08-17): the plain-spell branch's wording is NOT unrecoverable — the "genuine `gmNoticeHandler` vtable SLOTS" claim above was itself the artifact. `PStringBase::sprintf`'s second argument at those three call sites is a raw pushed literal (a plain `push 0x7bXXXX; call sprintf`); Binary Ninja's pseudo-C rendering of that operand as `&gmSpellcastingUI::\`vftable'.RecvNotice_XXX` was a spurious symbol match, not the true operand — a direct capstone disassembly of the raw bytes at `0x4c6e48`/`0x4c6ea4`/`0x4c6f18`/`0x4c6f5d` resolves the actual constants: `"CAST %hs"` @0x7b63a4 (untargeted/self-cast at `0x4c6f35`, and targeted+compatible at `0x4c6e57` — both enabled, the latter appending `" on %s"` @0x7b6464 with the target's name), `"You must select an appropriate target for %hs"` @0x7b6348 (targeted+incompatible, disabled), `"You must select a target for %hs"` @0x7b63b8 (no target, disabled); `%hs` is the spell's own name in all four call sites (`CSpellBase::InqName`, the same call `0x5bbee0` throughout). Now ported: `RuntimeSpellCastState.EvaluateCastGate` (the four-state gate) + `SpellcastingUiController.ComputeSpellCastState`. Also corrected the endowment branch's "USE the %s" operand: it was NOT the bare item name (F4) — the vararg to `"USE the %s"`/`"You must select a target for the %s"`/the still-unported incompatible-target string is the SAME composed `"%s (%hs)"` string (item name, spell name) built once at `@0x004c6bb6-ef` from format literal `data_7b64d8`, byte-confirmed by all three sprintf call sites (`0x4c6c7f`/`0x4c6ca4`/`0x4c6d46`) reading the identical `[esp+0x18]` slot — now ported via `SpellcastingUiController.ComposeEndowmentName`.** The character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors are now fully ported through a new `UiClickablePanel.TooltipText` (the same settable-string seam as `UiButton.TooltipText`): the six hardcoded attribute descriptions (`SkillSystem::InqAttributeDescription @0x005c8e30`) and three hardcoded, pair-shared vitals descriptions (`SkillSystem::InqAttribute2ndDescription @0x005c8f70`) were byte-decoded from the retail string pool (the pseudo-C dump truncates them with "…"); skill tooltips compose `SkillInfoRegion::GetTooltip @0x004f1fe0`'s exact `"\n" + formula + description` (ported verbatim, including the confirmed lack of any separator between the formula and description text) from the ALREADY-DAT-parsed `DatReaderWriter.Types.SkillBase.Description`/`.Formula` fields (portal `0x0E000004`, the same resource `CharacterSheetProvider.SkillTable` already reads for skill names/costs) rather than hand-transcribed literals — no guessing was needed for the ~30+ skill description strings. The formula-to-text algorithm itself (`SkillSystem::InqSkillFormula @0x005c89b0`, e.g. producing `"( (Strength + Coordination) / 2 )"`) was fully recovered by byte-decoding six short literal fragments (`data_7e7930`/`7e7934`/`7e7940`/`7e7950`/`7e7954`/`797584`) the pseudo-C dump left completely unlabeled — they sit between two `gmSpellcastingUI` vtable declarations and Binary Ninja's type inference never recognized them as strings, so the raw hex had to be read directly as narrow ASCII (confirmed against the function's own directly-visible `" / %u"` and `"(%u x %s)"` literals, which needed no such recovery). Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row); Batch B (2026-08-17) additions: `src/AcDream.App/UI/UiPanel.cs` (`UiClickablePanel.TooltipText`/`GetTooltipText`); `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`UpdateCastAvailability`, `ComputeEndowmentCastState`); night-round review (F3/F4, 2026-08-17) additions: `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`ComputeSpellCastState`, `ComposeEndowmentName`); `src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs` (`EvaluateCastGate`, `SpellCastGate`); `src/AcDream.App/Net/RetailSkillFormula.cs` (`AttributeName`, `FormatFormula`, `BuildTooltip`); `src/AcDream.App/UI/Layout/CharacterSheet.cs` (`CharacterSkill.TooltipText`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`BuildLiveCharacterSkills`'s tooltip compose); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`AttributeDescriptions`, `Attribute2ndDescriptions`, `BuildAttributeRows`/`BuildSkillRows` row wiring) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`); Batch B (2026-08-17): `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30` (cast-button state machine); `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` (endowment-icon `"%s (%hs)"` confirmation); `SpellCastSubMenu::AddFavorite @0x004C7060` / `UpdateFromPlayerModule @0x004C6570` (favorite/submenu plain-name confirmation); `ItemUses::IsUseable_SelfTarget @0x004fcd30`; `AttributeInfoRegion::AttributeInfoRegion @0x004f1530` / `Attribute2ndInfoRegion::Attribute2ndInfoRegion @0x004f1680` / `SkillInfoRegion::SkillInfoRegion @0x004f2140` / `SkillInfoRegion::GetTooltip @0x004f1fe0`; `SkillSystem::InqAttributeName @0x005c8d90` / `InqAttributeDescription @0x005c8e30` / `InqAttribute2ndName @0x005c8ed0` / `InqAttribute2ndDescription @0x005c8f70` / `InqSkillFormula @0x005c89b0` | | TS-84 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index b3d5ec59..a449b22b 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -22,7 +22,7 @@ particles), and uses the same Silk.NET + .NET stack we target. our tree (see CLAUDE.md for the full breakdown): - `src/AcDream.Core/Rendering/Wb/` — pure helpers (no GL): `TerrainUtils`, `TerrainEntry`, `RegionInfo`, `SceneryHelpers`, `TextureHelpers`. -- `src/AcDream.App/Rendering/Wb/` — Vulkan/RHI infrastructure + mesh pipeline: +- `src/AcDream.App/Rendering/Wb/` — GL infrastructure + mesh pipeline: `ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`, texture cache, shader infra, EnvCell/portal/scenery/terrain-blending pipeline classes. @@ -42,7 +42,7 @@ non-render content plus explicit bake/equivalence tooling. **MP1a (2026-07-05): CPU mesh-extraction half moved to `AcDream.Content`.** The GL-free portion of the former `ObjectMeshManager` — dat read → polygon -walk → vertex/index build → palette/conditional BCn texture decode → +walk → vertex/index build → inline BCn/palette texture decode → `ObjectMeshData` — is now `MeshExtractor` in a new `src/AcDream.Content/` assembly (no Silk.NET dependency), so the MP1b bake tool can run the exact same extraction code offline without an OpenGL context. This was a @@ -84,7 +84,7 @@ behavior change, no divergence-register row. survive, as they always did). The MP1b bake tool passes its own collector. - **Stays in `src/AcDream.App/Rendering/Wb/`:** `ObjectMeshManager` (the - staged-queue/worker-pool/Dispose-quiesce lifecycle and all Vulkan/RHI upload; + staged-queue/worker-pool/Dispose-quiesce lifecycle and all GL upload; production workers now consume `IPreparedAssetSource`), `ObjectRenderData`/`ObjectRenderBatch` (hold a GL `TextureAtlasManager` field), `TextureAtlasManager`, @@ -120,8 +120,9 @@ EnvCell, Surface, palette, and texture graphs during portals. The validated machine-local `acdream.pak` is opened through Content's `IPreparedAssetSource`; typed GfxObj and EnvCell requests deserialize immutable `ObjectMeshData` while retaining the existing App worker, staging, render-thread -upload, cache, ownership, and shutdown contracts. The prepared render payload -persists exact batch translucency so App does not reconstruct a +upload, cache, ownership, and shutdown contracts. The original +format-1/bake-tool-3 render payload persists exact batch translucency so App +does not reconstruct a `GfxObjMesh` for metadata. Setup activation uses the package TOC as an explicit type-presence index before reading valid Setup records through the bounded DAT cache. `DatPreparedAssetSource` and `MeshExtractor` remain explicit @@ -131,39 +132,8 @@ bake/equivalence/UI-Studio tools, not a production fallback. Portal → HighRes installed-DAT gates are recorded in `docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`. -**Launcher cumulative-overlay extension (2026-08-25).** Production still has -no live-DAT fallback and consumes the same prepared-payload contracts. For a -bounded recipe migration, App and Headless may receive one complete base pak -plus one cumulative filtered pak through `LayeredPreparedAssetSource`. The -overlay is probed first: Missing falls through to the base, while a present but -corrupt render or collision payload remains authoritative corruption. Both -mapped owners share one composite lifetime and there is never an overlay -chain. The launcher binds the overlay to the base digest in the optional -`pak/content.current.json` sidecar; format/global extraction migrations retain -the explicit full-rebuild path. A tiny `pak/content.client-pending` marker -keeps either result non-launchable until the matching client is confirmed, -including across a crash/restart. Design and gates: -`docs/plans/2026-08-25-launcher-content-stabilization.md`. - -**PAK v2 resource closeout (2026-08-27).** Format 2 / bake recipe 6 retains -the fixed header and sorted random-access TOC while moving texture arrays into -one globally shared type-8 payload partition and independently applying -adaptive Brotli compression with raw fallback. Unedited DXT1/3/5 surfaces -retain exact DAT BC blocks through Vulkan upload; clip maps and authored -translucency continue through the RGBA edit path. The reader bounds decoded -blobs and retains shared texture arrays in a 64 MiB / 1,024-entry LRU. The -complete installed package is 597,229,424 bytes versus 29,908,271,024 bytes -for format 1; four- and nine-worker bakes have identical SHA-256. The -authoritative connected before/after route reduced heavy-route working set by -48.0%, private bytes by 26.1%, and prepared-mesh GPU bytes by 25.9% with equal -or faster matching reveal/frame percentiles. Format changes remain mandatory -launcher-confirmed full rebuilds with beside-active validation and atomic -promotion; overlays never cross a format boundary. Design and evidence: -`docs/plans/2026-08-27-pak-v2-resource-campaign.md`. - -**Slice I3 prepared collision extension (2026-07-25).** At its introduction, -the package remained format 1 and retained mesh type values 1–3; bake-tool 4 -appended typed GfxObj, +**Slice I3 prepared collision extension (2026-07-25).** The package remains +format 1 and retains mesh type values 1–3; bake-tool 4 appends typed GfxObj, Setup, CellStruct, and EnvCell-topology collision payloads. Core owns the immutable flat records and deterministic raw-DAT flattener. Content owns the strict little-endian codec and `IPreparedCollisionSource`. diff --git a/docs/launch-options.md b/docs/launch-options.md deleted file mode 100644 index 0d6178ec..00000000 --- a/docs/launch-options.md +++ /dev/null @@ -1,383 +0,0 @@ -# acdream launch options — operator reference - -Every environment variable and command-line argument the acdream client -reads, what it does, and **what else it changes about the run**. - -**This is an operator's reference, not user documentation.** Players never -set these: the launcher owns installation and login, and the in-client -Options panel (F11) owns settings. If a flag here looks like something a -player would want, that is a signal it belongs in the Options panel, not a -signal to document it better. - -## How to use this document - -- **Running the client for yourself?** Read *Production launch* and stop. -- **Taking a measurement?** Read *Production launch*, then read the - *Side effects* column of every flag you are about to set. A flag that - changes what you are measuring is the normal case, not the exception. -- **Adding a flag?** Add its row in the same commit. `LaunchOptionsDocumentationTests` - fails the build otherwise — in both directions, so deleting a read site - without deleting its row fails too. - -### Why the side-effects column exists - -Two flags in this list were believed to be inert and were not: - -- `ACDREAM_AUTOMATION_ARTIFACT_DIR` reads like an output path. It also - constructs a per-frame diagnostics referee that re-enabled a retired - render pass, costing ~6 MB and ~14 ms **every frame** — three days of - performance measurements were silently taxed before anyone noticed - ([#432](ISSUES.md)). -- `ACDREAM_STREAM_RADIUS` reads like a radius knob. It forces the near - radius, only ever *raises* the far radius, and is then silently - discarded by any later quality apply — so a measurement taken with it - set is measuring a window production never uses. - -Assume a flag has a side effect until its row says otherwise. - -## Conventions - -- **Everything diagnostic is OFF by default.** Every probe, dump, capture, - and measurement flag in this document is inert until its variable is - explicitly set — an unset environment runs zero diagnostics. Exactly - four flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`, - `ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and - `ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B - off-switch (`=0` disables the behavior for a comparison run). That - four-flag set is frozen by `LaunchOptionsDocumentationTests` — a new - default-on flag fails the build. -- `=1` means the code tests for exactly the string `1`. Setting `true`, - `yes`, or `0` does **not** enable such a flag (and `0` does not disable - one whose test is "is the variable present"). -- **Default** is the behavior when the variable is unset. -- **Kind** is one of: - -| Kind | Meaning | -|---|---| -| `production` | Ordinary configuration; safe in a real run. | -| `measurement` | Profiling/instrumentation. Read the side effects before trusting numbers taken with it on. | -| `automation` | Drives scripted runs; usually implies extra machinery. | -| `permanent-probe` | A diagnostic toggle owned by a subsystem's diagnostics class. Expected to persist. | -| `temporary-probe` | Tied to an open investigation. Deleted with its issue — never build tooling on one. | -| `deprecated` | Superseded. Do not use for new work. | - ---- - -## Production launch - -The canonical connected launch against a local ACE server. PowerShell, -because the DAT path contains an apostrophe: - -```powershell -$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" -$env:ACDREAM_LIVE = "1" -$env:ACDREAM_TEST_HOST = "127.0.0.1" -$env:ACDREAM_TEST_PORT = "9000" -$env:ACDREAM_TEST_USER = "testaccount" -$env:ACDREAM_TEST_PASS = "testpassword" -$env:ACDREAM_RETAIL_UI = "1" -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release -``` - -| Flag | Value | What it does | Side effects | Default | Read by | -|---|---|---|---|---|---| -| `ACDREAM_A2C` | `unset/""` keep preset; `"0"/"false"/"False"/"FALSE"` → off; any other non-empty → on | Overrides preset's `AlphaToCoverage` blend flag | Changes MSAA alpha-to-coverage blending mode for foliage/translucent draws — a visual-behavior change, not just perf | preset's `AlphaToCoverage` (High/Ultra=true, Low/Medium=false) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:52`) | -| `ACDREAM_AC_DIR` | `=` | 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_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` | -| `ACDREAM_CAMERA_COLLIDE` | `=0` disables (anything else/unset = on) | selects whether the chase camera sweeps a 0.3 m collision sphere from head-pivot to eye and stops at the first wall (retail spring-arm) | alters camera position every frame (camera can clip into geometry when disabled); startup-only | true (on) | `CameraDiagnostics.CollideCamera` | -| `ACDREAM_CONFIG_DIR` | `=` | Overrides the resolved config-root directory (`settings.json`, `keybinds.json`) | none beyond redirecting config I/O | Windows: `%APPDATA%\acdream`; Linux: `$XDG_CONFIG_HOME/acdream` or `~/.config/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:79`), via `IApplicationPathEnvironment` seam | -| `ACDREAM_DATA_DIR` | `=` | Overrides the resolved data-root directory (logs, screenshots, plugins) | none beyond redirecting data I/O | Windows: `%LOCALAPPDATA%\acdream`; Linux: `$XDG_DATA_HOME/acdream` or `~/.local/share/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:81`), via `IApplicationPathEnvironment` seam | -| `ACDREAM_DAT_DIR` | `=` | Fallback dat-directory when no positional argument is given. App: single read at `Program.cs:58`. Cli: read independently per-subcommand (each subcommand does `args.ElementAtOrDefault(N) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")`) plus once more for the default (no-subcommand) asset-inventory mode at line 152. | Two of the four `Program.cs` line numbers in the raw grep (91, 135) are **not reads** — they're the literal string `ACDREAM_DAT_DIR` inside `Log.Error` usage-text messages, not `GetEnvironmentVariable` calls. Only line 58 is a real read in `AcDream.App`. | none — hard usage error (exit 2) if unset and no positional arg | `Program.cs:58` (App); `Cli/Program.cs:24,35,47,59,71,84,113,125,137,152` (every Cli subcommand) | -| `ACDREAM_DISPLAY_PROTOCOL` | `="auto"` / `"x11"` / `"wayland"` (case-insensitive, trimmed); any other value throws `InvalidOperationException` at startup | Linux-only: forces the GLFW 3.4 platform-init hint (X11 vs Wayland vs auto) before any window is created; ignored entirely on Windows (always `Windows` protocol) | An invalid value is fatal at startup (throws before any window exists), not a silent fallback | unset → auto-detected from `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY`/`DISPLAY`, falling back to GLFW `Automatic` | `GraphicalWindowBackendSelection.Resolve` (`GraphicalWindowBackendSelection.cs:26-58`) | -| `ACDREAM_FAR_RADIUS` | `=` | Overrides preset's `FarRadius` (outer streaming/reveal window, landblocks) | Enlarging changes streaming memory budget and what's resident/rendered — CLAUDE.md: leave unset for measurement/gate runs (same family as legacy `ACDREAM_STREAM_RADIUS`) | preset's `FarRadius` (Low=5, Medium=8, High=12, Ultra=15) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:47`) | -| `ACDREAM_LIVE` | `=1` (exactly the literal string `"1"`) | Core switch: connect to a live ACE server instead of running offline/no-connect. | The 4 non-`RuntimeOptions.cs` line numbers in the raw grep are **all comments or log-message text**, not reads — `SessionStartComposition.cs:39` is inside the string `"live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"`; `Program.cs:126` is inside a `--session-config` override log line; `GameWindow.cs:614,627` are doc comments. The only actual parse is `RuntimeOptions.cs:141`. Requires `ACDREAM_TEST_USER`/`ACDREAM_TEST_PASS` too (`HasLiveCredentials`) or the session silently reports `MissingCredentials` and skips. Forced to effectively-on (LiveMode=true) unconditionally by `--session-config` launches regardless of this var. | `false` | `RuntimeOptions.LiveMode` → `SessionStartComposition.cs` (log text only), `Program.cs:126` (log text only), `GameWindow.cs:614,627` (comments only), consumed for real via `RuntimeOptions.HasLiveCredentials` and `WorldSession`/`GameRuntime` session-start gating | -| `ACDREAM_MAX_COMPLETIONS_PER_FRAME` | `=` | Overrides preset's per-frame streaming-completion throughput cap | Directly changes the streaming admission budget measured by perf/completion gates — do not vary during a measurement run | preset's value (Low=2, Medium=3, High=4, Ultra=6) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:59`) | -| `ACDREAM_MSAA_SAMPLES` | `=` (0/2/4/8) | Overrides preset's MSAA sample count | Changes GPU multisample anti-aliasing (visual + GPU-cost change) | preset's `MsaaSamples` (Low=0, Medium=2, High/Ultra=4) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:48`) | -| `ACDREAM_NEAR_RADIUS` | `=` | Overrides preset's `NearRadius` (inner streaming ring radius) | Changes the streaming/render window size — CLAUDE.md: leave unset for measurement/gate runs | preset's `NearRadius` (Low=2, Medium=3, High=4, Ultra=5) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:46`) | -| `ACDREAM_NO_AUDIO` | `=1` | Suppresses OpenAL device init entirely (headless / driver-broken machines). | Fail-open design: even without this flag, a missing/broken OpenAL driver already makes `IsAvailable=false` and all `Play*` calls no-ops — this flag is the explicit, deliberate version of the same fallback. When set, `LiveSessionWorldRuntime.WorldAudio` is `null` and logout-audio reset/resume steps no-op. | `false` | `RuntimeOptions.NoAudio` → `GameWindow.cs:1430` → `ContentEffectsAudioCompositionPhase` → `OpenAlAudioEngine.cs` (fail-open doc), `LiveSessionRuntimeFactory.cs:71` (`WorldAudio` gate) | -| `ACDREAM_PAK_PATH` | `=` | Overrides the path to the prepared machine-local asset bake (`acdream.pak`) that production world-mesh streaming reads through `IPreparedAssetSource`. | none beyond the obvious | unset → `/acdream.pak` | `RuntimeOptions.PreparedAssetPath` → `ContentEffectsAudioComposition.cs:53,372,379`, `GameWindow.cs:1420` | -| `ACDREAM_PLUGIN_TAGS` | comma-separated tags (maximum 128 tags, 128 characters each) | Advertises machine-local role/group tags through the plugin peer-discovery API, for UtilityBelt-compatible expressions such as client selection by tag. Values are trimmed and deduplicated case-insensitively. | Writes the tags into the bounded local peer heartbeat document while a character is in world; no network traffic leaves the machine. | unset → no tags | `RuntimeOptions.PluginTags` → `AppAutomationSurface` / `LocalPluginPeerRegistry` | -| `ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB` | `=` (`>0`, else default; overflow-checked) | Byte ceiling for the retail alpha (translucency) draw queue's scratch buffer | Shrinking below production working set changes translucency-queue eviction/reflow behavior — not comparable to a default-budget perf run | 16 MiB | `ResidencyBudgetOptions.Parse` (`ResidencyBudgetOptions.cs:88-89`), flows through `RuntimeOptions.ResidencyBudgets`→`AlphaScratchBudgetProfile.Create`→`RetailAlphaQueue` ctor (`GameWindow.cs:721-725`) | -| `ACDREAM_RESIDENCY_ANIMATION_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for the retained animation-data cache | Changes cache eviction cadence for animation data — a perf/memory measurement under a non-default value is not representative | 512 | `ResidencyBudgetOptions.Parse` (`:82-84`) | -| `ACDREAM_RESIDENCY_ANIMATION_MIB` | `=` (`>0`, else default) | Byte ceiling for the retained animation-data cache | Same cache-behavior-change caveat as the entries variant | 64 MiB | `ResidencyBudgetOptions.Parse` (`:79-81`) | -| `ACDREAM_RESIDENCY_AUDIO_MIB` | `=` (`>0`, else default) | Byte ceiling for the retained audio-buffer cache | Shrinking can force more frequent audio buffer re-decode/eviction | 32 MiB | `ResidencyBudgetOptions.Parse` (`:85-87`), consumed by `ContentEffectsAudioComposition.cs` | -| `ACDREAM_RESIDENCY_COMPOSITE_PHYSICAL_MIB` | `=` (`>0`, else default) | Byte ceiling for physically-resident composite (character palette/texture) GPU memory | Changes composite-texture eviction pressure — not representative of production if varied during a measurement run | 128 MiB | `ResidencyBudgetOptions.Parse` (`:67-69`), consumed by `TextureCache.cs` | -| `ACDREAM_RESIDENCY_COMPOSITE_UNOWNED_MIB` | `=` (`>0`, else default) | Byte ceiling for unowned/retained (not currently referenced) composite textures kept for reuse | Same eviction-pressure caveat | 64 MiB | `ResidencyBudgetOptions.Parse` (`:70-72`), consumed by `TextureCache.cs` | -| `ACDREAM_RESIDENCY_MESH_GPU_MIB` | `=` (`>0`, else default) | Byte ceiling for GPU-resident object mesh data | The single largest residency budget (1024 MiB default) — shrinking it directly forces more mesh re-upload/eviction; do not vary during an FPS/GPU-memory measurement run | 1024 MiB | `ResidencyBudgetOptions.Parse` (`:49-51`), consumed by `ObjectMeshManager.cs`/`WbDrawDispatcher.cs` | -| `ACDREAM_RESIDENCY_MESH_STAGING_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for the mesh upload staging cache | Changes staging-buffer churn/eviction cadence | 256 | `ResidencyBudgetOptions.Parse` (`:64-66`) | -| `ACDREAM_RESIDENCY_MESH_STAGING_MIB` | `=` (`>0`, else default) | Byte ceiling for the mesh upload staging cache | Same staging-churn caveat | 128 MiB | `ResidencyBudgetOptions.Parse` (`:61-63`) | -| `ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for unowned (retained-for-reuse) object mesh entries | Changes mesh-cache eviction cadence | 50 | `ResidencyBudgetOptions.Parse` (`:52-54`) | -| `ACDREAM_RESIDENCY_PREPARED_MESH_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for the CPU-side "prepared mesh" cache (post-classification, pre-upload) | Changes eviction cadence for prepared-mesh CPU memory | 100 | `ResidencyBudgetOptions.Parse` (`:58-60`) | -| `ACDREAM_RESIDENCY_PREPARED_MESH_MIB` | `=` (`>0`, else default) | Byte ceiling for the CPU-side prepared-mesh cache | Same eviction-cadence caveat | 128 MiB | `ResidencyBudgetOptions.Parse` (`:55-57`) | -| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for unowned standalone (non-composite) texture entries | Changes standalone-texture eviction cadence | 256 | `ResidencyBudgetOptions.Parse` (`:76-78`), consumed by `TextureCache.cs` | -| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB` | `=` (`>0`, else default) | Byte ceiling for unowned standalone texture memory | Same eviction-cadence caveat | 32 MiB | `ResidencyBudgetOptions.Parse` (`:73-75`) | -| `ACDREAM_RETAIL_CHASE` | `=0` disables (anything else/unset = on) | selects the retail-faithful `RetailChaseCamera` vs. the legacy rigid-follow `ChaseCamera` | swaps the entire active camera implementation — changes camera motion/feel; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (retail camera on) | `CameraDiagnostics.UseRetailChaseCamera` | -| `ACDREAM_RETAIL_CLOSE_DEGRADES` | inverted: `="0"` disables; any other value (incl. unset) enables | Default-**on** real gameplay behavior: applies retail's close-range LOD mesh-part swap (`GfxObjDegradeResolver`) to humanoid setups (issue #47), matching retail's close-detail degrade. | Inverted default (opposite of every other boolean flag in this table — presence of the literal string `"0"` is what disables it, not presence of `"1"` enabling it). Documented explicitly as "set only for before/after diagnostic comparisons" — so although default-on production behavior, its *disable* path exists purely for A/B measurement. | `true` (enabled) unless value is exactly `"0"` | `RuntimeOptions.RetailCloseDegrades` → `DatLiveEntityProjectionMaterializer.cs:275-276,480-498` | -| `ACDREAM_RETAIL_UI` | `=1` | Switches on the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b). Without it, no retained UI is composed at all — e.g. no chargen Appearance page, no Summary page. | **Forced to `true` unconditionally** for every `--session-config` / launcher launch (`RuntimeOptions.cs:282`, "a session-config launch IS a product launch — the retail UI is the shipped UI, not a dev option"), regardless of this env var's value — the env var only matters for the bare env-var dev-flow launch path. | `false` for the env-var dev flow; `true` always for `--session-config` launches | `RuntimeOptions.RetailUi` → `LivePresentationComposition.cs:1108-1131` (gates retained-UI mount via `InteractionRetainedUiComposition`), `GameWindow.cs:455,566` (comments), `RuntimeOptions.cs:277` | -| `ACDREAM_TEST_HOST` | `=` | 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`) | -| `ACDREAM_TEST_USER` | `=` | ACE account name for live-mode connect. | none | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LiveUser` (`RuntimeOptions.cs:144`) | -| `ACDREAM_VULKAN_DEVICE` | `=` (decimal index) or `=` (case-insensitive device-name match) | Overrides automatic Vulkan physical-device selection (normally: discrete > integrated > virtual > CPU, tie-broken by device-local heap size) — for multi-GPU machines. | A bare-digits value is matched as an index ONLY (never falls through to substring match) specifically because digits like `"7"` are substrings of real device names ("AMD Radeon RX 9070 XT") — a fallback would silently select the wrong device by coincidence. An override matching nothing falls back to the automatic choice (does not fail startup) and records why in the capability report. | `null` → automatic ranked choice | `RuntimeOptions.VulkanDeviceOverride` → `VulkanPhysicalDeviceSelection.Choose` (`VulkanPhysicalDeviceSelection.cs:54-100`), consumed at `VulkanGraphicsContext.cs:207,322` | - -## Command-line arguments - -### `AcDream.App` - -| Arg | What it does | Side effects | -|---|---|---| -| `` (positional) | Dat directory; outranks `ACDREAM_DAT_DIR`. | Not read at all once `--session-config` is present. | -| `--session-config ` | The launcher's launch path: endpoint, account, credential reference, character selector, status file, plugins, login commands. | **Overrides `ACDREAM_LIVE` and every `ACDREAM_TEST_*`** (logged at startup). Diagnostic flags stay env-controlled. Missing value is a startup error. | - -### `AcDream.Headless` - -Its usage banner matches the parser exactly. `validate` loads and checks a -config without connecting; `run` connects. - -| Arg | What it does | Side effects | -|---|---|---| -| `validate` \| `run` (positional) | Selects the mode; must be the first argument. | Anything else is a parse error. | -| `--config ` | The versioned headless session-configuration document. Required. | — | -| `--config-dir` / `--data-dir` / `--cache-dir` `` | Override each portable path root. | Merged over the config document's own `process.paths`; the command line wins. | -| `-user` / `--user`, `-password` / `--password` | Direct single-session credentials, bypassing the config's credential source. | Plaintext in the process command line — prefer the config's credential reference. | -| `--help` / `-h` (or no args) | Prints usage, exits 0. | — | - -### `AcDream.Launcher` - -| Arg | What it does | Side effects | -|---|---|---| -| `--verify-publish` | Packaging smoke probe: parses arguments and exits 0 without opening a display or resolving user paths. | — | -| `--config-dir` / `--data-dir` / `--cache-dir` `` | Override each path root. | **All three or none** — supplying a subset is an error. Must be absolute. | -| `--update-manifest-uri ` | Points the self-updater at a different release manifest (test-feed seam). | Must be `https://` (or loopback `http://`). Changes where updates come from — do not point a real install at a test feed. | -| `--acdream-self-update-helper-v1`, `--acdream-self-update-confirm-v1` | Internal re-exec markers for the self-update handoff. | Not user-facing; never pass these by hand. | - -### `AcDream.Cli` - -A dat-dump and measurement tool dispatched by a positional subcommand -(`args[0]`); no `--flag` options. Most subcommands take a dat directory and -fall back to `ACDREAM_DAT_DIR`. - -- **Measurement:** `summarize-frame-history `, - `compare-screenshots [channelTolerance=2] [maxDifferentFraction=0.001] [mask.png]`, - `probe `. -- **Dat inspection:** no subcommand (asset-type inventory), `dump-vitals-bars`, - `dump-vitals-layout [0xLayoutId]`, `list-ui-layouts [0xRootType]`, - `dump-sprite-sheet <0xId,...>`, `dump-font-atlas [0xFontId] [sample] [outBase]`, - `dump-edges <0xId>`, `export-ui-sprite <0xId> [out.png]`. -- **Mockup rendering:** `render-vitals-mockup [out.png]`, `mock-selbar [out.png]`, - `crop `. - -## Measurement and profiling - -| Flag | Value | What it does | Side effects | Default | Read by | -|---|---|---|---|---|---| -| `ACDREAM_CAPTURE_RESOLVE` | `=` | appends one JSON-Lines record (full before/after `PhysicsBody` snapshot) per player-side `ResolveWithTransition` call, filtered to `IsPlayer` movers | real per-tick allocation (snapshot object graph + `System.Text.Json` serialize) and buffered file I/O (`AutoFlush=false`) for the local player only; will skew any perf measurement of local-player physics while active; feeds `CellarUpTrajectoryReplayTests` fixtures | unset (off) | `AcDream.Core.Physics.PhysicsResolveCapture` (`CapturePath`) | -| `ACDREAM_COLLISION_SHADOW_DIR` | `=` | output directory for Slice I5 graph/flat collision-shadow mismatch artifacts | only takes effect when `ACDREAM_COLLISION_SHADOW_EVERY>0`; directory creation + file writes on mismatch | `/.test-out/collision-shadow` | `PhysicsDiagnostics.CollisionShadowArtifactDirectory` | -| `ACDREAM_COLLISION_SHADOW_EVERY` | `=` | when >0 and the cache is constructed with `requirePreparedCollision:false`, arms a `CollisionShadowVerifier` that re-runs the graph-vs-flat collision referee every Nth traversal entry (`PhysicsDataCache` ctor) | extra CPU on sampled ticks + mismatch-artifact file I/O; graph path stays authoritative regardless of mismatch (doc-asserted, not independently verified here) — does not change production physics results, but does add work when active | `0` (disabled) | `PhysicsDiagnostics.CollisionShadowSampleEvery` (parsed via `ParsePositiveInt`, non-positive → 0) | -| `ACDREAM_DAY_GROUP` | `=` | Forces Dereth's day-group (weather preset) selection instead of the retail hash-based pick, "useful for visually A/B-testing each weather preset against retail" (own doc comment). | **Dead second read**: the `SkyDescLoader.cs:252` raw read only feeds `SelectDayGroupIndex`, which is only called from `ActiveDayGroup(double)` and the `DefaultDayGroup` property — and grepping all of `src/` finds **zero production call sites** for either. That whole path is unreachable; only the typed `RuntimeOptions.ForcedDayGroupIndex` → Runtime path is live. Bounds differ too: the typed path only checks `>= 0` (`TryParseNonNegativeInt`) and Runtime clamps out-of-range to `null`; the dead Core-layer path checks `forced >= 0 && forced < DayGroups.Count` directly. `SkyState.cs:400,403` are doc-comment mentions only, not reads. | unset → normal server/date-driven hash selection | `RuntimeOptions.ForcedDayGroupIndex` (typed) → `GameWindow.cs:718` → `WorldEnvironmentController` → `RuntimeWorldEnvironmentState` (Runtime, live path); **also** raw `Environment.GetEnvironmentVariable` at `SkyDescLoader.cs:252` (Core layer, separate parse) | -| `ACDREAM_DISABLE_TIER1_CACHE` | `="1"` (ordinal exact match; anything else = enabled) | A/B diagnostic that forces **every** static (non-animated) entity through the slow per-entity classification path, bypassing the Tier-1 classification cache (`#53`) | Materially changes per-frame CPU cost for entity classification — a perf/FPS measurement taken with this set is NOT representative of production and must not be compared against a normal run | unset (cache enabled) | `WbDrawDispatcher` ctor field `_tier1CacheDisabled` (`WbDrawDispatcher.cs:473-474`) | -| `ACDREAM_FRAME_HISTORY` | `=` | opts into a per-frame CSV history capture (frame idx, timestamps, per-stage CPU us, GPU us, alloc bytes) alongside the aggregated 5 s `[frame-prof]` report | allocates a `List` with ~131,072-record (~9 MiB) initial capacity, growing further for longer captures (~72 B/record, ~43 MB/hour at 165 fps) held in memory for the whole run; CSV write happens ONLY at `Dispose`/shutdown (no frame-thread I/O); only takes effect while `ACDREAM_FRAME_PROF` is ALSO on | unset (off) | `RenderingDiagnostics.FrameHistoryPath` / `AcDream.App.Diagnostics.FrameProfiler` | -| `ACDREAM_ORBIT_DISTANCE_METERS` | `=`, must be finite and `>0` | Diagnostic-only initial distance for the offline orbit camera, so deterministic renderer acceptance captures land inside a finite shadow reach. | Own doc comment: "used by deterministic renderer acceptance captures." Rejects non-finite/non-positive values silently (parses to `null`, camera default used). | `null` (unset) → normal camera default | `RuntimeOptions.InitialOrbitDistanceMeters` → `GameWindow.cs:1412` → offline orbit-camera composition | -| `ACDREAM_ORBIT_PITCH_DEGREES` | `=`, clamped `[-89, 89]` | Diagnostic-only initial orbit camera elevation. | Values outside `[-89,89]` or non-finite are silently rejected (→ `null`, default kept) rather than clamped. | `null` | `RuntimeOptions.InitialOrbitPitchDegrees` → `GameWindow.cs:1414` | -| `ACDREAM_ORBIT_YAW_DEGREES` | `=`, 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_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`) | -| `ACDREAM_STREAM_WORK_ENTITY_OPS` | `=` (`>0`, else default) | Per-frame ceiling on entity-cursor operations (small ops, e.g. one dictionary/index write each) on the update thread | A/B-measurement-only family. Doc comment: elapsed-time ceiling (`ACDREAM_STREAM_WORK_MS`) remains the authoritative CPU guard — this is a secondary cap, deliberately loose (leaves >90% of the time budget unused at default) | 4,096 | `StreamingWorkBudgetOptions.Parse` (`:62-64`) | -| `ACDREAM_STREAM_WORK_GL_RETIRE_OPS` | `=` (`>0`, else default) | Per-frame ceiling on GL/GPU resource-retirement operations on the update thread | A/B-measurement-only family; changes retirement cadence, which changes when GPU memory is actually reclaimed | 64 | `StreamingWorkBudgetOptions.Parse` (`:68-70`) | -| `ACDREAM_STREAM_WORK_GPU_MIB` | `=` (`>0`, else default) | Per-frame ceiling on GPU upload bytes on the update thread | A/B-measurement-only family; directly changes per-frame upload throughput | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:65-67`) | -| `ACDREAM_STREAM_WORK_HOLD_DEST_MS` | `=` (`>0` and finite, else default `8.0`) | Absolute (not quality-scaled) time ceiling for destination-lane work during a portal/login hold; never shrinks a profile whose own ceiling is already ≥ this value | Explicitly documented as "NOT a user setting... exists for A/B measurement only, matching the rest of the `ACDREAM_STREAM_WORK_*` family" — do not set outside a deliberate hold-latency A/B comparison | 8.0 ms | `StreamingWorkBudgetOptions.Parse` (`:74-76`); `HoldDestinationCeilingMilliseconds` widens the frame meter via `StreamingWorkBudget.WidenForDestinationHold` while a destination reservation hides the world behind the authored tunnel (#418) | -| `ACDREAM_STREAM_WORK_MS` | `=` (`>0` and finite, else default) | Per-frame elapsed-time ceiling for update-thread streaming work — "the authoritative CPU guard" per the entity-ops comment | A/B-measurement-only family; this is the primary per-frame time budget for streaming — changing it changes both perceived streaming latency and measured frame cost | 2.0 ms | `StreamingWorkBudgetOptions.Parse` (`:53-55`) | -| `ACDREAM_UNCAPPED_RENDER` | `=1` | Removes the normal VSync/refresh-rate software pacer, so the render loop runs as fast as the GPU/CPU allow. | Own doc comment (`RuntimeOptions.cs:147-150`): "Normal presentation is always bounded by VSync or a refresh-rate software pacer. This explicit diagnostic is the sole way to measure truly uncapped renderer throughput." Not representative of what a real player experiences — exists purely for throughput measurement. | `false` → VSync/pacer-bounded | `RuntimeOptions.UncappedRendering` → `GameWindow.cs:765` → `DisplayFramePacingController`; also `VulkanBringUpHost.cs:75` | -| `ACDREAM_WB_DIAG` | `=1` (raw `string.Equals` ordinal compare) | (a) `GameWindow`: gates the `[FRAME-DIAG]` render-thread entity-upload-distribution report; (b) `WbDrawDispatcher`: gates `BeginRhiTimer`/`SampleRhiTimers`, wrapping the opaque/detail/transparent draw passes in extra Vulkan GPU timer-scope queries and periodically logging a `[WB-DIAG]` CPU/GPU median/p95 report | adds extra per-pass GPU timestamp queries every frame while on — genuine measurement overhead; NOT read through `RenderingDiagnostics` or any diagnostics-owner class, unlike every other flag in this set — flag for whitelisting (see Notes #2); the flag's supposed interaction with `ACDREAM_FRAME_PROF`'s GPU query is stale documentation (see Notes #1) | unset (off) | read directly at `WbDrawDispatcher.cs:2061-2064` (every `Draw()`/`BeginEntityDispatch` call, i.e. effectively per frame, NOT cached) and cached once as a readonly field at `GameWindow.cs:153-156` | -| `ACDREAM_WORLD_TIME` | `=`, accepted only in `[0, 1)` | Campaign V slice V7 instrument-determinism pin: freezes the Dereth day fraction (and therefore sun direction, sky keyframe, and every lit surface) instead of following the server clock. | Outranks BOTH the server `TimeSync` clock and the `/time` slash command's `SetDebugTime` (which is deliberately transient — the next `TimeSync` clears it); this pin does not clear. Distinct axis from `ACDREAM_DAY_GROUP` (day-group/weather-preset selection) and `ACDREAM_SKY_PHASE_SECONDS` (cloud scroll + foliage wind) — the calendar DATE still advances, only the intra-day fraction freezes. Anything outside `[0,1)` (including negative, unparseable, or unset) leaves the server clock alone entirely — no partial/clamped behavior. | `null` → server clock | `RuntimeOptions.PinnedWorldDayFraction` → `GameWindow.cs:720` → `WorldEnvironmentController` → `Runtime.WorldTime.PinnedDayFraction` | - -## Automation - -A scripted route run adds three things at once — a session config so the -client self-selects a character, a route script, and an artifact directory: - -```powershell -$env:ACDREAM_UI_PROBE_SCRIPT = "$scratch\route.txt" -$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = "$scratch\artifacts" -$env:ACDREAM_FRAME_PROF = "1" -$env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" -& $exe --session-config "$scratch\session.json" -``` - -**Two traps this recipe exists to document:** - -1. **Without `--session-config`, the client stops at character select** and - the route never runs. The session JSON supplies the endpoint, account, - and a character `index` for auto-selection. -2. **`ACDREAM_AUTOMATION_ARTIFACT_DIR` is not free.** It constructs the - render-scene oracle, which fingerprints every resident entity every - frame. The allocation cost was fixed in - [#432](ISSUES.md), but the CPU walk remains — automation-run frame - rates are diagnostics-loaded and must only be compared against other - automation runs, never against a plain run. Some route verbs - (`wait world-*`) additionally do nothing unless this is set. - -| Flag | Value | What it does | Side effects | Default | Read by | -|---|---|---|---|---|---| -| `ACDREAM_AUTOMATION_ARTIFACT_DIR` | `=` | Output directory for the retail-UI automation probe's checkpoint JSON + screenshot PNG artifacts; gates whether the full `WorldLifecycleAutomationController` (checkpoint/screenshot/render-pack-automation capable) is composed at all vs. the cheaper facts-only `WorldRevealFactsAutomationRuntime` fallback (`wait world-ready/visible` verbs work either way per issue #415's fix; checkpoint/screenshot verbs report "requires ACDREAM_AUTOMATION_ARTIFACT_DIR" without it). | **Known #432 surprise, confirmed still live**: `FrameRootComposition.cs:349-353` — `AutomationArtifactDirectory is not null` (together with `RetainedUi?.Screenshots is not null`) unconditionally constructs a `CurrentRenderSceneOracle` **and** a `RenderSceneShadowComparisonController` — a per-frame diagnostics referee — regardless of whether any checkpoint/screenshot is ever actually requested that session. Merely setting this var for its "just an output path" purpose pays the per-frame comparison cost for the whole run. | unset (null) → facts-only automation runtime, no per-frame referee constructed | `RuntimeOptions.AutomationArtifactDirectory` → `FrameRootComposition.cs:351,543-627`, `WorldLifecycleAutomationController.cs`, `RetailUiAutomationScriptRunner.cs:108` | -| `ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER` | `=1` | Forces the graphical host to use the persisted display resolution as the *initial* size of a **borderless** window at creation, so the OS window manager cannot clamp a decorated window to the desktop work area — needed for pixel-exact automated screenshot comparison. | Changes window chrome (borderless) at startup — a visible difference from an ordinary launch, not just an internal measurement knob. | `false` → normal decorated window | `RuntimeOptions.ExactAutomationFramebuffer` → `GameWindow.cs:852` (`CreateStartupWindowOptions`) | -| `ACDREAM_BAKE_PUBLISH_NONCE_V1` | `=<32-hex GUID "N" format>` | Launcher-to-bake-child authorization token: when present and valid, the bake child takes a cross-process publish file lock + writes an authorization file before atomic publication (serializes with launcher recovery) | If present but fails `IsValidNonce` (not a 32-char Guid "N"), throws `InvalidOperationException` and aborts the bake. When absent, bake runs unguarded (standalone mode). Never set this manually outside the launcher's own child-process spawn. | unset (standalone unguarded bake) | `BakePublicationGuardPaths.cs:12`, read by `BakePublicationGuard.AcquireIfRequested` (`AcDream.Bake/BakePublicationGuard.cs:18`); set by `BakeProcessRunner.cs:150/162` | -| `ACDREAM_NET_DROP_DIR` | `="out"`/`"in"`/anything-else (incl. unset) → `Both` (case-insensitive) | Selects which direction(s) — outbound, inbound, or both — the deterministic loss-injection decorator drops | Only takes effect when `ACDREAM_NET_DROP_PCT>0` (decorator is structurally absent otherwise). Drives real datagram loss on the live connection — the injection point for `tools/run-connected-loss-gate.ps1`. Never set during a normal/measurement run. | `Both` | `NetDiagnostics.NetDropDir` (`NetDiagnostics.cs:88-90,98-104`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`Transport/LossyTransportDecorator.cs:21-22`), also read at `WorldSession.cs:901-907` (comment only) | -| `ACDREAM_NET_DROP_PCT` | `=` (out-of-range or unparsable → 0) | Percent chance (post-handshake-arming, per droppable datagram) that the deterministic `LossyTransportDecorator` drops a packet in the configured direction(s) | **Fault injection.** `>0` wraps the real socket transport in a packet-dropping decorator for the whole session — genuinely breaks/delays delivery to exercise N1-N4 reliable-transport recovery. At 0 the decorator is never constructed (zero structural cost). Must be 0/unset for any normal run or non-loss-gate measurement. | `0` (off, decorator absent) | `NetDiagnostics.NetDropPercent` (`NetDiagnostics.cs:60-69,92-96`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`:21`), wired at `WorldSession.cs:901-907` | -| `ACDREAM_NET_DROP_SEED` | `=` (unparsable → `1`) | PRNG seed for the loss decorator (outbound seeded with `seed`, inbound with `~seed`) — same seed reproduces an identical drop pattern | Only matters when `ACDREAM_NET_DROP_PCT>0`; makes fault injection deterministic/reproducible for the connected loss gate | `1` | `NetDiagnostics.NetDropSeed` (`NetDiagnostics.cs:75-82`), consumed by `LossyTransportDecorator` (`:21-22`) | -| `ACDREAM_OPEN_CHARGEN` | `=1` | Campaign CC slice CC4 interim env/test-only seam: opens the character-creation screen (`gmCharGenMainUI`) automatically once Runtime's chargen view goes active, bypassing the real retail Create-Character-button transition. Fires once per mount (`_openOnStartConsumed` latch). | Own doc comment explicitly calls this "interim env/test-only" — Campaign CC (closed 2026-08-16, user-accepted) later wired the real Create button with its roster<55-slot ghost gate, so this flag is now a bypass of that gate for automation/testing rather than the only way in. | `false` | `RuntimeOptions.OpenCharacterCreationOnStart` → `CharacterCreationUiController.cs:21,524-530` | -| `ACDREAM_UI_PROBE_DUMP` | `=1` | Enables the retail-UI automation probe's diagnostic dump path and feeds `RetailUiProbeBindings`/`RetailUiAutomationScriptRunner`. Also part of `RuntimeOptions.UiProbeEnabled` (`UiProbeDump \ | \ | UiProbeScript is set`). | `RuntimeOptions.UiProbeDump` → `LivePresentationComposition.cs:1465-1495`, `InteractionRetainedUiComposition.cs:1092-1100` | -| `ACDREAM_UI_PROBE_SCRIPT` | `=` | Path to a script file the `RetailUiAutomationScriptRunner` executes against the retained UI (pointer/semantic-input command playback) for scripted UI regression testing. | Also flips `RuntimeOptions.UiProbeEnabled` true even without `ACDREAM_UI_PROBE_DUMP=1`. | `null` | `RuntimeOptions.UiProbeScript` → `InteractionRetainedUiComposition.cs:1094` | -| `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=` (case-insensitive property name, e.g. `MultiDrawIndirect`) | Test knob (Slice V5): clears one named required Vulkan feature from the capability record to synthetically fail the gate, so the `NotSupportedException` → exit-code-4 → report path can be exercised on hardware that actually supports everything. | Deliberately breaks Vulkan startup when set to a matched feature name — this is a "make it fail on purpose" gate-testing flag, never appropriate for a normal or measurement run. | `null` → real capabilities used unmodified | `RuntimeOptions.VulkanForcedUnsupportedFeature` → `VulkanCapabilityRecord.Without` (`VulkanCapabilityRecord.cs:113-119`), consumed at `VulkanGraphicsContext.cs:339` | -| `ACDREAM_VULKAN_PROBE` | `=1` | Runs the standalone Vulkan capability-probe/bring-up harness (opens its own window, runs the capability gate, presents synthetic V6c/V6d verification scenes, captures one screenshot) **instead of** the real client composition host, then exits. | This flag ALONE gates entry (`GameWindow.cs:828`); the former `ACDREAM_RENDER_BACKEND=vulkan` co-requisite died with the OpenGL backend (its class doc was corrected 2026-08-24). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe` → `GameWindow.cs:828` → `VulkanBringUpHost` | -| `ACDREAM_VULKAN_PROBE_FRAMES` | `=` (non-negative) | Bounds the bring-up probe harness to N presented frames so it can run unattended in CI, instead of presenting until a human closes the window. | The frame budget never cuts a pending screenshot capture short — the loop stays open until the screenshot has been attempted even past the budget, so an unattended run's whole product (a PNG) is guaranteed. Zero (unset/unparseable/explicit `0`) keeps the interactive wait-for-close behavior. | `0` → interactive (wait for window close) | `RuntimeOptions.VulkanCapabilityProbeFrames` → `VulkanBringUpHost.cs:141-249` | -| `ACDREAM_DUMP_MOVE_TRUTH` | `=1` | Emits one `move-truth OUT` line per outbound movement record (MoveToState / AutonomousPosition): local resolved position vs the wire position/cell, ground contact, velocity (`MovementTruthDiagnosticController`). | **Automation apparatus, NOT a spent probe** — the canonical nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on ≥2 of these lines per destination as its proof that production input produced outbound movement traffic; deleting it fails the soak at every stop (#437, deleted-and-restored 2026-08-24). Print volume follows the outbound send cadence. | off | `RuntimeOptions.DumpMoveTruth` → `GameWindow.cs` → `MovementTruthDiagnosticController` | - -## Permanent diagnostics - -| Flag | Value | What it does | Side effects | Default | Read by | -|---|---|---|---|---|---| -| `ACDREAM_CAPTURE_PLAYER_QUANTA` | `=` (any non-whitespace path) | Opt-in JSON-Lines trace of every admitted player physics quantum (position/orientation/velocity/contact-plane snapshots at each stage boundary of `CPhysicsObj::UpdateObjectInternal`) | Appends+flushes one JSON line per physics quantum to the file (real file I/O on the physics tick when enabled); disabled path costs one static string null/empty check, no allocation. Read once into a mutable static property (settable via `ResetForTest`) rather than a typed options object. | unset (disabled, zero-alloc) | `PlayerPhysicsQuantumCapture` static class (`AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs:22`) | -| `ACDREAM_DUMP_MOTION` | `=1` | prints `UM`/`[UM_STALE]`/`[MOTIONDONE]`/`VU.land`/raw-hex wire dump lines tracing inbound `UpdateMotion` handling, remote ground-contact edges, and motion-done callbacks (bug-a/#32 stuck-cast subthread is temporary; core trace is long-lived) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` and `UpdateMotion.cs` fire on EVERY inbound motion/UM event (not cached) — `Environment.GetEnvironmentVariable` call per packet even when off; `UpdateMotion.cs`'s branch additionally builds a `StringBuilder` hex dump when on. Rule-5 violation (raw reads outside a diagnostics-owner class) at 5+ call sites | off | THREE independent readers: `PhysicsDiagnostics.DumpMotionEnabled` (owner, appears unconsumed — see Notes), `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached once at startup, consumed by `LiveEntityAnimationPresenter`), and raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (4 sites) + `Core.Net/Messages/UpdateMotion.cs:163` + `Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:630` | -| `ACDREAM_DUMP_PLAYSCRIPT` | `="1"` (ordinal) | Traces PhysicsScript playback: missing/empty script resolution, malformed `StartTime` entries, and other `[pes]`-prefixed hook-dispatch events | print-only (`Console.WriteLine`) at all 4 use sites (`:85-86,136,300,328`) | unset (off) | `PhysicsScriptRunner.DiagEnabled` (`PhysicsScriptRunner.cs:61-62`) — per-instance settable property seeded from the env var, not a shared static diagnostics-owner class | -| `ACDREAM_DUMP_SURFACES` | `="1"` (ordinal) | One-shot (per session) surface-format histogram dump for the atlas-opportunity audit — fires once after `_dumpFrameCounter>=600` OnRender ticks AND `_uploadMetadata.Count>=100` uploaded textures; writes to the host diagnostics directory | Doc comment claims "Zero cost when off" but `_uploadMetadata[name]=(w,h,fmt)` (`TextureCache.cs:1042`) is written **unconditionally on every texture upload regardless of the flag** — real (small) always-on dictionary-write cost. `TickSurfaceHistogramDumpIfEnabled` also re-reads `Environment.GetEnvironmentVariable` every OnRender frame (not cached) until the one-shot fires. Dump-write failures are caught and logged to stderr, not fatal. | unset (off) | `TextureCache` (`TextureCache.cs:102-113` fields, gate at `TextureCache.cs:802-812`, dump at `TextureCache.cs:814-829`), Phase N.6 slice 1 | -| `ACDREAM_FRAME_PROF` | `=1` | master toggle for the frame profiler: CPU frame time, GPU time samples, per-stage CPU attribution, per-frame alloc/GC, `[frame-prof]` report every ~5 s (doc: "permanent apparatus ... do not strip with session probes") | when on, samples `GC.GetAllocatedBytesForCurrentThread()` and stage-scope timing every frame (cheap, by design); its own XML doc claims a GPU-query self-disable tied to `ACDREAM_WB_DIAG=1` that `FrameProfiler.cs` says no longer exists — see Notes #1; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.FrameProfEnabled` / `FrameProfiler` | -| `ACDREAM_PROBE_ENVCELL` | `=1` | emits one `[envcells]` line per indoor frame: `CellsRendered`/`TrianglesDrawn` + ourBldgs/otherBldgs/filter counts (phase a8 relic; its own render pass was removed but the probe was kept) | print-only; implicitly turned on whenever `ACDREAM_PROBE_VIS` is on (getter is `_probeEnvCellEnabled \ | \ | `RenderingDiagnostics.ProbeEnvCellEnabled` (backing field OR'd with `ProbeVisibilityEnabled`) | -| `ACDREAM_PROBE_INDOOR_ALL` | `=1` | master switch that reads as AND / writes as cascade across Walk, Lookup, Upload, Xform, Cull | print-only (every underlying probe is print-only); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.IndoorAll` (cascades to the 5 flags below) | -| `ACDREAM_PROBE_INDOOR_CULL` | `=1` (also set by `ACDREAM_PROBE_INDOOR_ALL=1`) | emits `[indoor-cull]` per culled cell entity with cull reason (visibleCellIds-miss / frustum / landblock) | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorCullEnabled` | -| `ACDREAM_PROBE_INDOOR_LOOKUP` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-lookup]` per visible cell entity/sec: render-data hit/miss, IsSetup, parts-hit/parts-miss tallies | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorLookupEnabled` | -| `ACDREAM_PROBE_INDOOR_UPLOAD` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-upload]` requested/completed lines per EnvCell id at `WbMeshAdapter`'s staged-drain time | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorUploadEnabled` | -| `ACDREAM_PROBE_INDOOR_WALK` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-walk]` per visible cell entity/sec: world position, parent cell, landblock/AABB-visible flags, "drew" flag | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorWalkEnabled` | -| `ACDREAM_PROBE_INDOOR_XFORM` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-xform]` per visible cell entity/sec: cell-geometry SetupPart's composed world-matrix translation | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorXformEnabled` | -| `ACDREAM_PROBE_LOGIN_FRAMES` | `="1"` | Per-completed-frame login/portal-wormhole presentation classification (`world`/`tunnel`/`black`/`void`); logs `[login-frames]` on each classification transition | print-only. "Not a user setting; not in RuntimeOptions; not persisted" (doc comment). | unset (off) | `RenderPresentationDiagnostics.ProbeLoginFrames` (`LoginPresentationFrameProbe.cs:28-29`), consumed by `LoginPresentationFrameProbe.Process` | -| `ACDREAM_PROBE_NET` | `="1"` | Emits `[net-out]` (per outbound reliable message), `[net-tick]` (1 Hz WorldSession.Tick summary incl. reliable-transport rates), `[net-final]` (cumulative stats at Dispose), and `[cmd-gate]` (generation-gated command rejections) | print-only. Doc comment: "the counters themselves increment unconditionally in `TransportStats`; only the string work is gated" — i.e. the underlying stats tracking has a small always-on cost independent of this flag, but this flag itself gates only string/console formatting. | unset (off) | `NetDiagnostics.ProbeNet` (`NetDiagnostics.cs:56-57`), issue #260 probe family | -| `ACDREAM_PROBE_RESOLVE` | `=1` | gates one structured `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call (in/target/out position+cell, ok-vs-partial, grounded/contact status, wall normal, walkable-polygon validity, responsible entity) (l.2a slice 1, general-purpose resolver probe) | print-only, ~30 Hz per moving entity while on | off | `PhysicsDiagnostics.ProbeResolveEnabled` | -| `ACDREAM_PROBE_REVEAL` | `="1"` | While a reveal destination's composite warmup is incomplete, emits one `[composite-warmup]` line/second: pending queue depth, scan state, upload-budget gate, first few unresolved GfxObj ids | print-only | unset (off) | `NetDiagnostics.ProbeReveal` (`NetDiagnostics.cs:115-116`), issue #260 | -| `ACDREAM_PROBE_REVEAL_TIMING` | `="1"` | Wall-clock attribution of each login/portal reveal hold: `[reveal-timing]` lines for `begin`/first-true readiness edges (render/composites/collision/gate/materialized), 1 Hz progress, and one `SUMMARY` line at viewport reveal; paired low-frequency `[reveal-resource]` snapshots report mesh staging/uploads/arena state, prepared-asset activity, composite warmup/uploads, managed memory, and tracked GPU residency | print-only; the probe object and render-resource sampler are not constructed when unset. When enabled, canonical resource owners are sampled only at begin, readiness edges, 1 Hz progress, and summary—not every frame. Use with `ACDREAM_FRAME_PROF=1` / `ACDREAM_FRAME_HISTORY` for per-frame CPU/GPU/alloc timing. | unset (off) | `StreamingDiagnostics.ProbeRevealTiming`, `RevealTimingProbe`, `RuntimeRenderFrameResourceDiagnosticsSource`, `PublicationTimingProbe` | -| `ACDREAM_PROBE_TUNNEL_FREEZE` | `=1` or `=N` | #419 RenderDoc apparatus: holds the teleport state in stable `Tunnel` after destination readiness and freezes the portal-space animation/roll at frame 72 (`=1`) or an explicit frame 2–120 (`=N`); emits one `[tunnel-freeze]` line with the actual frame and retail Setup/animation ids | **behavior-changing diagnostic:** placement, world viewport reveal, and LoginComplete are intentionally withheld until transition cancellation/process exit. For static visual inspection only; never use in a performance or lifecycle measurement. | unset (off) | `StreamingDiagnostics.TunnelFreezeFrame`; consumed by `LocalPlayerTeleportPresentation` and `PortalTunnelPresentation` | -| `ACDREAM_PROBE_SOUND_WIRE` | `="1"` | One line per inbound server Sound event (`0xF750`) and per wire-sound play decision, with the drop reason when nothing plays — used to determine whether missing interior soundscapes are server- or client-side | print-only, consumed at `AudioHookSink.cs:159` and `EntityEffectController.cs:123` | unset (off) | `AudioDiagnostics.ProbeWireSoundsEnabled` (`AudioDiagnostics.cs:20-21`) | -| `ACDREAM_PROBE_USEABILITY_FALLBACK` | `=1` | gates a per-call log of `IsUseableTarget` calls that take the null-useability fallback path (creature/door/lifestone passes) (measures a real ace-vs-retail data gap, not a bug investigation) | print-only; measures how often ACE ships entities without `_useability` set | off | `PhysicsDiagnostics.ProbeUseabilityFallbackEnabled` | -| `ACDREAM_PROBE_VIS` | `=1` | emits `[vis]` line on root-cell CHANGE: visible cell ids, OutsideView poly/plane counts, per-cell plane counts, scissor-fallback count (phase u.2d repurposed the flag; its DebugPanel mirror is unreachable — #434) | print-only; ALSO implicitly enables the separate `ACDREAM_PROBE_ENVCELL` probe (its getter ORs with this flag — see Notes #3); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.ProbeVisibilityEnabled` | -| `ACDREAM_REMOTE_VEL_DIAG` | `=1` | prints per-UM/per-tick remote-velocity and animation-cycle diagnostic lines; `Runtime/Physics/RemoteMotion.cs` carries diagnostic-only fields (`PrevServerPos`, `PrevServerPosTime`, `MaxRootMotionSpeedSinceLastUP`, `LastOmegaDiagLogTime`) unconditionally on every remote — small fixed per-instance memory regardless of the flag, not gated (long-lived remote-velocity/animation diagnostic, commit a.1) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` fire on every UM/tick even when off (rule-5 violation, `Environment.GetEnvironmentVariable` call per event, 6+ call sites) | off | THREE readers: `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached at startup, consumed by `LiveEntityAnimationPresenter` for `[SEQSTATE]`/`[CURRNODE]`/other part-diagnostic lines, throttled to 1/sec/entity) + raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (6+ sites: `[UM_RAW]`, `[FWD_WIRE]`, `[VEL_DIAG]`, `[UPCYCLE_SRC]`, `[UM_STALE]`) + `RemoteServerControlledVelocityCycle.cs:68` (`[UPCYCLE]`) | -| `ACDREAM_DUMP_CELLS` | `=` | one-shot JSON dump of any cached EnvCell whose id matches the list, to `ProbeDumpCellsPath` (issue #98 fixture capture) — Standing fixture-extraction tooling (A6.P3/#98 lineage) for the physics replay harness; roundtrip-tested. Not investigation-scoped. | file I/O once per matching cell id (no-op on repeat); fixture-generation tool, not a perf-neutral no-op when ids are listed | off/unset | `PhysicsDiagnostics.ProbeDumpCellIds` (`ParseHexIdList`) | -| `ACDREAM_DUMP_CELLS_DIR` | `=` | overrides the output directory for `ACDREAM_DUMP_CELLS` — Companion output-directory knob for ACDREAM_DUMP_CELLS. | print/file-path only; no effect unless `ACDREAM_DUMP_CELLS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpCellsPath` | -| `ACDREAM_DUMP_GFXOBJS` | `=` | one-shot JSON dump of any cached GfxObj's polygon table + BSP root metadata matching the list, to `ProbeDumpGfxObjsPath` (issue #98 fixture capture) — Standing fixture-extraction tooling (A6.P3/#98 lineage), pair of DUMP_CELLS. | file I/O once per matching id (no-op on repeat) | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjIds` (`ParseHexIdList`) | -| `ACDREAM_DUMP_GFXOBJS_DIR` | `=` | overrides the output directory for `ACDREAM_DUMP_GFXOBJS` — Companion output-directory knob for ACDREAM_DUMP_GFXOBJS. | print/file-path only; no effect unless `ACDREAM_DUMP_GFXOBJS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjsPath` | -| `ACDREAM_DUMP_SKY` | `=1` | Print-only: dumps decoded `SkyDesc` raw values on region load (`SkyDescLoader.cs`) and per-GfxObj `Surface.Type`/translucency flags on first upload (`SkyRenderer.cs`), plus gates a `TimeSync` console diagnostic in `GameWindow`. Built to resolve specific open questions about retail sky units and GfxObjReplace timing (2026-04-23 research), now answered but the dumps remain wired. — Generic sky-keyframe isolation dump (introduced with the phase-1 tint revert); a tool, not a bug probe. | Three independent reads of the SAME env var, only one of which (`RuntimeOptions.DumpSky`) goes through the typed options object; the other two are raw scattered reads (see Notes). `SkyRenderer.cs:582`'s raw read is in the App layer and has no architectural excuse for bypassing `RuntimeOptions` — `_options.DumpSky` was already available to that composition. `print-only` in all three sites. | off/unset | `RuntimeOptions.DumpSky` (typed) → `GameWindow.cs:704` (`TimeSyncDiagnostic`); **also** two independent raw `Environment.GetEnvironmentVariable` reads at `SkyDescLoader.cs:392` (Core) and `SkyRenderer.cs:582` (App) | -| `ACDREAM_DUMP_STEEP_ROOF` | `=1` | gates `[steep-roof] KILL-VELOCITY-APPLIED` in `PhysicsEngine.ResolveWithTransition` when retail's `kill_velocity` zeroes body velocity on steep-slope impact, plus per-frame plane-normal traces in `TransitionTypes`/`PlayerMovementController` — KEEP: observes LIVE divergence-register row AD-56 (the plumb-fall freeze on steep-but-walkable polys, restored 2026-08-07). The only runtime lens on that active divergence; delete only with the AD-56 row itself. | print-only | off/unset | `PhysicsDiagnostics.DumpSteepRoofEnabled` | -| `ACDREAM_HIDE_PART` | `=` | Hides one mesh part by index on entities with ≥10 parts (humanoids) — a debugging aid for equipment/clothing part-visibility issues. — Generic model-part isolation tool (issue #37 lineage but general-purpose since); a tool, not a bug probe. | Real (visible) behavior change, not print-only, but scoped to a single diagnostic index and off by default. | off/unset | `RuntimeOptions.HidePartIndex` → `LivePresentationComposition.cs:608` → `LiveEntityAnimationPresenter.cs:21,38,243` | -| `ACDREAM_PROBE_CELL` | `=1` | gates one `[cell-transit]` line per `PlayerMovementController.CellId` change (old→new cell, position, reason tag) — Standing cell-transit tracer (L.2a slice 1), pair of the permanent ACDREAM_PROBE_RESOLVE; recurs in every membership investigation. | print-only; low volume (only on actual cell crossings) | off/unset | `PhysicsDiagnostics.ProbeCellEnabled` | - -## Temporary probes - -Each row names the issue that owns it. **A temporary probe is deleted in -the same commit as its investigation's fix** — if you find one here whose -issue is closed, the strip was missed; delete both. - -> **Probe debt, measured 2026-08-24:** 64 temporary probes existed, citing 21 -> distinct issues with 14 already closed. [#435](ISSUES.md) part 1 stripped -> the 17 rows whose investigation had ended without the strip — see the -> Retired section below for their removal record — leaving 47. Part 2 -> traced each of the (then-)14 unattributed rows to its introducing commit -> and stripped the 7 that belonged to closed investigations -> (`ACDREAM_A8_DUMP_PV`/Phase A8, `ACDREAM_DUMP_CLOTHING`/#37, -> `ACDREAM_DUMP_EDGE_SLIDE`/#32, `ACDREAM_DUMP_LIVE_SPAWNS`/Phase A8, -> `ACDREAM_DUMP_STEPUP`/L.2.3d-f, `ACDREAM_DUMP_VENDOR`/the vendor -> campaign, `ACDREAM_DUMP_VITALS`/#5). An eighth, -> `ACDREAM_DUMP_MOVE_TRUTH`, was deleted and then RESTORED the same day: -> it turned out to be automation apparatus, not a probe — the canonical -> nine-stop soak hard-gates on its output (see its row under Automation; -> #437 is the record). The rest of the attributed rows were reclassified -> into Permanent diagnostics as standing tools rather than investigation -> probes, leaving **31 temporary probes, every one attributed to an owning -> issue or campaign**. Each still costs a branch on its hot path even when -> unset, and a handful re-read the environment per frame rather than -> caching (see their side-effects column). - -| Flag | Owning investigation | Value | What it does | Side effects | Read by | -|---|---|---|---|---|---| -| `ACDREAM_CLIP_DEBUG` | #176 | `=1` | forces the EnvCell SHELL pass to map every instance to clip slot 0 (no-clip) instead of its cell's portal-slice region | ALTERS RENDERED OUTPUT: shells draw whole/unclipped instead of trimmed — a visual isolation mode, not a log-only probe; no DebugPanel mirror | `RenderingDiagnostics.ClipDebugNoShellTrim` | -| `ACDREAM_DUMP_APPEARANCE` | #5 | `="1"` | Logs every `0xF625` ObjDescEvent + `0xF7DB` UpdateObject with body length, target guid, hex preview — used to debug remote-player appearance asymmetry | print-only (`Console.WriteLine`) | `WorldSession` static field `DumpAppearanceEnabled` (`WorldSession.cs:792-793`), raw scattered read, issue #5 diagnostic | -| `ACDREAM_DUMP_OPCODES` | #5 | `="1"` | Logs first occurrence of each genuinely-unhandled inbound opcode (deduped by opcode) | print-only. Must stay the LAST else-if in the dispatch chain per comment (else it would intercept handled opcodes) — currently correct. | `WorldSession` static field `DumpOpcodesEnabled` (`WorldSession.cs:788-789`, consumed `WorldSession.cs:2391-2398`), issue #5 diagnostic. Also mirrored (display-only, non-functional) via `DebugPanel.cs:241`/`DebugVM.cs:227`. | -| `ACDREAM_DUMP_SCENERY_Z` | #48 | `=1` | Per-spawn Z-placement diagnostic for procedural scenery (trees/bushes/rocks), added for issue #48 (the "trees-in-sky" bug). | **NOT print-only** — this is a real behavior fork, not just added logging. `LandblockBuildFactory.cs:167-178`: when the flag is on, the streaming worker calls a **separate, duplicate scenery-building method** (`BuildSceneryEntitiesForStreaming`, a full parallel reimplementation of GfxObj/Setup mesh resolution + placement inline in this file) instead of production's `LandblockPhysicsContentBuilder.HydrateProceduralScenery`. Any visual/measurement run taken with this flag set is exercising a different scenery-placement code path than production, which can drift from it silently. | `RuntimeOptions.DumpSceneryZ` → `SessionPlayerComposition.cs:280` → `LandblockBuildFactory.cs:23,42,168,335` | -| `ACDREAM_DUMP_TRANSIT_FAIL` | #345 | `=1` | buffers per-tick `[transit-fail-insert]`/`[transit-fail-stepup]`/`[transit-fail-walk]`/`[transit-fail-adjust]` trace lines into a `[ThreadStatic]` list and flushes them to console ONLY when a tick requested nonzero XY movement but delivered zero (self-selecting "stuck tick" predicate) | print-only, zero allocation when off (flag checked before touching any buffer per its own doc); buffer/list allocation only on ticks that are already stuck | `PhysicsDiagnostics.DumpTransitFailEnabled` | -| `ACDREAM_LIGHT_DEBUG` | #176 | `=` (`int.TryParse`; unset/invalid → 0) | shader isolation mode uploaded as `uLightDebug` by `EnvCellRenderer` + `WbDrawDispatcher`: 0=off, 1=ambient-only vertex lighting, 2=kill dynamic point lights, 3=raw vLit visualization (texture ignored) | ALTERS RENDERED OUTPUT directly every draw pass (changes fragment-shader lighting/texturing) — not a log probe; no DebugPanel mirror | `RenderingDiagnostics.LightDebugMode` | -| `ACDREAM_PROBE_BUILDING` | l.2d slice 1 | `=1` | gates the multi-line `[resolve-bldg]` BSP-shadow-hit trace in `TransitionTypes.FindObjCollisions`, one-time `[entity-source]` registration logs in `GameWindow`, `[door-cycle]` UM dispatch trail, and a one-shot `[setstate-hex]` wire dump of the first `SetState` (0xF74B) packet in `WorldSession` | print-only; also un-gates the `PhysicsDiagnostics.LastBspHitPoly` diagnostic side-channel (a static field write in `BSPQuery`/`FlatBspQuery`, read back by the `[resolve-bldg]` line) — no gameplay effect, but an extra static-field write per BSP hit while on; heavy output (one multi-line entry per BSP hit per physics tick) | `PhysicsDiagnostics.ProbeBuildingEnabled` | -| `ACDREAM_PROBE_CELLSET` | a6.p5 | `=1` | gates `PhysicsDiagnostics.LogCellSetBuild`, one `[cellset-build]` line per `BuildCellSetAndPickContaining` call (seed cell, sphere XY, candidate list) from `CellTransit.cs:1468` | print-only; builds a `StringBuilder` of the candidate id list only when the flag is on | `PhysicsDiagnostics.ProbeCellSetEnabled` | -| `ACDREAM_PROBE_CELL_CACHE` | indoor walking phase d | `=1` | gates one `[cell-cache]` line per EnvCell first-cached in `PhysicsDataCache.CacheCellStruct` (poly counts, BSP root structure) | print-only; fires at most once per EnvCell (cache is no-op after first population); no DebugPanel mirror | `PhysicsDiagnostics.ProbeCellCacheEnabled` | -| `ACDREAM_PROBE_CHILD_CELL` | c4 route 7 | `=1` | gates one `[child-cell]` line per Runtime committed-child canonical-cell write in `RuntimeLiveEntitySessionController`, `RuntimeEntityObjectLifetime`, `RuntimeEntityDirectory` (parent/child guid, old/new cell, cause tag) | print-only | `PhysicsDiagnostics.ProbeChildCellEnabled` | -| `ACDREAM_PROBE_CLIPROUTE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[clip-route]` / `[clip-route-disp]` / `[clip-route-scis]` lines: outside-slice clip routing, region-SSBO bytes, terrain-UBO head, actual GL/RHI scissor state | print-only | `RenderingDiagnostics.ProbeClipRouteEnabled` | -| `ACDREAM_PROBE_CONTACT_PLANE` | spike-only, 2026-05-20 | `=1` | gates one `[cp-write]` line per write to `CollisionInfo.ContactPlane*`/`LastKnownContactPlane*` fields (field, old→new, caller method via stack walk, source line); only logs on actual value changes | print-only, but performs a stack walk to identify the caller method when firing — real CPU cost per write while on (not just a string format); suppresses no-op writes to bound volume | `PhysicsDiagnostics.ProbeContactPlaneEnabled` | -| `ACDREAM_PROBE_ENT` | #138 | `="1"` | Traces the persistent player entity across teleport streaming churn: presence in the render draw-set flat view vs. survival of the dynamics cull, to distinguish "missing from draw set" vs "present but culled" | print-only, "Observation-only — emits no behavior change" (doc comment). `LogPlayerDynOnChange` dedupes by transition to avoid per-frame spam. Marked STRIP-once-root-caused (like the dense-town FPS apparatus). | `EntityVanishProbe.Enabled` (`EntityVanishProbe.cs:23-24`), issue #138-B | -| `ACDREAM_PROBE_FLAP` | "throwaway apparatus — strip once the flap mechanism is confirmed" | `=1` | EVERY FRAME (unthrottled, not change-gated) while the camera root is indoor: `[flap]` from `PortalVisibilityBuilder.Build` (portal side-test/traverse/cull/projection) + paired `[flap-cam]` from `PhysicsCameraCollisionProbe`/`[flap-sweep]` (FindCameraCell resolution, eye positions) | print-only, but unthrottled per-frame `StringBuilder` allocation + `Console.WriteLine` on multiple call sites while indoor — heavy log volume/allocation under sustained indoor play; does not alter rendered output | `RenderingDiagnostics.ProbeFlapEnabled` | -| `ACDREAM_PROBE_GLSTATE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[gl-state]` line: depth/blend/cull/scissor/viewport/draw-FBO/color-mask/`glGetError` snapshot | print-only per its docstring; the actual state-snapshot/comparison call site lives outside `RenderingDiagnostics.cs` and was outside this pass's cited read sites | `RenderingDiagnostics.ProbeGlStateEnabled` | -| `ACDREAM_PROBE_INDOOR_BSP` | indoor walking phase 1 / cellar-lip wedge | `=1` | gates `[indoor-bsp]` (per `BSPQuery.FindCollisions` indoor call), `[neg-poly]` (near-miss polygon detail in `BSPQuery`), and `[stepdown-decide]` (step-down accept/reject inputs in `TransitionTypes`) trace lines | print-only; also un-gates the `LastBspHitPoly` diagnostic side-channel write (same as `ACDREAM_PROBE_BUILDING`) | `PhysicsDiagnostics.ProbeIndoorBspEnabled` | -| `ACDREAM_PROBE_INDOOR_LIGHT` | #176/#177 discriminator, a7.l1 | `=1` | rate-limited (1 Hz) `[indoor-light]` line from `LightManager.BuildPointLightSnapshot`: point-light pool set composition (pool/cellLess/registered/capped/byCell histogram) | print-only, explicitly "inert unless set" per the call-site comment (LightManager.cs:368-370); no DebugPanel mirror | `RenderingDiagnostics.ProbeIndoorLightEnabled` | -| `ACDREAM_PROBE_JUMP` | campaign ch round 2 | `=1` | gates the `[jump]` line in `PlayerMovementController.ReportJumpRefusal`, printed UNCONDITIONALLY (even when `OnInterfaceText` is null) to distinguish "branch never fired" from "branch fired, callback dropped it" | print-only; `Headless/Policies/HeadlessBotPolicy.cs`'s `JumpProbeHeadlessBotPolicy` doc comment references this flag as a companion but does not itself read it — it is a headless bot behavior meant to be run alongside `ACDREAM_PROBE_JUMP=1`, not a second consumer | `PhysicsDiagnostics.ProbeJumpEnabled` | -| `ACDREAM_PROBE_LOCAL_TELEPORT` | c4 route 3 d-t8 | `=1` | gates one `[local-tp]` line per local-player portal-arrival attempt (committed AND refused) from `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController.LogPortalArrivalAttempt` — the single Runtime chokepoint both graphical and headless hosts share | print-only; dual-host parity evidence (same line shape from both hosts) | `PhysicsDiagnostics.ProbeLocalTeleportEnabled` | -| `ACDREAM_PROBE_PARK` | issue #309 | `=1` | gates `[park]`/`[park-restore]` lines when a `RuntimeSetPositionState` placement parks or a cancelled park's withdrawal is rolled back | print-only, low volume (parks are rare); in a MULTI-session headless host, `HeadlessStaticStateAudit.ValidateProcessIsolation` THROWS `HeadlessConfigurationException` at startup if this (or any other process-global `Probe*`/`Dump*` boolean, `CollisionShadowSampleEvery`, or `PhysicsResolveCapture`) is enabled — refusal is waived only when `sessionCount==1` (logs loudly and proceeds instead) | `PhysicsDiagnostics.ProbeParkEnabled` | -| `ACDREAM_PROBE_PLACEMENT_FAIL` | issue #98 | `=1` | gates one `[place-fail]` line per Path-1 (Placement/Ethereal) `Collided` return in `BSPQuery.FindCollisions`, plus one per `Transition.DoStepDown` placement-insert rejection | print-only; low volume (fires only on actual rejection) | `PhysicsDiagnostics.ProbePlacementFailEnabled` | -| `ACDREAM_PROBE_POLY_DUMP` | a6.p3 slice 4, issue #98 | `=1` | gates one `[poly-dump]` line (full polygon geometry: cell, poly index, sides, plane, all vertices) per `AdjustSphereToPlane` push-back call | print-only; HEAVY output (one full-geometry dump per push-back call) — doc explicitly says "use briefly, then turn off" | `PhysicsDiagnostics.ProbePolyDumpEnabled` | -| `ACDREAM_PROBE_PORTAL_CHURN` | "throwaway apparatus — strip once the bound ships" | `=1` | one `[portal-churn]` summary per `PortalVisibilityBuilder.Build` call: per-cell pop/re-pop counts, re-enqueue totals, reciprocal-clip pre→post region growth | print-only | `RenderingDiagnostics.ProbePortalChurnEnabled` | -| `ACDREAM_PROBE_PUSH_BACK` | phase a6.p1 | `=1` | gates `[push-back]` (`BSPQuery.AdjustSphereToPlane`), `[push-back-disp]` (`BSPQuery.FindCollisions` 6-path dispatcher), `[push-back-cell]` (`Transition.CheckOtherCells` multi-cell BSP) lines | print-only; the `DebugVM.cs:380` "runtime mirror" is dead code — `DebugVM`/`DebugPanel` (`AcDream.UI.Abstractions/Panels/Debug/`) are never instantiated anywhere in `src/` (the ImGui frontend they required was removed at Campaign V slice V11); only the startup env var takes effect | `PhysicsDiagnostics.ProbePushBackEnabled` | -| `ACDREAM_PROBE_PVINPUT` | "throwaway apparatus — strip once the jitter source is pinned" | `=1` | one `[pv-input]` line/frame with 6-dp-precision `PortalVisibilityBuilder.Build` inputs (camera eye, player position, VP elements) + resulting flood-cell count; deliberately runs WITHOUT the heavier `[flap]` probe so the log stays diffable | print-only | `RenderingDiagnostics.ProbePvInputEnabled` | -| `ACDREAM_PROBE_REMOTE_SLIDE` | bug b, temporary — strip once two-client roof capture lands | `=1` OR `=` | gates `[remote-slide-up]`/`[remote-slide-vec]`/`[remote-slide-snap]`/`[remote-slide-enq]` lines across `LiveEntityNetworkUpdateController`, `InterpolationManager`, `RuntimeRemotePhysicsUpdater`, `RuntimeRemoteSteadyStatePosition` tracing two candidate remote-slide "blip" producers | print-only; `BeginRemoteSlideAttribution`/GUID-stamping calls are UNCONDITIONAL at several call sites (self-guard is internal), so a `[ThreadStatic]` field write happens on every remote tick regardless of the flag (cheap, non-allocating); a GUID allow-list narrows output to specific entities for a readable two-client capture | `PhysicsDiagnostics.ProbeRemoteSlideEnabled` + `ProbeRemoteSlideGuids` (raw string parsed via `ParseHexIdList` unless it's the literal `"1"`) | -| `ACDREAM_PROBE_REMOTE_TELEPORT` | c4 route 4b-3, temporary | `=1` | gates one `[remote-teleport]` line per routed remote teleport arm in `LiveEntityNetworkUpdateController.ApplyRemoteContactRouting` | print-only; a 2026-08-04 fix moved the enabled-check to the CALL SITE because the probe's internal self-guard did not prevent `teleportStatus.ToString()` from being evaluated/allocated on every teleport regardless of flag state — now properly guarded | `PhysicsDiagnostics.ProbeRemoteTeleportEnabled` | -| `ACDREAM_PROBE_SEAMDRAW` | #176, "throwaway apparatus" | `"1"`/`"true"`/blank → default #176 Facility Hub cell set (7 fixed hex ids); otherwise comma-separated hex cell-id list | change-deduped + 2 s-heartbeat `[seam-cell]`/`[seam-snap]`/`[seam-ent]`/`[seam-mask]` lines from `EnvCellRenderer.Render` and `WbDrawDispatcher` describing per-instance transforms and resolved light-set identities at target cells | print-only | `RenderingDiagnostics.ProbeSeamDrawEnabled` / `SeamDrawTargetCells` | -| `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` | -| `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` | -| `ACDREAM_PROBE_TELEPORT` | 2026-06-22, "removable diagnostic" | `=1` | gates `[tp-probe]` lines (`LogTeleport`) at AIM/ENQ/BUILD/APPLY/PLACED teleport-pipeline events across `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController`, with cross-thread monotonic timestamps | print-only | `PhysicsDiagnostics.ProbeTeleportEnabled` | -## Deprecated - -| Flag | Value | What it does | Side effects | Default | Read by | -|---|---|---|---|---|---| -| `ACDREAM_DEVTOOLS` | `=1` | logs a one-time "ImGui dev UI removed" notice; the only remaining functional consumer is `VulkanGraphicsContext.cs:184` (`enableOptionalExtensions: _options.DevTools`, selects optional Vulkan validation/debug-utils extensions) | real effect: turns on Vulkan validation/debug-utils extensions (can change perf and can surface validation-layer errors that don't occur when off) — NOT measurement-neutral for a perf gate; `GameWindow.DevToolsEnabled` is a hardcoded `false` const (dead — no ImGui dev UI exists to gate); `DevToolsInputCaptureSource(bool enabled)` explicitly discards its `enabled` ctor arg (`_ = enabled;`) — dead parameter, always reports `WantCaptureKeyboard=false` | off | `RuntimeOptions.DevTools` (typed, `Program.cs`/`RuntimeOptions.Parse`) | -| `ACDREAM_STREAM_RADIUS` | `=` (non-negative) | Legacy override for the streaming near/far radii, applied on top of the quality-preset's radii at session-start composition. | **CLAUDE.md explicitly documents this as "legacy" and warns against using it for measurement.** Confirmed in code (`SessionPlayerComposition.cs:256-259`): `nearRadius = legacyRadius; farRadius = Math.Max(legacyRadius, farRadius)` — it FORCES `NearRadius` and only ever RAISES (never lowers) `FarRadius`. It is set once at session-start composition and is **silently discarded** by any later Settings quality change: `RuntimeSettingsController.ApplyQuality` → `RuntimeSettingsTargets.ApplyQuality` → `StreamingController.ReconfigureRadii` recomputes radii straight from the quality preset with no knowledge of this override. A measurement/gate run taken with this set is measuring a different streaming window than production and than any run that later touches Settings. | `null` → quality-preset radii unmodified (production default: High preset, Near 4 / Far 12) | `RuntimeOptions.LegacyStreamRadius` → `SessionPlayerComposition.cs:254-268` | - ---- - - - -## Retired - -Flags that no longer exist, kept only so a stale script or an old research -document does not send someone hunting. Rows below this marker are exempt -from the "must still exist" check. - -| Flag | Retired | Replacement | -|---|---|---| -| `ACDREAM_RUN_SKILL` | Client-side run-skill override for local motion prediction. Skills now arrive from the server (`LiveMovementStatsApplier`); the hardcoded fallback is 200. | none — server-authoritative | -| `ACDREAM_JUMP_SKILL` | As above. The fallback is 300, not the 200 that CLAUDE.md advertised. | none — server-authoritative | -| `ACDREAM_RENDER_BACKEND` | Selected the GL-vs-Vulkan backend. Campaign V deleted the OpenGL backend; Vulkan is the only one. Two comments still named it as a live co-requisite until 2026-08-24. | none | -| `ACDREAM_ANIM_SPEED_SCALE` | Animation-speed multiplier from the pre-retail-sequencer era; died with the 1.248x factor. | none | -| `ACDREAM_A8_AUDIT` | Phase A8 EnvCell batch/cull audit dump. Its only caller never existed; `EnvCellRenderer.CollectCellAuditLines` was unreachable and was deleted 2026-08-24. | `ACDREAM_PROBE_ENVCELL` | -| `ACDREAM_AIRBORNE_DIAG` | #42 airborne-sweep `[SWEEP]`/`[SWEEP-OBJ]` XY-drift trace. Investigation closed; stripped 2026-08-24 (#435) along with its 16 siblings below. | none | -| `ACDREAM_DUMP_ENTITY` | #119 tower-staircase HYDRATE/DRAW/WALK-REJECT entity watchlist. Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_AUTOWALK` | Issue #63 server-initiated auto-walk trace. Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_LIGHT` | #133 A7 dungeon-lighting `[light]`/`[light-detail]` trace. Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_OUTSTAGE` | #131 outside-stage dynamics routing trace (also the `ACDREAM_DUMP_ENTITY` `[outstage-own]` watchlist consumer). Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_PHANTOM` | #113 phantom-shell/phantom-objs draw-mechanism trace. Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_REACH` | #334 broadphase candidate-disposition trace. Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_REMOTE_LANDING` | Bug A / issue #32 remote ground-contact landing trace. Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_SHELL` | #78 cell-shell opaque-pass render trace. Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_STEP_HEIGHTS` | Issue #338 step-up/step-down height provenance trace (including its unconditional once-per-process `AnnounceStepHeightProbeOnce` self-report). Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_STICKY` | R5-V3 issue #171 sticky-melee lifecycle/steer trace. Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_SUPPORT` | Issue #337 `[support]`/`[geom]` collision-vs-visual classifier trace. Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_TEXFLUSH` | #105 white-indoor-textures staged-upload trace. Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_VIEWER` | #119-residual viewer/flood capture (tower-ascent replay). Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_PROBE_WALK_MISS` | Issue #83 indoor walkable-plane miss trace. Investigation closed; stripped 2026-08-24 (#435). | none | -| `ACDREAM_WIRE_MESH` | Issue #337 F2 overlay upgrade to real physics-BSP polygon edges. Investigation closed; stripped 2026-08-24 (#435) — F2 reverted to its proxy-cylinder overlay. | none | -| `ACDREAM_WIRE_RADIUS` | Companion radius knob for `ACDREAM_WIRE_MESH`. Stripped alongside it 2026-08-24 (#435). | none | -| `ACDREAM_A8_DUMP_PV` | Phase A8.F portal-frame visual-gate triage dump (camera-cell portal census + EXIT-PROJ/EXIT-CLIP/EXIT trace in `PortalVisibilityBuilder.Build`). Phase A8 closed; stripped 2026-08-24 (#435 part 2). | none | -| `ACDREAM_DUMP_CLOTHING` | Issue #37 humanoid-coat clothing/part-swap trace. #37 closed 2026-05-11; stripped 2026-08-24 (#435 part 2). | none | -| `ACDREAM_DUMP_EDGE_SLIDE` | Issue #32 L.2c edge-slide/cliff-slide branch trace (five `edge-slide:` lines). #32 closed 2026-08-07; stripped 2026-08-24 (#435 part 2). | none | -| `ACDREAM_DUMP_LIVE_SPAWNS` | Phase A8 indoor-visibility batch live-spawn/DROP trace. Phase A8 closed; stripped 2026-08-24 (#435 part 2). | none | -| `ACDREAM_DUMP_STEPUP` | L.2.3d/e/f step-up `stepup: enter/SUCCESS/FAILED` trace. Investigation closed; stripped 2026-08-24 (#435 part 2) — its content is still covered by the separate `[transit-fail-stepup]` line under `ACDREAM_DUMP_TRANSIT_FAIL`. | `ACDREAM_DUMP_TRANSIT_FAIL` | -| `ACDREAM_DUMP_VENDOR` | `[vendor-diag]` trace (~25 call sites) for two vendor-approach/split-stack regressions. Vendor campaign closed 2026-08-08; stripped 2026-08-24 (#435 part 2) along with its owner class `VendorDiagnostics.cs`. | none | -| `ACDREAM_DUMP_VITALS` | Issue #5 `PrivateUpdateVital`/`PlayerDescription`/parse-failure trace across 4 sites. #5 closed 2026-04-25; stripped 2026-08-24 (#435 part 2). | none | diff --git a/docs/plans/2026-04-24-ui-framework.md b/docs/plans/2026-04-24-ui-framework.md index 0a82d8a8..a216406e 100644 --- a/docs/plans/2026-04-24-ui-framework.md +++ b/docs/plans/2026-04-24-ui-framework.md @@ -184,28 +184,17 @@ panel through `IPanelRenderer`. ## Plugin UI API -The shipped plugin-facing gameplay UI contract is the additive BCL-only -`AcDream.Plugin.Abstractions.IUiRegistry.AddPanel`: a plugin provides a stable -window id/title/icon descriptor, KSML-style markup, and a binding object; the -host builds it into the retained `UiRoot` tree. The API-v1 -`AddMarkupPanel` member remains source/binary compatible and is enriched into -the same first-class window route by the scoped host. `IPanel`/ -`IPanelRenderer` remains a historical first-party developer-panel contract and -is intentionally not referenced by `Plugin.Abstractions`. +The shipped plugin-facing gameplay UI contract is +`AcDream.Plugin.Abstractions.IUiRegistry.AddMarkupPanel`: a plugin provides +KSML-style markup and a binding object; the host builds it into the retained +`UiRoot` tree. `IPanel`/`IPanelRenderer` remains a first-party developer-panel +contract and is intentionally not referenced by `Plugin.Abstractions`. -This makes plugin gameplay panels presentation-assembly independent while -allowing them to share the retained input, window, and DAT-sprite runtime. -Registrations made before the graphical host exists are buffered. The host -assigns `plugin:{pluginId}:{windowId}`, registers every panel with the common -window manager, persists its geometry/visibility, and exposes it through the -shared plugin sidepanel. Hiding/minimizing a panel does not dispose or pause the -plugin. No-window hosts retain the plugin session but expose the no-op UI -capability. - -The retained markup vocabulary includes panels, nested groups, labels, -buttons, meters, tabs, lamp-style toggles, and scalar sliders. Controls bind to -BCL-visible properties/actions on the plugin binding object; visible controls -must correspond to real behavior, never placeholders that report success. +This makes plugin gameplay panels independent of ImGui while allowing them to +share the retained input, window, and DAT-sprite runtime. Registrations made +before the GL host exists are buffered. In builds where retail UI is disabled, +they remain registered but have no gameplay surface; the long-term release +configuration enables retained gameplay UI. The following was the original pre-D.2b proposal and remains historical context, not the shipped plugin contract: @@ -266,8 +255,7 @@ walk around / take damage / regen. ### Sprint 3 — Plugin API hardening (superseded shape) - Document the `IPanel` contract. -- The shipped route is `IUiRegistry.AddPanel` (with `AddMarkupPanel` as the - compatible legacy entry), not plugin-owned +- The shipped route is `IUiRegistry.AddMarkupPanel`, not plugin-owned `IPanel` implementations. - Confirm plugins can subscribe to game events and expose retained markup bindings without referencing App or ImGui assemblies. diff --git a/docs/plans/2026-08-10-options-panel-campaign.md b/docs/plans/2026-08-10-options-panel-campaign.md index 14458996..31591d10 100644 --- a/docs/plans/2026-08-10-options-panel-campaign.md +++ b/docs/plans/2026-08-10-options-panel-campaign.md @@ -94,9 +94,8 @@ DAT-authored values. register row for the ACE-sourced 2013-unverifiable mapping. - **D4 — Configure Keyboard is the campaign's rebind screen** (it is the ONLY rebind screen — D1). Port `gmKeyboardUI`'s shape and DAT ActionMap - data (lane D Option C). **Superseded 2026-08-26 by #446:** named retail - `.keymap` Load File / Save As/startup/shutdown persistence now ships; - `keybinds.json` remains only the host-command compatibility mirror. + data (lane D Option C) but persist to `keybinds.json`; retail `.keymap` + file interchange is a register-row deferral. - **D5 — dead-endpoint buttons short-circuit to their own retail failure strings.** Urgent Assistance / Report Abuse open a defunct `support.turbine.com` URL in retail; acdream skips the browser launch and @@ -361,8 +360,8 @@ modal capture; right-click erases; N-way cross-map conflicts + the non-user-bindable refusal per lane D §5; Save/Cancel; Reset-to-defaults reloads the DAT maps. Persistence: `keybinds.json` (D4). -**Register rows:** any retail column/behaviour consciously narrowed. The -former D4 `.keymap` deferral was retired by #446 on 2026-08-26. +**Register rows:** `.keymap` file interchange not implemented (D4); any +retail column/behaviour consciously narrowed. **Gate:** connected — rebind a movement key, conflict prompt on a taken chord, persistence across relaunch, reset restores retail defaults. @@ -395,8 +394,7 @@ chord, persistence across relaunch, reset restores retail defaults. `2026-08-09-chat-retail-window-shell.md` §6.3's register row. - A pre-world character-select flow (D6 adapts; its register row carries the future work). -- None for retail `.keymap` file read/write; #446 implemented it on - 2026-08-26 and retired AP-202. +- Retail `.keymap` file read/write (D4 register row). - The `0x21000017` docked `gmPanelUI` host variant — acdream ships the floating host only (register row in OP3 if the review deems it a divergence; retail exposes both). diff --git a/docs/plans/2026-08-24-character-advancement-campaign.md b/docs/plans/2026-08-24-character-advancement-campaign.md deleted file mode 100644 index d9e5e040..00000000 --- a/docs/plans/2026-08-24-character-advancement-campaign.md +++ /dev/null @@ -1,126 +0,0 @@ -# Campaign CA — character advancement retail parity (#431 + the raise/train/specialize family) - -**Status:** ACTIVE 2026-08-24. Owner-directed scope; #431 promoted here. -**Milestone:** M4 — Live in the world. -**Issue anchors:** #431 (promoted), #430 tooltips (SEQUENCED AFTER, not in -this campaign — needs the #409 client-wide tooltip system). - -## Owner's report (2026-08-24, verbatim scope) - -The raise flow "works from the GUI and everything" — the guess is the -refresh side "is not wired correctly." Required behavior: - -1. Raising an attribute must update everything derived from it, in real - time: raising Endurance/Self/etc. must move the **vitals bar** maxima - (health/stamina/mana); raising a **vital directly** (secondary - attributes are XP-raisable too) must update the bar the same way. -2. **Skills must update in real time** both when their underlying - attributes raise and when the skill itself is raised with XP. -3. **Run speed must increase when Quickness is raised** — Run is - attribute-fed. -4. **Skills with no underlying attribute (e.g. Salvaging) need their own - handling** — no formula contribution, raise-only progression. -5. **Unknown/untested territory:** raising skills with XP, and - **specializing** skills; also the retail **respec flow** (quests / - item turn-ins that drop learned skills so a character can re-spec / - re-specialize). None of this has been exercised against ACE. - -## Current-state survey (2026-08-24, verified in source) - -- **Outbound: complete.** `CharacterActions` builds all four actions — - `RaiseVital 0x0044`, `RaiseAttribute 0x0045`, `RaiseSkill 0x0046`, - `TrainSkill 0x0047` — and `CharacterSheetProvider` wires the panel's - buttons to them through the Runtime command seam. This is why the GUI - "works": ACE accepts and applies the raises. -- **Inbound: the hole.** The ONLY private stat-update messages parsed - anywhere are the vitals pair `PrivateUpdateVital (0x02E7)` / - `PrivateUpdateVitalCurrent (0x02E9)`. The attribute and skill update - family ACE sends back after a raise is UNHANDLED — no parser, no - routing, nothing reaches `LocalPlayerState` (whose own doc says - attributes refresh "only at PlayerDescription / future - `PrivateUpdateAttribute`"). Post-raise, the client's attribute/skill - model is stale until the next full PlayerDescription (i.e. next login). -- **Consequences observed by the owner (#431):** derived skills don't - move when an attribute raises; run speed doesn't change with - Quickness. Both follow directly from the missing inbound family — the - recompute never triggers because the trigger never arrives. -- **Run-rate seam already exists:** - `PlayerMovementController.ApplyServerRunRate` (the #431 filing's own - pointer) — the wire echo path updates live run rate; what's missing is - driving it (and the formula-side skill totals) from stat updates. -- **Character state owner:** `RuntimeCharacterState` (J4.3) owns the - spellbook/local-player graph; new stat state routes through it, not - through a parallel store. - -## Oracle targets (CA1 — DO FIRST, no guessing) - -Per the mandatory workflow (grep named-retail → cross-reference ≥2 refs → -pseudocode → port → conformance): - -1. **The inbound message family.** Pin exact opcodes + layouts from ACE - (`GameMessagePrivateUpdateAttribute`, `...Attribute2ndLevel` (vitals), - `...Skill`, `...SkillLevel`, `...SkillAC` as ACE names them; their - sequence-number semantics) cross-checked against Chorizite.ACProtocol - and holtburger. Our `PrivateUpdateVital.cs` already cites ACE's - `GameMessagePrivateUpdateAttribute2ndLevel` naming — extend the same - treatment to the whole family. Also pin what ACE sends for - TrainSkill/specialize responses and for skill-credit changes. -2. **Retail's recompute chain.** In named-retail: how the client applies - an attribute update — which cached values recompute - (`CACQualities::InqSkill` / skill formula with attribute divisors from - SkillTable, max-vital formulas, run-rate refresh via the movement - system). The SkillTable formula fields we already load for chargen - (`ChargenSkillAdvancement`) are the same divisor data — verify the - in-world recompute uses identical math. -3. **Attribute-less skills.** SkillTable rows with no formula - (Salvaging & friends): confirm retail's display/derivation for them - (base = trained ranks + augmentation only). -4. **Training / specialization semantics.** Credits accounting, what the - 0x0047 response looks like, how specialization changes the formula - multiplier (specialized = ranks count differently), and what messages - carry it. -5. **Respec / untrain.** Identify the retail mechanism (quest/item-driven - skill refund) and what the CLIENT sees — expectation: server-driven - property/skill updates using the SAME inbound family, so no bespoke - client flow; verify rather than assume. Confirm ACE's implementation - surface for a test path. - -## Slices - -- **CA1 — oracle + research doc** (`docs/research/2026-08-24-advancement-wire-and-recompute.md`): - everything above, with decomp addresses and ACE file citations. Output: - the pinned message table + retail recompute pseudocode. -- **CA2 — inbound stat-update family.** Parsers for the - attribute/skill/(vital-level) private updates; routed as ordered deltas - into the J4 owners (`RuntimeCharacterState` / `LocalPlayerState`) - through the existing generation-gated seam. Conformance tests from ACE - byte layouts. -- **CA3 — derived recompute + real-time presentation.** One recompute - path (retail's formula) fed by CA2's deltas driving: character panel - skill rows, vitals bar maxima (attribute- and vital-raise both), - run-rate into `ApplyServerRunRate`'s seam, attribute-less skills - handled per oracle. Binding-seam tests (the #436 lesson: assert the - REAL composition binds the refresh, not just that VMs recompute). -- **CA4 — train/specialize/respec verification.** Live-vs-ACE for - TrainSkill + specialization (panel flow + credits), and the respec - path exercised as far as ACE supports; fixes as the oracle demands. -- **CA5 — connected gate script** (`docs/research/2026-08-24-campaign-ca-test-script.md`), - user-driven: raise Quickness → run speed visibly increases immediately; - raise Endurance/Self → vitals maxima move; direct vital raise; skill - raise; train; specialize; respec if ACE path exists. PASS = every - change visible without relog. - -**Sequenced after this campaign:** #430 skill/attribute tooltips (needs -the #409 client-wide tooltip surface; the CA1 oracle should still note -where retail sources its tooltip strings while it is in the -neighborhood). - -## Ledger - -| Slice | Status | Evidence | -|---|---|---| -| CA1 | COMPLETE 2026-08-24 | docs/research/2026-08-24-advancement-wire-and-recompute.md — six inbound messages pinned byte-for-byte with 3-source agreement; live-at-inquiry recompute verdict verified by hand in Ghidra; RetailSkillFormula already ports 0x00591960 exactly | -| CA2 | COMPLETE 2026-08-24 (`65430d4c`) | 0x02E3/0x02DD parsers + WorldSession events + router routing into LocalPlayerState; conformance tests incl. holtburger golden fixture; 0x02DF deliberately unparsed (no ACE producer) | -| CA3 | COMPLETE 2026-08-24 | Live formula recompute (SkillFormulaBonusResolver over RetailSkillFormula) on attribute writes + fresh-train derivation; movement re-applied down the PD seam (PushMovementSkillTotals — Quickness raise → run speed, no relog); vitals bar pull-model verified; router behavior + fresh-train tests | -| CA4 | COMPLETE 2026-08-24 | Optimistic ApplyLocalRaise layer DELETED; retail one-in-flight + ghost + server-authoritative flow ported per the pinned §5 pseudocode (AP-73 NARROWED — rejection-release semantics owed to CA5 live); train cost verified DAT-exact; specialize correctly has no panel send (gem + confirmation route, seams already present); provider contract tests rewritten | -| CA5 | FIRST DRIVE 2026-08-24 (partial) + fix round `bce17b3c` | §1 PASSED from the log (run total 40→433 live, each raise re-applied; zero exceptions). Drive harvested #440 (trained row stuck until next click — row refresh now driven by the authoritative record) and closed #430 (tooltips could never mount — rows lacked the popup locator). OWED at the next drive: §2 Endurance→stamina fan-out, §3.2 AP-73 ghost probe, §5/§6 re-check (train row moves immediately now), tooltip hover-dwell visual, and the three feel answers (run speed, advancement chat lines, ghost cycle). | diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md deleted file mode 100644 index 18204b83..00000000 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ /dev/null @@ -1,602 +0,0 @@ -# Campaign CT — Character-panel retail parity (header identity, Titles page, resize/scrollbar, row alignment) - -**Status:** IMPLEMENTATION COMPLETE 2026-08-25 (+ gate-fix CT-GF1 review-closed: retail ancestor+self clip `989f6652`/`025108a8`/`90a0da68`) — CT1-CT6 all review-closed (per-slice Opus dual-lens review + fix round). CT-GF1 (the CT7 gate's own first finding — the client-wide retained-UI ancestor clip) landed `989f6652` and its fix round is CODE-COMPLETE (see the CT-GF1 subsection below); CT7 connected gate script ready at `docs/research/2026-08-25-campaign-ct-test-script.md`, still awaiting the owner's drive. **CT7 GATE PASSED 2026-08-25** — two rounds: full pass, then one regression (plugin markup text under the CT-GF1 self-clip, fixed `752782d0`) re-gated PASS. Owner retail observation recorded: the Titles divider IS visible inside retail's window while scrolling — a retail quirk the clipped rendering reproduces. NOT pushed to gitea (owner directive). -**Execution model:** Fable plans and coordinates; Sonnet implements each -slice; Opus runs the dual-lens review (retail-faithful + architectural) -per slice, then a fix round. No pushes to gitea until the owner says so. -**Register:** [STALE-INTENT CORRECTION 2026-08-25, at Campaign AS's AS5 -re-review: this retirement never happened — CT3/CT4 NARROWED AP-109 and -Campaign AS AS5 later closed its rank-prefix residual; the row remains -ACTIVE-narrowed to CT4's FormatXp sliver.] Original intent: retire AP-109 -(inert Titles page) when CT3+CT4 land; every deviation a slice introduces -adds its row in the same commit. - -## Owner report (2026-08-24, screenshots on file) - -1. Attribute/skill row **icons misaligned** vs retail. -2. Retail keeps a **margin between the value column and the border** — - the gutter reserved for the list scrollbar that appears when the - window is resized shorter. We author no such margin and never show - the scrollbar on Attributes. -3. The character window is **resizable in Y down to an authored - minimum** in retail; ours is not. -4. Header identity block: retail shows the name, then - **" "** (e.g. "Female Aluvian War - Mage"), then **"Non-Player Killer" / "Player Killer" / - "Player Killer Lite"** in **pure white** — on Attributes AND Skills. - We show gender+heritage only, no PK line contract, color off. -5. **Level number color** slightly off vs retail. -6. **Titles tab is inert** (AP-109): retail lists all earned titles - (sorted), shows the current display title, and lets the player set - one ("Set as Display Title"); scrollbar with many titles; the - header identity line updates live when the display title changes. -7. **All windows share retail's authored minimum-size behavior** — - resize clamps to the authored constraints everywhere. - -## Retail recon (verified 2026-08-24, this session) - -### Titles page — `gmCharacterTitleUI` -- `PostInit @0x0049A610` binds: display-title text `0x1000052F`, - "Set as Display Title" button `0x10000535`, title ListBox - `0x10000532`. Registers notice handlers for the title-table / - add-title / set-display-title notices. -- Rows carry the title id in attribute `0x1000008E`; - `AddTitleToList @0x0049A840` resolves the display string via - `CharacterTitleTable::GetCharacterTitleFromID` (DAT title-string - table — CT2 locates the DID) and inserts SORTED - (`FindSortedInsertPosition @0x0049A760`). It writes the resolved title - text into row child `0x10000537` and stamps the row's id via - `SetAttribute_Enum(row, 0x1000008E, titleId)`, inserting the row via - `AddItemFromTemplateList(listBox, 0, insertPos)` — CT3 mirrors this - exact write shape when populating `0x10000532`. -- **CORRECTED (CT1 fix round 2026-08-24):** `UpdateButtons @0x0049A500` - — the display button is **GHOSTED (state 0xd) UNLESS a row is - SELECTED whose title id differs from the current display title; no - selection → Ghosted.** (Not "ghosts when selected == current" — that - phrasing had the no-selection case backwards.) Verbatim mechanism: - a no-match selection falls through to index `0xFFFFFFFF` → - `GetItem` returns null → `SetState(0xd)`. Selection change - (msg 4/0x43) re-runs it. -- Clicking `0x10000535` sends - `CM_Social::Event_SetDisplayCharacterTitle(titleId)` - (`ListenToElementMessage @0x0049A6D0`). -- `gmStatManagementUI::RecvNotice_SetDisplayCharacterTitle @0x004EFD50` - → the stat panel refreshes its header when the display title changes. - -### Wire (ACE cross-checked) -- Inbound `CharacterTitle` event `0x0029` (already in our - `GameEventType`): `u32 =1, u32 displayTitleId, u32 count, - count × u32 titleId` (`GameEventCharacterTitle.cs`). -- Inbound `UpdateTitle` event `0x002B`: `u32 titleId, - u32 setAsDisplay` (`GameEventUpdateTitle.cs`). -- Outbound `TitleSet` GameAction (`GameActionSetTitle.cs`): - `u32 titleId`. Retail sender: `CM_Social::Event_SetDisplayCharacterTitle`. - -### Header identity — `gmStatManagementUI::PostInit @0x004EFD90` -Binds name `0x10000231`, heritage line `0x10000232`, PK line -`0x10000233`, level `0x1000023B`, total XP `0x10000235`, XP-to-level -`0x10000238` + meter `0x10000236`, luminance pair `0x100005C5/C6`, list -box `0x1000023D`. The refresh (vtable slot, near -`UpdateExperience @0x004F0A70`) composes the heritage line WITH the -display title; the PK strings are exactly "Player Killer" / -"Player Killer Lite" / "Non-Player Killer" (IsPK / IsPKLite — -cross-anchor `CharExamineUI::SetAppraiseInfo @0x004B45F0`). CT5 reads -the composing function verbatim before writing a line of C#. - -### CT4 contract (read verbatim 2026-08-24, Fable) -`gmStatManagementUI::UpdateCharacterInfo @0x004F0770`: -- **Name line** (`0x10000231`): `ACCWeenieObject::GetObjectName(player, - NAME_SINGULAR)` through `AllegianceData::GetFullName @0x005B6950` - (read verbatim): when `AllegianceSystem::GetTitle(rank, heritage, - gender)` yields an allegiance rank title, the line is - ""; otherwise the plain name. - The owner's retail screenshot (plain "Dww") is the rankless case. -- **Heritage line** (`0x10000232`): - `AppraisalSystem::InqGenderHeritageDisplay(gender=Int 0x71, - heritage=Int 0xBC, 0)` ("Female Aluvian"); then, when - `CharacterTitleTable::GetCharacterTitleFromID(m_titleID)` resolves, - `AppendText(separator @data_794358)` + `AppendText(titleString)` → - "Female Aluvian War Mage". PE-read RECOVERED (2026-08-24): - the separator `@data_794358` is a single space `" "`; the - allegiance-rank separator `@data_794098` is likewise `" "`. -- **Level** (`0x1000023B`): `InqInt(0x19)` present → - `Formatted(@data_7a0184)` = `"%d"`; absent → literal - `@data_7b0f34` = `"???"` (both PE-read recovered 2026-08-24). -- **PK line** (`0x10000233`): `UpdatePKStatus @0x004F00A0` — three-way - `IsPK` / `IsPKLite` / neither → StringInfo from table enum - `0x10000001` (= StringTable `0x23000001`, same compute_str_hash - mechanism the chat labels use) with keys - `ID_StatManagement_Header_PKStatus_PK` / `_PKL` / `_NPK`. -- Related key family for the footer/meter (already-shipped surfaces — - audit only if CT1 finds drift): `ID_StatManagement_Footer_*`, - `ID_StatManagement_Header_XPToLevelMeterInfinity`. - -### Already in-tree -- Tab/page ids wired (`TabTitlesId 0x10000538`, `TitlesPageId - 0x10000539`); pages currently show retail-authored closed visuals. -- Header labels partially bound (`StatHeaderLine` + `PkStatus` seams - exist in `CharacterStatController.Bind` — content contract wrong). -- `GameEventType.CharacterTitle/UpdateTitle` enum entries exist and now - (CT2, landed) have a parser, a `RuntimeCharacterTitleState` owner, and - an outbound `TitleSet` builder — see CT2's paragraph below. -- The character window registers with `DatConstraintSource` — authored - min/max plumbing exists in `RetailWindowFrame`; Y-resize for this - window and the list-scrollbar contract do not. - -## Slices - -**CT1 — DAT ground truth + pins. REVIEW-CLOSED (2026-08-24): landed `ca4100e7`, Opus dual-lens review (1 doc-level blocker + 5 should-fix, all applied), fix round `e264d839`.** -Research: `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`; -9 InstalledDat pins in `CharacterPanelLiveDatTests`. Three corrections -now BINDING on later slices: -(a) the character root (`0x10000227`, Type-8 TabControl) authors NO -min/max constraints and `MountCharacter` wires no `DatConstraintSource` -— CT6 must first find retail's actual minimum mechanism in the decomp -(likely class behavior, not a DAT property); -(b) row templates are reachable ONLY via the targeted -`ImportInfos(dats, layoutId, elementId)` overload (`0x10000248` stat -row in `0x21000045`; `0x10000536` title row in `0x2100005E`) — the -plain import's #375 prototype-skip hides them; -(c) authored row geometry: icon 20x20 at X=0 (code: 16x16 at X=4), -name X=25 W=150, value X=175 W=100 right-justified, 7px gutter to the -282px row edge; row Highlight media is `0x06000F93` (code uses -`0x06001397` — flagged, CT5 verifies). -Title chain verified end-to-end: titleId → EnumMapper `0x22000041` -(canonical key, e.g. `ID_CharacterTitle_War_Mage`) → compute_str_hash → -StringTable `0x2300000E` → text (id 13 = "War Mage"). -Original scope: Live-DAT probe of layout -`0x2100002E`: attribute/skill row templates (icon x/y vs our hand-built -rows), the value-column right margin, header element fonts/colors -(level `0x1000023B` color — item 5), Titles-page elements -(`0x1000052F/32/35` geometry, row template, scrollbar), window -min/max constraints. Output: research doc + InstalledDat pins (the -tooltip/scrollbar-pin pattern). No production changes. - -**CT2 — Runtime title ownership + wire. REVIEW-CLOSED 2026-08-24: landed `bcfddc97`, Opus review (0 blockers, 4 should-fix), fix round `544f8cb2`.** Parsed -`0x0029 CharacterTitle` (retail's `CharacterTitleTable::UnPack -@0x005c6e90` — the leading ACE `1u`/retail-Pack-constant field is -discarded, matching retail's own read) and `0x002B UpdateTitle` -(`CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0`: title id + -setAsDisplay). New sibling owner `RuntimeCharacterTitleState` -(`RuntimeCharacterState.Titles`) holds the earned-title set + display -title id, clears at generation reset (`CaptureOwnership`/`IsConverged` -extended with `TitleCount`/`DisplayTitleIsDefault`), and fires -`TableReplaced`/`TitleAdded`/`DisplayTitleChanged`. Outbound -`TitleSet (0x002C)` ships behind `IRuntimeCharacterCommands.SetTitle` -on both hosts (`DirectGameRuntimeCommandAdapter` direct-send, -`CurrentGameRuntimeCommandAdapter` via the `LiveCommandBus`/ -`LiveSessionCommandRouter` queue) — verified against retail's own -`CM_Social::Event_SetDisplayCharacterTitle @0x006a5720`, which sends -the wire message and touches no local field; the display title updates -only from the server's own echo. No register row: this slice -introduces no retail deviation. App-layer `CharacterTitleResolver` -(`src/AcDream.App/UI/Layout/CharacterTitleResolver.cs`) ports -`GetCharacterTitleFromID`'s EnumMapper(`0x22000041`) → hash → -StringTable(`0x2300000E`) chain for CT3/CT4 to consume; Runtime stays -id-only. Conformance tests against ACE's writer shapes -(`tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs`), -Runtime owner tests (`RuntimeCharacterTitleStateTests.cs` + -`RuntimeCharacterStateTests.cs` integration), a wire-send command test -(`DirectGameRuntimeCommandAdapterTests.cs`), and an InstalledDat pin -(`CharacterTitleResolverLiveDatTests.cs`, ids 0/1/2/3/5/13/14) all pass. - -**CT2 fix round (Opus dual-lens review, 2026-08-24).** Four SHOULD-FIX -corrections landed. **F1 (the important one):** the NOTICE broadcast is -unconditional (retail's server-side `SendNotice_AddCharacterTitle` fires -regardless of prior membership), but the client-side table ADD is -DEDUPED — `gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990` -walks `mTitleList` and returns without effect when the id is already -present, only inserting + adding the row on a miss. -`RuntimeCharacterTitleState.ApplyUpdateTitle` (which models the CLIENT -receive side, not the server send side) now fires `TitleAdded` only on a -genuine new membership; the inverted pin is -`ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision`. -**F3:** the send-side `titleId == 0` rejection is REMOVED from both -command adapters — retail's own send path -(`Event_SetDisplayCharacterTitle @0x006a5720`) packs whatever id it is -handed, and ACE accepts id 0 (`CharacterTitle.Invalid` is a defined enum -value); retail's actual protection is the UI ghost-when-current gate -(CT3's job), not a send-side rejection. No register row: removing the -guard makes acdream MORE retail-exact, not less. The fix round also -closed four SHOULD-FIX-adjacent items: A2 (`ResetSession` now publishes -`TableReplaced` unconditionally and `DisplayTitleChanged` when the -display id was non-zero before the clear, matching the -`LocalPlayerState.Clear()` precedent), A3 (`RuntimeCharacterState -.CaptureOwnership` reads the new non-allocating `Titles.Count` instead of -`EarnedTitleIds.Count`), A4 (the whole mutation in `ReplaceTable`/ -`ApplyUpdateTitle` now happens under one `_gate` hold, with change flags -computed inside the lock and events raised after release), and A5 (every -revision bump is now gated on an actual state change — a no-op wire -resend produces zero revision edges; `TableReplaced` itself still fires -unconditionally per retail's own `Refresh()` dispatch). A1 -(`CharacterTitleResolverLiveDatTests` now honors `ACDREAM_DAT_DIR` -first), A6 (documented the `EmitResult` `primaryObjectId`-as-title-id -precedent inline), A7 (corrected the "third consumer" comment — CT1 §5 -already records `gmAttributeUI::PostInit`'s icon-DID lookup as that third -consumer; CT5 is where the shared `GetDIDByEnum` helper gets factored), -and A8 (`CharacterTitleResolver` now memoizes the final resolved string -per title id, the DAT-static equivalent of retail's lazy-hash cache on -the string buffer) round out the fix round. - -**CT3 anchors from the CT2 review** (carried forward for CT3 to consume, -not yet acted on): -1. CT3 must refresh the display-title TEXT from `TableReplaced` as well - as `DisplayTitleChanged` — retail's - `RecvNotice_UpdateCharacterTitleTable` unconditionally `Refresh()`es - on every `0x0029` arrival, not only when the display id differs. -2. ACE sends NO echo when re-setting the already-current title — the - Set-as-Display button must not wait for a confirmation that never - arrives; retail prevents the send in the first place via the UI - ghost-when-current gate. -3. Retail's fallback display text when a title id doesn't resolve is the - hardcoded literal `"Unknown"` (`Refresh @0x0049abc0`), not a - StringTable key — `CharacterTitleResolver.Resolve` returning `null` - is the correct signal for CT3 to substitute that literal. -4. The deduped client-side add contract (F1 above) — CT3's title-list - row rendering must not assume every `TitleAdded` firing corresponds - to a wire arrival; the reverse still holds (every genuine new row has - a `TitleAdded` firing). - -**CT3 — Titles page UI. REVIEW-CLOSED 2026-08-24: landed `03e073b7`, Opus review (1 blocker: selection-clear semantics; 2 should-fix), fix round `4cc9448b`; full hermetic suite re-verified green after a load-flake false alarm.** Bind the authored page through the standard -GUI classes (`UiTemplateListBox`/`UiScrollbar`/`UiButton` — zero -bespoke widgets): sorted rows via the ported title-table lookup, -selection, ghost-when-current logic (state 0xd contract), display-title -text, Set-as-Display round trip, scrollbar. Retires half of AP-109. - -**CT3 fix round (Opus dual-lens review, 2026-08-24).** BLOCKER: ported -`Refresh @0x0049abc0`'s unconditional `SetSelectedItem(nullptr, 1)` -(`@0x0049ac5a`) — selection now clears on BOTH `TableReplaced` and -`DisplayTitleChanged`, regardless of whether the previously-selected id -is still earned in the new table, but deliberately survives -`TitleAdded` (`RecvNotice_AddCharacterTitle @0x0049a990` splices one -row without ever touching `m_pSelectedItem` — a genuinely different -retail method from `Refresh`). SHOULD-FIX: `AddTitleToList @0x0049A840`'s -early-outs (`@0x0049a873`/`@0x0049a914`) ported — an id of 0, or an id -`CharacterTitleResolver.Resolve` fails to resolve, now produces NO row -at all (the `"Unknown"` fallback literal belongs only to the -display-title text, never a row — this was previously ported -backwards); rows use the row template's own authored `DefaultColor` -instead of a hardcoded white, and each row/display-text `UiText.Line[]` -is built once per text change and cached instead of reallocated every -draw call. Notes also applied: corrected two comments that falsely -claimed the Titles page authors its own copies of the raise buttons -(verified against the fixture — it does not; the hide loop that -comment guarded is a defensive no-op, kept only for the -contentPage-not-found fallback path), switched the row sort from -`List.Sort` to a stable `OrderBy`/`ThenBy` (ties broken by title id), -wrapped the title-resolver delegate in the same `DatLock` the -row-template resolver already takes (`RetailUiRuntime.MountCharacter`), -and set the list box's authored 24px row height so wheel/line scroll -lands row-aligned. - -**CT4 — Header identity block. REVIEW-CLOSED 2026-08-25: landed `ed652ed8`, Opus review (2 blockers: luminance strings recovered by the reviewer, verbatim-title append; PK re-sourced to PWD bits), fix round `e7e32409`.** Retail composition: name; " - "; PK status line — authored fonts/colors -(pure white per probe), live refresh on display-title change and PK -status, identical on Attributes AND Skills pages. Level color from the -authored element. Retires the rest of AP-109's UI half. - -**CT4 landing notes (2026-08-24).** Verified the existing `Label(...)` seam -already covers both Attributes/Skills page copies — `CharacterStatController` -binds the SAME physically-visible container (contentPage = the Attributes -page chain) for both tabs; the Skills-page duplicate header subtree is never -shown (pinned by `Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated`, -a test that PREDATES CT4 — corrected at the CT4 fix round below, since the -original wording here implied CT4 wrote it fresh; the pre-existing test did -not cover `PkStatusId` until the fix round extended it). -All four header identity elements (Name/Heritage/PkStatus/Level) switched -from hand-picked `Body`/`Gold` runtime colors to the widget's own authored -`DefaultColor` (`LabelAuthoredColor`), matching CT1's live-DAT pin exactly — -the former "runtime color, dat carries none" comment was false. PK status now -resolves through StringTable `0x23000001` by key with a bitwise IsPK/IsPKLite -test (the prior exact-equality switch silently dropped combined-flag -values); **live-DAT-verified authored strings**: `ID_StatManagement_Header_PKStatus_PK` -→ "Player Killer", `_PKL` → "Player Killer Lite", `_NPK` → "Non-Player Killer" -(pinned in `CharacterPanelLiveDatTests.PkStatusKeys_ResolveExpectedAuthoredStrings`). -Level shows `"%d"`-formatted `InqInt(0x19)` or the PE-recovered literal -`"???"` when absent (`CharacterSheet.Level` is now `int?`). The heritage -line's appended title now comes from CT2/CT3's `RuntimeCharacterTitleState.DisplayTitleId` -resolved through `CharacterTitleResolver`, refreshing live on both -`TableReplaced` and `DisplayTitleChanged` (`CharacterSheetProvider`'s -`ChangeBinding` now subscribes to both). **Name-line ruling:** ships the -PLAIN-NAME case only — retail's allegiance rank-title prefix -(`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle -@0x005b8dd0`) needs a ~200-string, 22-function heritage×gender table -(verbatim hardcoded literals in the decomp, not DAT-resolved — e.g. -`GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/"Baron"/"Reeve"/ -"Thane"/"Ealdor"/"Duke"/"Aetheling"/"King"/"High King") judged out of -reasonable size for this slice; `RuntimeAllegianceState` already carries the -local player's own rank, so only the string table is missing. **Luminance -(item 5):** the DATA (`CharacterSheet.AvailableLuminance`/`MaximumLuminance`, -PropertyInt64 6/7) already flows generically through both the -PlayerDescription snapshot parser and the live `0x02CF` private-update path -— no wiring gap existed — and the retail show/hide gate -(`Level >= 200 && MaximumLuminance != 0`, `UpdateExperience @0x004F0A70`) is -wired and toggles `Visible` on both `0x100005C5`/`0x100005C6`, but the -label's caption and the value's composed number format could not be -recovered this slice (retail's `SetText` source resolves through a -Binary-Ninja-mislabeled data pointer, not a StringTable key; a DAT -string-table sweep found no match) — content stays unbound rather than -guessed. AP-109 narrowed accordingly (register row updated in the same -commit, not deleted — the two open items above remain). Tests: -`CharacterStatControllerTests` (heritage composition + live update, name -stays plain, level int/"???" with authored — not constant — color, PK line -shows resolved text in authored color, luminance visibility across five -level/luminance combinations) and `CharacterSheetProviderTests` (PK -key-by-status resolution including a combined-flag case, no-resolver ⇒ null, -Level null-vs-present, title resolution + live refresh on both title -events + unsubscribe-on-dispose, luminance Int64 read-through). - -**CT4 fix round (Opus dual-lens review, 2026-08-25).** 2 BLOCKERS: (1) the -luminance caption/value strings were RECOVERED by PE-byte-decoding the raw -retail binary (caption UTF-16 `"Luminance:"` at `@0x007c3dd4`, value narrow -`"%s / %s"` at `@0x007c3dcc`, both immediately following -`gmStatManagementUI::UpdatePKStatus`'s own vftable slots — the CT4 landing's -"could not be recovered" claim is FALSIFIED), so the pair now binds real -text (each number formatted through a new shared `FormatXp` helper — -`.ToString("N0", InvariantCulture)`, also now used by Total XP / XP-to-next- -level, replacing their un-invariant `.ToString("N0")`), and the hide path -switched from `Visible = false` to retail's own `UIElement_Text::ClearAllText` -mechanism (`@0x004f0e31`/`@0x004f0e3c` — empty the LinesProvider, leave -layout); (2) `CharacterIdentityText.StripLeadingArticle` is deleted — retail -`AppendText`s the resolved title VERBATIM (`@0x004f0990`), and 26 real ACE -`CharacterTitle` entries begin with "The", so every one of them was being -mangled; the dead `CharacterSheet.Race` fallback (no retail producer — the -`InqGenderHeritageDisplay` creature-type argument is a hardcoded literal `0` -at `@0x004f08db`) is deleted alongside it. 5 SHOULD-FIX: (3) the PK line now -classifies off the live `ClientObject.PublicWeenieBitfield` PWD bits -(`0x20`/`0x02000000`, `ACCWeenieObject::IsPK`/`IsPKLite` -`@0x0058c8b0`/`@0x0058c8a0`) instead of a bitwise test against raw -PropertyInt 134 — PropertyInt 134 carries ACE's own `PlayerKillerStatus` -enum bit layout, not the PWD layout, so the deleted `0x4 | 0x8` combined-flag -test case asserted a non-retail answer (PropertyInt 134 already drives the -correct PWD bits via `PlayerKillerStatusBitfield.Apply`, so this is a -re-source, not new wiring); (4) the register's AP-109 row restores CT3's -Titles-page narrowing paragraph (CT4's edit had compressed it to a bare -pointer phrase), corrects the rank-prefix item's source to PropertyInt -`0x1E` (`AllegianceRank`) read live off the qualities bundle — NOT -`RuntimeAllegianceState`, which is a DIFFERENT UI's (`SocialAllegiancePageController`) -own documented substitute — corrects the title-table size from the -originally-estimated 22 functions/~200 strings to the ACTUAL 17 -functions/~170 strings (`AllegianceSystem::GetTitle`'s dispatch switch read -directly: Gearknight/Tumerok author only a male function reused both ways, -Lugian only a female one, and Olthoi/OlthoiAcid have none), and downgrades -the row's evidence claim to "synthetic-layout binding tests plus a small -number of InstalledDat string/DID pins" rather than implying a -connected/live gate; (5) `CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors` -gains the luminance pair's own occurrence-count + font/color pins, matching -the pattern every other header id already uses. Also landed this round: an -InstalledDat pin (`GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain`) -proving `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` -match the live retail `EnumMapper` chain (master map category 1 → -`ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs -`0x2200000A`/`0x2200000B`) byte-exact, including the two entries (10 -"Penumbraen", 12 "Olthoi") the review had flagged as unverified guesses — -both are correct; the mechanism divergence (hardcoded table vs. live DAT -read) is filed as AP-235, pointing CT5 at the ALREADY-EXISTING generic -`RetailDataIdResolver.Resolve` helper (not a new "GetDIDByEnum helper" to -write) as the unification seam; `RetailAppraisalNameResolver.ResolveHeritage`'s -independent re-implementation of the same three overrides is noted there -too, for CT5. `CharacterSheetProvider.BuildSheet`'s level read switched from -a `GetInt` + `Ints.ContainsKey` double dictionary lookup to one -`TryGetValue`. - -**CT5 — Row alignment + value gutter. REVIEW-CLOSED 2026-08-25: landed `f532f28c`, Opus review (0 blockers, 4 should-fix incl. the authored Normal-state row band 0x06004CC2), fix round `0a37a28e`.** Reconcile our hand-built -attribute/skill rows with the authored row templates from CT1: icon -placement, name/value columns, the authored right margin that reserves -the scrollbar gutter. - -**CT6 — Resize + scrollbar contract. REVIEW-CLOSED 2026-08-25: landed `ec50455a`, Opus review (1 blocker: chrome-inclusive host constraints double-counted; S4 default-height ruling = retail's 372px), fix round `996cd736`.** CT6 research lead (Fable, -2026-08-24, follows CT1 correction (a)): `gmPanelUI::ResizeTo -@0x004BC6E0` is a bare tailcall to `UIElement::ResizeTo` — no clamp -there, so the minimum lives in the generic Resizebar drag path reading -element attributes, and the authoring to probe is the PANEL-HOST layout -`0x2100006E`'s slot elements (Character slot per `RetailPanelCatalog`, -sibling of social `0x1000018F`) — NOT the character layout root CT1 -probed. First CT6 step: probe the host slots' min/max + resize -authoring, then read `UIElement_Resizebar::StartMouseResizing -@0x0046B7E0`'s clamp source verbatim. -Original scope: Character window Y-resizable to -the authored minimum; the stat list shows its scrollbar when the -resized viewport overflows (the full-track/disabled behavior from the -2026-08-24 scrollbar work applies as-is); authored min/max constraint -enforcement verified as the STANDARD path for every registered window -(one shared mechanism in `RetailWindowFrame`/`RetailWindowManager`, -no per-window special cases). - -**CT6 landing notes (2026-08-25, implementation).** Live probe (dumped -+ deleted, pattern preserved by the new -`CharacterPanelLiveDatTests.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract` -pin) confirmed the research lead's hypothesis exactly: the shared -`gmPanelUI` host `0x100005FE` (LayoutDesc `0x2100006E`) authors -MinWidth=MaxWidth=310 (fixed — no horizontal Resizebar), MinHeight=372, -MaxHeight=1000; its bottom Resizebar (`0x10000660`) and top Dragbar -(`0x1000065C`) are DIRECT CHILDREN of the host, not the content parent -— matching `UIElement_Resizebar::StartMouseResizing @0x0046B7E0`'s -`GetParent()` call and `UIElement::MouseResizeElement @0x00461130`'s -`GetAttribute_Int(this, 0x3C..0x3F)` reads off that same parent. The -Character/Skills slot `0x1000018E` itself authors no constraints of its -own (confirmed, same pin). `RetailUiRuntime.MountCharacter` now imports -that host element and passes it as `DatConstraintSource`. **CORRECTED -(CT6 fix round, BLOCKER B1):** this paragraph originally claimed the -mounted outer frame clamped at "MinWidth=MaxWidth≈320, MinHeight≈382, -MaxHeight≈1010 after the NineSlice chrome inset" — that was WRONG. Host -`0x100005FE` is not a content element our wrapper adds chrome to; it IS -retail's own outer window frame (5px bevel + 300×362 content parent -`0x10000180` + 5px = 310×372), so its authored 0x3C..0x3F values are -already chrome-INCLUSIVE. Adding the NineSlice wrapper's own 10px inset -on top double-counted the bevel, clamping MinWidth to 320 while the -window's actual mounted outer width stayed 310 — silently below its own -minimum until `RetailWindowManager.ResizeTo` forcibly widened it despite -`ResizeX=false`. Fixed with a new -`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` opt-out -(chrome inset = 0 for constraint resolution when set — the value stays -DAT-sourced, only the redundant inset is skipped); `MountCharacter` sets -it true. The mounted outer clamps are now EXACTLY what the host authors: -width fixed **310**, height **372..1000** — no composed arithmetic. A -new mount-time invariant in `RetailWindowFrame.Mount` (throws if the -just-mounted outer extent falls outside its own just-computed clamp) -would have caught B1 at the very first test run; it is now permanent for -every window this path mounts. - -**S4 (2026-08-25, campaign-lead ruling — which number governs the -default mount size):** `0x2100002E`'s own root is authored 300×600 (the -"Size tension" the earlier ground-truth doc left unresolved — CT3's -Titles page alone is 300×575, plus the 25px tab bar). That 600 is a real -authored canvas, but it is the CONTENT's own design surface, not the -mounted default — retail scroll-clips it into the shared host's much -smaller 300×362 content parent (`0x10000180`). Pre-fix, `MountCharacter` -left `ContentHeight` unset, so it fell back to the raw 600px canvas, -producing a stale 610px mounted default (600 + 10px chrome inset) that -was never retail's actual opening size. **372 (the host's own outer -frame, 362 content + 10 chrome) is the number that governs the mount -default** — it is also exactly the host's own authored MinHeight, so -retail's Character/Skills window opens AT its resize floor and can only -be dragged taller, never shorter. `MountCharacter` now sets -`Options.ContentHeight = 362f` explicitly to realize this. The authored -page composition (header 112 + list 160 + divider + footer) IS the -362px design; at that default the 9 attribute/vital rows (180px content) -OVERFLOW the 160px list, so the stat list's scrollbar is active -immediately on open — retail-correct, not a regression (see S2 below for -what "active" actually looks like). Persistence still restores a -user-chosen size within the 372..1000 clamp on top of this default. Full -derivation + decomp anchors: -`docs/research/2026-08-24-campaign-ct-dat-ground-truth.md` §CT6. -`CharacterStatController.RebuildActiveList` now wraps BOTH the -Attributes and Skills tabs' rows in the same `UiScrollablePanel` -viewport (previously only Skills got one; Attributes rows were added -directly to the ListBox with no clipping/scrolling and the shared -scrollbar was force-hidden — the owner's item 2). The shared scrollbar -is now always BOUND (`.Model`/`.Visible = true`); no per-tab visibility -toggle is needed. **CORRECTED (CT6 fix round, S2):** this paragraph -originally claimed `UiScrollbar`'s own `IsPresentationVisible`/ -`IsModelDisabled` "draw the correct full-track 'disabled' thumb when -content fits (`HideWhenDisabled` defaults false)" — that had the -authored default BACKWARDS. `0x1000023E` (this scrollbar) and -`0x10000533` (the Titles list's own scrollbar) both author property -`0x79` (`HideWhenDisabled`) **TRUE**, fixture-verified (`BoolValue: true` -on both elements' property 121/0x79 in the committed fixture). A fitting -list HIDES the bar entirely; it does not leave a full-track disabled -thumb visible. The code was already correct — `.Visible = true` only -keeps the bar in the tree, `IsPresentationVisible` does the actual -show/hide — only this description was wrong; fixed here, in -`CharacterStatController.RebuildActiveList`'s own comment, in the CT7 -script, and in `CharacterStatControllerTests`' comment, plus a new -`IsPresentationVisible` assertion pair added to the resize test (hidden -once growing makes the content fit, visible+interactive while -overflowing). This surfaced and fixed a real, previously-unexercised `#372`/ -`#412`-class anchor-baseline bug: the viewport's `Left|Top|Bottom` -anchor was capturing its baseline margins lazily on its OWN first -`ApplyAnchor` call, which happens AFTER the ListBox has already grown -from its raw DAT height (160px) to its mounted height — measuring a -bogus non-zero margin that permanently capped the viewport short on -every later resize. Fixed with an eager -`viewport.CaptureCurrentAnchorBaseline()` call right after -`AddChild`, mirroring the identical fix already shipped in -`UiTemplateListBox.Viewport`'s own lazy getter. **CORRECTED (CT6 fix -round, S3):** `CharacterTitlesController.Bind` originally gained the -same defensive `if (listBox.LayoutPolicy is null) Anchors = -Left|Top|Bottom` fallback for the Titles ListBox (`0x10000532`) that -`CharacterStatController` already had for its own list. Both -`0x10000532` and the Titles page container `0x10000539` author -`HasOriginalParentSize=true` in the real DAT AND the committed fixture, -which makes `LayoutImporter`/`DatWidgetFactory` always assign a real -`LayoutPolicy` — the fallback branch was therefore UNREACHABLE, not a -harmless no-op "matching the established pattern for synthetic/test -layouts" as originally described. Deleted rather than left as dead code; -a new `CharacterPanelLiveDatTests` pin asserts `HasOriginalParentSize` -on both elements to guard the deletion against future DAT drift. -STANDARDIZATION AUDIT (no gaps found, no follow-up filed): `UiElement -.MinWidth/MinHeight/MaxWidth/MaxHeight`, set once at -`RetailWindowFrame.Mount` from `Options.DatConstraintSource`/explicit -overrides, are the ONLY clamp fields — read identically by the -interactive drag path (`UiRoot`'s resize handling), the programmatic -path (`RetailWindowManager.ResizeTo`, which both `RetailPanelUiController`'s -main-panel geometry sync and this slice's tests exercise), and the -persisted-geometry restore clamp (`RetailWindowLayoutPersistence.Apply`). -`RetailWindowFrame.Mount` remains the single production mount path (no -window bypasses it). New regression pin (**renamed, CT6 fix round N4**: -the original name `NineSlice_ChatShapedConstraints_ -ClampProgrammaticResizeAtAuthoredBounds` overclaimed — it exercises -NineSlice inset ARITHMETIC on a content-shaped source (490×100, -height-only synthetic constraints) and never actually pinned chat's real -DAT contract, since no width constraints were even set): -`RetailWindowFrameTests.NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize` -proves the same mechanism still clamps chat-shaped constraints after -Character was wired onto it. A new companion test, -`Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset`, mounts -with `Chrome=Imported` and chat's real 300/100/2000/2000 constraints -(matching production's actual `MountChat` wiring) and asserts no inset -applies — the true chat-contract pin the renamed test's name no longer -claims to be. Tests: `CharacterStatControllerTests -.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar` -(window-level: clamp at authored min/max, list shrink, scrollbar -overflow flip, footer stays bottom-docked, grow-back restore) and -`CharacterTitlesControllerTests.TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips` -(same contract for the Titles list) plus the pre-existing 126+22-test -suites, all updated where the new nested-viewport DOM shape required it -(`Descendants(list)` instead of `list.Children` — the shape Skills rows -already needed). No register row: **CORRECTED (CT6 fix round, N5)** — -before the B1 fix this sentence ("every number is either a live-probed -authored DAT value ... nothing inferred") was not actually true: the -mounted 320/382/1010 clamp WAS an inference (the host's chrome-inclusive -values plus a second, redundant chrome inset composed on top). After B1 -removes that composition, the mounted clamp is now literally the host's -own four probed values with zero arithmetic applied — the sentence holds -for real. No register row for the S4 content-height default either: 362 -is the same host content-parent width/height CT6 already probed and -cited (`0x10000180`, 300×362), not a new number. - -**CT7 — Connected gate.** Test script -(`docs/research/2026-08-25-campaign-ct-test-script.md`), owner drive: -titles round trip against ACE (earn/set/display), header lines vs -retail side-by-side, resize behavior, row alignment screenshots. - -### CT-GF1 — client-wide retained-UI ancestor clip (gate finding + fix round) - -Landed `989f6652`: ports retail's `UIRegion::DrawHere @0x0069FA30` -ancestor-clip intersection as `UiElement.ClipsChildren`'s new client-wide -default (true), fixing the CT7 gate's own first finding — the Titles page's -authored divider `0x10000530` escaping the Character window above its top -edge at the CT6-correct 372px mounted default. One opt-out -(`UiElement.ExpandsClipForPopup`, `UiMenu`'s inline-drawn popup) plus new -`UiAncestorClipTests` mechanism coverage. - -**Fix round** (Opus dual-lens review, 0 blockers / 7 SHOULD-FIX / 4 NOTE, all -applied): moved the ambient clip to wrap `OnDraw` + children + -`OnDrawAfterChildren` in one block — the literal `DrawHere` shape, clipping -an element's own `DrawSelf` too, not just its children (`UIElement_Text:: -DrawSelf @0x00467AA0`; `UIRegion::DrawSelf @0x0069F1A0`) — and deleted the -two now-redundant ad-hoc self-clips it superseded (`UiText.DrawText`, -`UiField.DrawMultiLine`); kept the one that clips to a genuinely smaller -authored inner rect (`UiButton.DrawBlockLabel`'s `LabelBox`/`ValueBox`). -Deleted `UiItemList`'s `ClipsChildren` override (inverted under the new -default). Pinned the escaped-popup input path end to end (`UiRoot.PopupHit` -routing, `WantsMouse`) with a new real-`UiRoot` test. Strengthened the -Titles-divider regression test's positive half (exact-rect assertion + -visible/hidden diff, not a bare Y-band check). Added a draw-capture -regression sweep across Character/Chat/Vendor/Options mounted through their -real controllers (`UiWindowDrawCaptureSweepTests`). `PushClipUnbounded` now -resets to the screen rect, not `null` — retail's own popup region is -screen-clipped, not truly unbounded (AD-113 amended). `UiRoot.ClipsChildren` -now explicitly overrides false (the root's own region IS the screen — a -safety net against a momentarily zero-sized root blanking the whole UI). -Added the empty-clip subtree cull (retail's `var_24` gate), scoped to the -main draw pass only — the popup's separate `DrawOverlays` traversal is -provably unaffected (new coverage: a menu inside a fully-clipped window -still draws its popup). - -**Owed:** the CT7 re-gate (script `docs/research/2026-08-25-campaign-ct-test- -script.md`) still needs the owner's connected drive — this fix round landed -on the automated side only. §5 of that script now also names the -collapsed-toolbar check and the four highest-overflow windows (combat/ -vitals bar, Options bottom-button row, map/house page, floaty chat) as -explicit eyeball items for that same re-gate. - -## Review protocol - -Per slice: Sonnet implements → Opus dual-lens review (lens 1 -retail-faithfulness vs the cited decomp anchors; lens 2 architecture — -GUI-class standardization, Runtime ownership boundaries, no -controller-side state) → fix round → full hermetic suite green. -Commits to the worktree branch as slices land; **no gitea push until -the owner directs it**. diff --git a/docs/plans/2026-08-25-assess-window-parity-campaign.md b/docs/plans/2026-08-25-assess-window-parity-campaign.md deleted file mode 100644 index 9ee79da7..00000000 --- a/docs/plans/2026-08-25-assess-window-parity-campaign.md +++ /dev/null @@ -1,157 +0,0 @@ -# Campaign AS — assess/examination window retail parity (player targets) - -**Status: CLOSED — CONNECTED GATE PASSED 2026-08-25 ("fixed! gate pass!").** -AS1–AS5 review-closed; the gate round harvested two findings, both resolved -in-round: the extras-list "black rectangle" is retail's own authored -scroll-less clipped listbox (no scrollbar authored on 0x10000335 — verified -against the live DAT; wheel-scroll and resize reveal rows; AS-GF1 -`65f6f584` ruled it not-a-code-defect), and the paperdoll's absence -narrowed to an intermittent FIRST-OPEN DELAY (#443, kept open) after the -probe round proved the render layer healthy — the render pipeline was -never broken by this campaign. Gate probes deleted at close per the -probe-dies rule (recoverable via `git show 65f6f584`). Branch NOT pushed — -the owner pushes on their word. - -Owner report (2026-08-25, side-by-side screenshots, acdream vs retail, both -assessing the player "Dww"): acdream's examination window on a PLAYER target -is missing retail's identity block and body/config sections. - -Missing vs retail: - -1. **Identity block** under the title bar: gender + heritage ("Female - Aluvian"), current display title ("War Mage"), PK status - ("Non-Player Killer") — none of it rendered by acdream. -2. **Per-bodypart armor levels**: retail shows three grouped rows — - "Head/Chest/Groin AL: x/y/z", "Bicep/Wrist/Hand AL: x/y/z", - "Thigh/Shin/Foot AL: x/y/z". Absent in acdream. -3. **Target-configurable extras**: retail only shows what the assessed - player configured to show (date of birth, age, number of deaths, chess - rank, fishing skill, damage/crit ratings, …). acdream shows a - "Dmg/CritDmg Rating:" line but not the rest of the family. -4. **Allegiance/faction lines**: allegiance name/patron/faction shown when - the target has them. Absent in acdream. - -Explicit owner rulings: - -- **The animated 3D paperdoll is an INTENTIONAL acdream deviation** - (register row AD-114, filed at AS2). Retail's examine preview clone is - NOT a static tinted preview — it is INDEPENDENTLY ANIMATED, just - decoupled from the live target: `BasicCreatureExamineUI::Init - @0x004AB9C0` clones the selected object via `CPhysicsObj::makeObject - @0x005144B0` (which runs `MorphToExistingObject` then - `play_script_internal(setup->default_script_id)`), sets the clone's - heading to 191.367905°, and `CreatureMode::Render @0x004529D0` runs - `update_position` on it every frame. acdream's deviation is that our - preview mirrors the target's LIVE motion instead of playing its own - private, decoupled cycle. Keep ours. (Retail's preview colors are also - buggy on the owner's reference setup, so porting the decoupled clone - would not even be a faithfulness win.) -- Retail comparison is the oracle for text composition; all strings come - from DAT StringTables per the decomp — **never hardcoded English - literals**. -- All UI work goes through the standard GUI classes (UiLabel / UiPanel / - UiScrollablePanel / retail chrome) — no bespoke widgets, no quick fixes. - -## Execution model (set by owner) - -- **Fable** plans and coordinates (this doc + slice contracts + synthesis). -- **Sonnet** implements each slice against a pinned contract. -- **Opus** runs the dual-lens review per slice — retail-faithfulness lens + - architecture lens — followed by a fix round; slice is REVIEW-CLOSED only - after the re-review accepts the fixes. -- Commit to the worktree branch (`claude/windmill-seam-and-solid-polys`) as - slices land, full hermetic suite green each time - (`Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure`). -- **Do NOT push to gitea until the owner says so.** -- Campaign stops when all slices are REVIEW-CLOSED and the connected-gate - script is written and waiting on the owner's drive. - -## Ground truth sources - -- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — the retail - examination window class, line composition, StringTable keys, 0x00C9 - client-side parse. -- `references/ACE/` — what our live server actually sends per flag, and the - target-option gating rules. -- `references/Chorizite.ACProtocol/` — field-order cross-check. -- Campaign CT sealed verdicts (`claude-memory/project_character_panel_campaign.md`) - — the heritage/title composition and PK bitfield rules already ported for - the character panel; REUSE, do not re-derive. -- Research synthesis doc (AS1 output): - `docs/research/2026-08-25-campaign-as-ground-truth.md`. - -## Slices (FINAL — re-cut at AS1: the 0x00C9 parse is already complete and -the appraisal profile is session-scoped UI presentation state flowing -through the established router seam, so the skeleton's wire and -runtime-owner slices are unnecessary; all work is App-side composition) - -**Oracle for every slice:** -`docs/research/2026-08-25-campaign-as-ground-truth.md` (AS1 synthesis) — -its §2 line-composition tables, §3 wire truth, §4 gap ledger G1–G10, and -§5 rulings R1–R8 are BINDING on implementers and reviewers. Deviating from -a ruling requires a plan-doc amendment, not an implementer judgment call. - -- **AS1 — ground truth synthesis (DONE, Fable).** Three research lenses - merged; gap ledger G1–G10; rulings R1–R8. -- **AS2 — header identity block (Sonnet).** Fix the element mis-mapping in - `AppraisalUiController.ApplyCreature(character: true)` per ground truth - §2a: `0x10000150` ← composed gender+heritage (reuse - `CharacterIdentityText`, including retail's heritage-id overrides and the - creature fallback when heritage==0); `0x10000151` ← current display title - via `CharacterTitleResolver` (Int 261, fallback String 5 Template); - `0x10000152` ← PK line from the assessed `ClientObject`'s PWD bits - (`PlayerKillerStatusBitfield`, ruling R7); `0x1000053A` ← String 47 - AllegianceName gated on Int 30 ≥ 1, DELETING the invented - "Assessment incomplete" literal. First-ever `AppraisalView.Character` - controller tests (G10). Register: file the animated-paperdoll AD row - (owner-ruled intentional deviation 2026-08-25) in this commit. -- **AS3 — armor-level rows + extras-list plumbing (Sonnet).** Plumb - `Parsed.ArmorLevels` into the extras composer (signature change from - bare `PropertyBundle`); emit the spacer + three grouped AL rows before - the rating rows per ground truth §2b rows 3–7, including the `*%d` - ≥9999 unenchantable rendering, retail's per-row rating gates - (307|313|314 · 308|315|316 · 350|351), spacer discipline per ruling R4, - and the `* = Unenchantable` legend per ruling R3 (unconditional, - gate-verified). Ordering pinned by tests. -- **AS4 — society/allegiance/fellowship + configurable extras (Sonnet).** - Ground truth §2b rows 1–2 and 8–14: the Society row with rank bands and - the local-vs-target faction color rule; the Monarch/Patron/Followers - cascade; Fellowship; Arrived in Dereth; Time in Dereth (locate or port - `ClientUISystem::DeltaTimeToString` — grep for an existing port first); - Chess Rank; Fishing Skill; Deaths ("Has never died" at ≤0); Titles - Earned. Register: narrow AP-110 ("exhaustive character detail regions" - clause retires) in this commit. -- **AS5 — allegiance rank-title table (Sonnet).** Port the 17-function - heritage×gender `AllegianceSystem::GetTitle @0x005B8DD0` table (census - per AP-109's corrected 2026-08-25 text: 11 heritages → 17 functions, - ~170 strings; Gearknight/Tumerok male-only reused, Lugian female-only - reused, Penumbraen aliases Shadowbound, Olthoi excluded by the unsigned - range check) + `AllegianceData::GetFullName @0x005B6950`; wire the - examination title bar (rank from `props.GetInt(0x1E)`, ruling R8) AND - the character panel's name line (closing AP-109's rank-prefix residual). - Register: NARROW AP-109 in this commit (CORRECTED at the AS5 review — - retiring would have deleted a live open item: CT4's FormatXp - `GetNumberFormatA` approximation sliver survives as the row's sole - remaining item, so the row stays active-narrowed). -- **AS6 — connected gate script (Fable).** User-driven script - `docs/research/2026-08-25-campaign-as-test-script.md`; two-client where - needed (allegiance/fellowship/PK lines, deception-failure rendering); - includes the R3 legend retail-side-by-side check and the owner's - configurable-extras toggle matrix. - -Slices AS2→AS3→AS4 are SERIAL (all touch `AppraisalUiController` / -`CreatureAppraisalRows` — coupled-file rule); AS5 may run after AS4's -review closes. Each slice: Sonnet implements → full hermetic suite green → -commit → Opus dual-lens review (retail-faithfulness + architecture) → fix -round → narrow re-review → REVIEW-CLOSED. - -## Ledger - -| Slice | State | Land / fix commits | Notes | -|---|---|---|---| -| AS1 | **DONE 2026-08-25** | (docs commit) | 3-agent research; ground-truth doc committed | -| AS2 | **REVIEW-CLOSED 2026-08-25** | `f8a22589` / `cc5290af` | port exact per dual-lens review; 6 findings (docs/test/refactor) fixed; +AD-114 (animated paperdoll), +AD-115 (title clear-vs-stale), PK bits promoted to `PublicWeenieFlags`; carried follow-up chip: 3 more Core PK-bit copies | -| AS3 | **REVIEW-CLOSED 2026-08-25** | `1616cd3d` (no fix round) | APPROVE first pass — trio/legend/monster-path exact by offset-level decomp verification; R3 flattening theory disproven at source; ratings adjudication: pre-AS3 code already retail-exact. 5 NITs: 12 (legend-order comment), 11 (stronger refresh test) fold into AS4; 14/15 done in the close commit; 13 (geometry-keyed test helper) noted | -| AS4 | **REVIEW-CLOSED 2026-08-25** | `4ade9b04` / `bf8f5b70` (docs-only fix) | port exact per dual-lens review (presence-gate adjudicated FOR the implementer at `InqInt @0x005B3830`; Time-in-Dereth = pre-existing `RetailDurationText @0x00565E10` port, correct reuse); fix round was oracle-doc corrections + records only, NO code change. True full-solution hermetic count 15,528 (the commit's 15,410 was a mis-report). **AS6 carry-note: the Society green/red colorIdx is MODEL-ONLY (ResolveColor no-op pending AP-110 FontInfo residual) — the gate script must NOT gate on row colors.** Pre-existing parallel-load flake surfaced (shadow-caster zero-alloc pin) — #442, unrelated to AS4 | -| AS5 | **REVIEW-CLOSED 2026-08-25** | `8f8c0c3a` / `9f3e3263` | the campaign's most rigorously verified slice: 170/170 title strings confirmed (164 mechanical diff, 6 PE byte-decoded from the PDB-paired binary), all 20 dispatch arms, all 17 bounds tests, both call sites re-derived; zero behavioral findings. Fix round = "retires AP-109" → "narrows" at 5 comment sites + the plan (the FormatXp sliver keeps the row active); re-review also flagged + this close fixed the last "retires" phrasing (`AppraisalUiController.cs`) and the CT plan's stale retirement intent | -| AS6 | **GATE PASSED 2026-08-25** | script `87e98395`; gate round `65f6f584` (AS-GF1) + probe-removal close commit | owner ran the gate live; two findings harvested and resolved in-round (extras clip = retail's authored scroll-less listbox, not a defect; paperdoll = #443 first-open delay, render layer proven healthy by probe); identity block, AL rows, allegiance/extras, and the rank-title title bar all owner-verified; probes stripped at close | diff --git a/docs/plans/2026-08-25-launcher-content-stabilization.md b/docs/plans/2026-08-25-launcher-content-stabilization.md deleted file mode 100644 index ada1a92a..00000000 --- a/docs/plans/2026-08-25-launcher-content-stabilization.md +++ /dev/null @@ -1,230 +0,0 @@ -# Launcher content stabilization - -**Date:** 2026-08-25 -**Status:** IMPLEMENTED -**Goal:** make prepared-content updates fast, explicit, and safe without -turning the launcher into a package manager. - -## User contract - -1. The launcher window appears before network access, full-file hashing, - baking, recovery, or any other potentially long operation. -2. Ordinary startup reads only small metadata: the install record, pak header, - file length/write time, and the verification sidecar when present. -3. No long content operation begins silently. The launcher first names the - reason, work kind, approximate disk requirement, and whether the existing - installed game remains usable. -4. A prepared-content change uses a small locally generated overlay whenever - the affected DAT IDs/landblocks are bounded. A full rebuild is an explicit, - rare fallback for format changes or extraction changes with unbounded - impact. -5. The launcher never starts a mixed client/content pair. Cancellation or a - bake/publication failure preserves the prior pair. Once approved content is - ready, an unavailable or failed matching-client update leaves Play disabled - and retains the verified content for a cheap retry. - -## What exists already - -- `acdream.pak` has a 64-byte header containing DAT iterations, format version, - and `BakeToolVersion` (the current content-recipe identity). -- `install.json` records the pak SHA-256, size, DAT path, and recipe identity. -- `install.verification.json` avoids the former 24-second startup hash when - size/write-time still match. Explicit **Verify files** remains the full-hash - path. -- `acdream-bake` already accepts `--ids` and `--landblocks`, and a filtered - bake produces an ordinary valid pak with only those typed keys. -- `IPreparedAssetSource` and `IPreparedCollisionSource` are the existing - renderer/physics seams; no consumer needs to know which mapped pak supplied a - key. -- Launcher and client are published together, and the launcher payload already - includes the matching bake executable. - -## Deliberately small model - -There are only four work kinds: - -| Kind | Launcher behavior | -|---|---| -| `None` | No content prompt. | -| `Overlay` | Build one cumulative overlay containing all keys changed since the base recipe. | -| `FullRebuild` | Explain the long rebuild and required free space before starting. | -| `Verify` | User-requested or exceptional recovery hash; always visible and cancellable. | - -The release-feed schema remains unchanged for the first implementation. Every -published build already updates the launcher before the client. The updated -launcher carries the matching content requirement and a small compiled -migration catalog. This avoids stranding strict schema-1 launchers on a feed -shape they cannot parse. A future independently versioned content feed can -replace the catalog without changing the runtime content model. - -`BakeToolVersion` is retained on disk for compatibility but is treated as a -**content recipe version**, not an executable build number. It changes only -when the produced prepared content changes. - -## On-disk content state - -The existing `install.json` remains the base-pak authority and is not extended; -older launchers reject unknown fields. New state lives in the optional sidecar -`DataDirectory/pak/content.current.json`: - -```json -{ - "schemaVersion": 1, - "baseSha256": "", - "effectiveRecipeVersion": 6, - "overlay": { - "path": "acdream-update-6.pak", - "sha256": "<64 lowercase hex>", - "size": 123, - "recipeVersion": 6 - } -} -``` - -Rules: - -- The sidecar is valid only when `baseSha256` binds it to the current base - record and every path is a safe canonical filename beneath the pak directory. -- At most one overlay is active. A later overlay is cumulative and atomically - replaces the prior sidecar; there is no unbounded lookup chain. -- The base and overlay must name the same installed DAT iterations and pak - format. The base may carry an older recipe; the overlay carries the effective - recipe. -- Missing overlay keys fall through to the base. A present-but-corrupt overlay - key is authoritative corruption and never falls through. -- Render and collision reads follow the same ordering and share the same two - memory mappings. -- An absent sidecar means the base pak is the complete active content set. -- `content.client-pending` is a separate, tiny crash-safe activation gate. A - content migration creates it before touching content and removes it only - after client compatibility is confirmed. It deliberately carries no package - graph; existence means “do not publish this content to Play yet.” - -## Migration catalog - -One compiled catalog entry describes each recipe transition: - -```text -target recipe -work kind -player-facing reason -affected DAT IDs and/or landblocks (overlay only) -``` - -To update a base from recipe 5 directly to recipe 7, the launcher asks the -catalog for the cumulative 5 -> 7 impact and emits one recipe-7 overlay. If any -step is `FullRebuild`, the combined migration is a full rebuild. A missing -catalog step fails closed with an explanatory error; it never guesses. - -The recent procedural night-sky change is `None` because it changed client -shader/code only. A future addition of bounded prepared sky keys can be -`Overlay`. A global mesh-extraction correction such as recipe 5's solid-face -change is `FullRebuild`. - -## Update transaction and UI - -The launcher keeps the existing one-question update surface. When the candidate -client needs newer content, pressing **Update** first opens the content-work -confirmation: - -> **World data update required** -> This release adds prepared sky assets. acdream will build a small update -> from your installed Asheron's Call files. The existing game stays installed -> until this finishes. -> Estimated work: overlay / approximately N files / M free space required. -> **Update now** · **Later** - -After confirmation: - -1. Validate the remembered DAT directory and free-space floor. -2. Build to a transaction-owned candidate path while the active content stays - untouched. -3. Validate pak header/TOC and compute the new artifact's SHA once. Never hash - the unchanged base as part of an overlay update. -4. Atomically publish the content sidecar. -5. Install/activate the compatible client. - -The newly prepared content is not published to the launch orchestrator until -the startup check confirms that the active client is compatible or the client -update succeeds. Choosing **Not now**, losing the network, or failing the -client download therefore cannot launch the old executable against the new -pak. The launcher keeps the verified content on disk and resumes at the much -smaller client-update step. - -For `FullRebuild`, the same transaction builds a candidate base beside the old -base, verifies it, then atomically swaps the base record/file. It never moves -the playable base out of place before the long build starts. - -Progress uses the existing strict Bake JSONL protocol and shows phase, -percentage, failures, and ETA. Cancellation returns to the launcher without -changing active content. - -## Startup ordering - -`App.OnFrameworkInitializationCompleted` must not synchronously wait on -`LoadExistingAsync` before constructing `MainWindow`. It constructs the shell -with an explicit `Checking` installation state, assigns/shows the window, then -starts content discovery on the UI dispatcher. Feed update checking begins only -after that cheap discovery completes, preventing two startup modals from -racing. - -If an exceptional recovery path really needs a full base hash, the shell is -already visible and says exactly what it is doing. Launch stays disabled until -the recovery check finishes, but the application never looks frozen. - -## Compatibility and rollback - -- A client session receives the resolved base path plus zero or one overlay - path. Old clients continue receiving only the base. -- The client validates the effective recipe before constructing world owners. -- The updater does not activate a client whose content requirement is - unsatisfied. -- Choosing **Later** leaves the old client/base pair active. -- Client rollback is allowed only when the selected client accepts the active - content set; otherwise the launcher explains the required content rollback - or rebuild instead of launching an incompatible pair. - -## Verification gates - -- Launcher window construction test proves no installer/hash task is awaited - before the main window is assigned. -- Quick-discovery tests cover missing sidecar, matching sidecar, missing cache, - changed length/time, recipe mismatch, and exceptional visible verification. -- Content-state tests cover path containment, base-digest binding, atomic - publication, cancellation, and crash residue. -- Composite-source tests cover overlay hit, base fallback, authoritative - overlay corruption, render/collision parity, stats, and balanced disposal. -- Session-config round trips cover base-only and base+overlay on App and - Headless. -- Update tests prove prepared content cannot become launchable before client - compatibility is confirmed; **Not now** and client-download failure remain - fail-closed, while bake/candidate failure preserves the prior pair. -- Release solution compilation and the affected Launcher, Content, App, and - Headless gates remain green. - -## Implementation checkpoint - -Implemented 2026-08-25: - -- The Avalonia window is assigned and opened before content discovery, client - recovery, feed access, or exceptional hashing begins. -- Ordinary current-install discovery uses metadata/header/cache checks; the - explicit verification command owns visible whole-pak hashing. -- Recipe migrations are compiled and cumulative. Bounded migrations build one - filtered overlay; unbounded/global migrations use the explicit candidate - full-rebuild path. The current recipe 4 -> 5 transition is correctly a full - rebuild because the solid-face extraction change is global. -- Base/overlay reads are unified for render and collision with overlay-first, - Missing-only fallback and authoritative corruption. -- Content activation is bound to client compatibility in memory and through - `content.client-pending`, so **Not now**, failed download, process crash, and - launcher restart cannot expose a mixed pair. - -Final Release gates: - -- `dotnet build AcDream.slnx -c Release`: 0 warnings, 0 errors. -- Launcher UI/ViewModels, excluding the documented manual desktop lane: 82/82. -- Launcher.Core Windows-compatible suite: 360/360. -- Hermetic Content suite: 130/130. -- Affected App layered/session composition: 35/35. -- Affected Headless configuration: 9/9. diff --git a/docs/plans/2026-08-26-mosstank-parity-campaign.md b/docs/plans/2026-08-26-mosstank-parity-campaign.md deleted file mode 100644 index c00c08d8..00000000 --- a/docs/plans/2026-08-26-mosstank-parity-campaign.md +++ /dev/null @@ -1,483 +0,0 @@ -# MossTank — VTank parity campaign - -Date: 2026-08-26 -Status: ACTIVE — MT1 USER-PASSED; MTUI–MT9 functional/API scope complete; connected shelf/shell, accessibility, reconnect, bidirectional peer-expression and two-member fellowship gates passed; #452 root fixed and 30-minute dual-client activation soak passed; hostile/collision gates remain - -Research baseline: -`docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md` - -## Product definition - -MossTank will provide the complete automation capability associated with -Virindi Tank, implemented as a first-class acdream plugin over a stable, -BCL-only plugin API. UtilityBelt's typed expression dialect is the scripting -baseline. Native file formats may differ; behavior and extensibility may not. -The finished surface is a visually verbatim VTank reproduction: every VTank -tab and function is present and every enabled control invokes real behavior. - -## Non-negotiable boundaries - -- modern code, behavior matched to documented VTank/retail behavior; -- one Runtime owner for every state/action; plugin API is a borrowed projection; -- policy engines remain in MossTank, not App or Runtime; -- plugin UI only through `IUiRegistry`; -- every API addition works in graphical and no-window hosts, with explicit - unavailable behavior until the host can genuinely supply it; -- no fake success and no silent expression-function omission. - -## Slice ledger - -### MT0 — research and campaign design - -- [x] Reconcile existing VTank audit with current Runtime ownership. -- [x] Audit current UtilityBelt grammar and all 260 expression declarations. -- [x] Define complete capability ledger and staged architecture. - -### MT1 — autocombat foundation (current stop gate) - -- [x] Add target/combat views and attempt commands to the plugin API. -- [x] Project canonical hostile, selection, mode, power and spell state. -- [x] Implement target lock and range/angle/hybrid selection. -- [x] Implement melee/missile charge-release and direct offensive magic. -- [x] Deliver the polished combat dashboard and settings. -- [x] Focused, App/Runtime and complete solution gates. -- [x] Connected user gate: user confirmed autocombat works in the plugin. - -MT1 intentionally does not pretend later features exist. It is “autocombat -ported,” not “all combat policy ported.” - -### MTUI — generic plugin-window and VTank shell foundation - -- [x] Add manifest-authenticated, stable plugin panel descriptors without - breaking API-v1 hosts/plugins. -- [x] Register plugin panels with the common retained window manager so - geometry and visibility persist. -- [x] Add the shared right-edge plugin shelf and window minimize/restore; - hidden panels leave the plugin session and automation running. -- [x] Add reusable nested groups, tabs, lamp toggles and sliders to retained - plugin markup. -- [x] Replace MossTank's dashboard/settings pair with one VTank-shaped shell - using the exact Options, Profiles, Vitals, Monsters, Items, Consumables, - Buffs, Route, Meta tab order. -- [x] Bind all currently enabled controls to real MT1/buff behavior and leave - unimplemented tabs visibly disabled. -- [x] Enable the Items/Consumables pages against durable, manifest-scoped - exact-name profiles; selection and Add/Add-no-buffs/Add-All-Peas controls - all mutate the policy consumed by combat. -- [x] Connected visual gate: shelf placement, minimize/restore persistence, - and first VTank-shell comparison in the live client. - -### MT2 — complete monster/weapon/debuff combat policy - -- [x] ordered `DEFAULT` + first-match monster rules; -- [x] priorities -1..4 and complete action-flag matrix; -- [x] damage/weapon/offhand selection, swap state machine and auto power, - including the official GameInfoDB exact-name overrides, ordered creature- - species preferences, and VTank's final elemental fallback; -- [x] debuff groups, skill/level choice, receipt-gated reapply and explicit - wand switching policy; -- [x] ring/arc/bolt density/range logic, streaks, Void, harm/martyr, grenades, - lenses, cast-on-strike and pets; - (carried phials are complete; crafting a missing phial belongs to MT4's - generalized craft transaction); -- [x] blacklist and both ghost-monster detectors, including canonical App - entity teardown for a detected client ghost. - -MT2 checkpoint 2026-08-27: the BCL API now projects complete learned-combat -spell metadata, server cast and physical-attack receipts, health-update -revision/age, canonical equipment snapshots/commands, and exact-incarnation -ghost deletion. MossTank owns the complete Monsters expression/action model, -debuff tracker, elemental/shape spell catalog, range/density selection, -weapon/offhand policy, temporary blacklist, and both VTank ghost algorithms. -Focused evidence at this checkpoint: 104 MossTank tests, 20 Runtime action/ -target tests, and an isolated Release App build all pass with zero failures or -warnings. MT2 remains open for automatic physical power and the four item- -backed combat families. - -MT2 checkpoint 2 (2026-08-27): the official VTank assembly and live GameInfoDB -feed were inspected directly. Item appraisal SpellBooks are now retained; -plugins receive ordered combat chat and exact item UseDone receipts; the -source planner implements `dz.b.CompareTo` for SpellLevel/Skill preference; -the 72 official phials, lenses, cast-on-strike weapons and pets are executable -and profile-gated; proc success waits for the actual `You cast ... on ...` -line. `hi.cs` automatic attack power, including Recklessness clamping, is -ported verbatim. Items/Consumables profiles are atomically persisted through a -new per-manifest plugin-storage contract. Focused evidence: 131 MossTank tests, -the storage/chat/App tests, and an isolated Release App build pass. MT2 remains -open only for target-database `Auto` damage selection and the connected gate; -missing-grenade crafting is deliberately MT4 transaction scope. - -MT2 automated closeout (2026-08-27): `Auto` now consumes the official 59-name -override and 103-species preference tables. The ordered element decision -outranks spell shape/tier, drives profiled physical weapon selection, and feeds -automatic attack power and vulnerability policy. Unknown targets preserve -VTank's final Pierce→Bludgeon→Slash→Acid→Lightning→Cold→Fire fallback. Focused -evidence after the closeout and named-profile foundation: 146 MossTank tests; -isolated Release App build 0 warnings / 0 errors. The connected MT2 combat -matrix remains part of the later combined user gate. - -### MT3 — buff, heal and resource parity - -- [x] named macro profiles, buff exclusions/item buffs/top-off foundation; -- [x] all three vital threshold tiers and canonical fellowship vitals; -- [x] profiled kits/consumables and worn-item mana recharge; -- [x] VTank ManaStone/ManaTank acquisition and exact-receipt fill behavior; -- [x] conversions, self/item/fellow dispel response, and critical/normal/idle - component plus six-category consumable upkeep. - -### MT4 — inventory, craft and transactions - -- [x] AutoStack/AutoCram and the official 757-row VTank craft database; -- [x] generalized use/apply/give/move/split/stack/drop transaction API with - receipts and busy arbitration; -- [x] retail 0x027D salvage and authoritative current-vendor sale paths; -- [x] same-input authoritative split crafting, all three split priorities and - exact VTank door/lockpick policy. - -### MT5 — looting and extensible rule engine - -- [x] corpse lifecycle/ID waits, exact 30-attempt/200-second open blacklist, - 60-minute cache, 100-second public ownership, fellow Share Loot and rare-only - policy; -- [x] ordered first-match raw/projected-property expressions plus Keep, - KeepUpTo, Read, Salvage, Sell, ManaStone, ManaTank and User1–User5; -- [x] canonical appraisal/pickup/salvage/vendor seams, unknown-scroll fallback, - and exact VTank salvage workmanship bands with 40-attempt abandonment; -- [x] independent By-char and named native loot profile documents; -- [x] exact VTClassic `.utl` v0/v1 importer/exporter, every structured - requirement, forward-compatible length blocks, and profile-owned salvage - ranges/value modes; -- [x] external loot-classifier plugin capability. - -MT5 functional closeout (2026-08-27): the graphical host now exposes corpse -discovery, raw item properties, canonical appraisal/pickup, learned-spell -membership, fellowship Share Loot, retail salvage (0x027D), and current-vendor -sale through additive BCL-only interfaces. MossTank owns all policy and waits -for authoritative receipts/object removal; no action reports success at -dispatch. The official VTank corpse timers, rare/fellow ownership branches, -unknown-scroll difficulty check, mana-stone pairing, salvage-bag workmanship -bands, and bugged-bag retry ceiling were ported from the official decompiled -source. Focused evidence: 184 MossTank tests, 21 inventory-wire/session tests, -134 App automation/item/UI tests, and an isolated Release App build with zero -warnings/errors. File interoperability remains an MT9 compatibility tail, not -a reason to hold Route/Navigation. - -### MT6 — navigation - -- [x] canonical move/follow/turn/charged-jump/checkpoint host primitives; -- [x] circular, linear, once and Target/follow routes, including VTank's - endpoint reversal, destructive Once traversal and follow-around-corners; -- [x] every decoded nav node (0..9), closed-door/lockpick policy, vendor and - repeated NPC use, portal re-entry protection and combat/nav priority; -- [x] independent By-char and named native route profiles; -- [x] exact `uTank2 NAV 1.2` importer/exporter. - -MT6 functional closeout (2026-08-27): the additive navigation API projects -VTank coordinates, live and server-accepted player position, object -reacquisition, door state, portal state, and typed movement levels through the -one Runtime command interpreter. MossTank owns the exact four route modes and -ten node types. Steering ports `fd.cs`'s 4° turn threshold, far 45° and near -15° forward cones; checkpoints use `gr.cs`'s accepted-position gate and -15-second nudge; Target mode ports `gl.cs` breadcrumb pruning; doors port -`b7.cs`'s defaults (disabled, 20 m ID, 4 m open, −50 lockpick threshold). -Portal2/UseNPC reacquire exact-name objects near the saved point, NPC use waits -for tell/give chat, jumps align to their stored heading before charge/release, -and Once removes completed rows exactly like VTank. Evidence: 204 MossTank -tests, focused App navigation projection tests, and isolated Release App build -with zero warnings/errors. Legacy file interop remains an MT9 compatibility -tail and does not hold the expression engine. - -### MT7 — expressions - -- [x] immutable AST, typed values, budgets and diagnostics; -- [x] UtilityBelt grammar semantics including lists/dicts/slices; -- [x] implement/alias/explicitly disposition the 260-function audit ledger; -- [x] VTank option/expression command diagnostics; -- [x] parser, evaluator, persistence and capability-security gates. - -### MT8 — meta engine and runtime views - -- [x] complete condition/action vocabulary, nested composition, once-per-entry, - call/return and watchdog; -- [x] chat capture variables and option access; -- [x] plugin-authored runtime views over the retained markup contract; -- [x] native meta profile; -- [x] exact VTank CondAct `.met` importer/exporter, including recursive rules, - embedded NAV and the historical CreateView record quirk. - -### MT9 — fellowship, profiles, commands and polish - -- [x] tell-driven recruitment, waiting-list, status/location commands, and - two-minute kick/ban/giveleader/setopen voting over canonical fellowship - commands; -- [x] helper healing, fellowship corpse permissions and shared target views; -- [x] macro-profile foundation: true per-character `By char` documents, named - create/copy/clear/select, mine-only filtering, hot loading, atomic manifest- - scoped storage, and complete current combat/buff/vitals/monster/item state; -- [x] independent navigation/loot/meta profile documents remain with MT5/MT6/MT8; -- [x] exact 137-name typed VTank option catalog/defaults and durable - `/vt opt setinall` across every indexed named/character macro profile; -- [x] all documented `/vt` command names are locally registered and handled; -- [x] exact `.nav`, `.met`, and `.utl` dumps/import-export; -- [x] privileged debug-operation semantics (`clearlocks`, `clearbusy`, - `fakeimp`) use canonical owners and authoritative lifetime cleanup; -- [x] first-run guidance, native/VTank profile migration and corrupt-profile - recovery with append-only raw-data preservation; -- [x] accessibility and scaling polish; -- [ ] performance soak, reconnect/lifecycle and multi-client gates. - -## MT1 execution order - -1. Add BCL-only combat records/interfaces with inert defaults. -2. Extend Runtime hostile query with exact position/heading snapshots. -3. Bind App's automation surface to the canonical action/spell owners. -4. Implement/test MossTank's deterministic combat controller. -5. Replace the small panel with dashboard/settings markup and generic markup - affordances needed by the design. -6. Run narrow tests, Release build, broad tests; record exact evidence here. - -## Closeout evidence - -MT1 code-complete 2026-08-26 and user-passed 2026-08-27. The additive BCL-only contract is -`CombatAutomation.cs`; older API-v1 implementations retain inert default -members. `AppAutomationSurface` borrows the canonical Runtime owners and -projects hostile captures, combat state, physical press/release attempts, -targeted casting and learned direct offensive spells. MossTank's -`CombatController` owns priority, target lock, range/angle/both selection, -mode entry, power-bar timing and magic choice. The dashboard/settings markup -uses the retained plugin registry; generic markup now supports bound child -visibility/enabled state and button colors. - -Automated evidence: - -- focused MossTank: 54 passed / 0 failed; -- complete Runtime: 1,854 passed / 0 failed; -- repository-owned hermetic Release gate: **15,775 passed / 0 skipped / - 0 failed across 14 assemblies**; -- Release build: 0 warnings / 0 errors; -- the original MossTank XML documents parsed successfully before the gate. - -MTUI code-complete 2026-08-27. `PluginPanelDescriptor` and authenticated -`PluginUiOwner` carry presentation metadata through Core's transactional -plugin lifetime; App mounts the stable panel as a `RetailWindowHandle` and the -generic `PluginSidePanel` owns only hide/restore UI. The one-window MossTank -shell uses real retained tabs/toggles/sliders. Focused evidence: 18 App/plugin -tests and 56 MossTank tests passed; isolated Release App build passed with -0 warnings / 0 errors. Broader hermetic evidence: Core 4,720/4,720 and Runtime -1,854/1,854 passed; App passed 6,441/6,442 with the sole failure in the -unrelated pre-existing landblock recenter assertion -`OriginRecenter_RetryPreservesLiveIdentityAndDoesNotRescueReusedGuid`. Its -connected visual gate remains open. - -MT3/MT4 resource closeout 2026-08-27: crafting now runs through VTank's three -ordered tiers: critical component/consumable recovery, normal component and -general profile crafting, then no-target idle component and six-category -kit/food stock targets. Same-input recipes wait for both the authoritative -split receipt and publication of two distinct stacks before applying. The -official `IdleCraftCount_*` underscore names, 4/20/20 component defaults, and -2/2/2 kit plus 15/15/15 food targets persist in named/By-char profiles. -Self-cast and item dispels port `c8.cs`/`cx.cs`; fellowship Awakener selection -ports `af.cs`, including exact training, Arcane Lore, 5 m, spell-3179 and -summed-vulnerability-quality gates. The additive shared duration-spell ledger -matches VTank's confirmed local/external `LogSpellCast` model and clears on -session detach. Evidence: 277/277 MossTank tests, 12/12 focused App automation -tests, and isolated Release App build with zero warnings/errors. - -MT7–MT9 checkpoint 2026-08-27: MossTank registers all 260 audited -UtilityBelt public expression names over the typed evaluator, and the Meta -runtime/editor, dynamic views, embedded routes, command execution and durable -variable scopes are integrated. The host now provides an unload-safe generic -plugin-command registry; `/vt` follows the same local command route from typed -chat, launcher login commands and no-window clients. The exact official -four-line command catalog and 137-row typed option database are present; -`setinall` rewrites every indexed named/character profile. Run Macro is now a -master lifecycle distinct from Enable Combat, and command jumps align before -charging. The additive fellowship API projects the canonical retail commands; -MossTank owns VTank's tell commands, wait list, spam limit, near-player -recruitment, leader transition cleanup and two-minute voting. Evidence at this -checkpoint: 261/261 MossTank tests, 18/18 runnable focused App/plugin tests, -and isolated Release App build with zero warnings/errors. Four additional -GraphicalPluginSession tests could not locate the repository when deliberately -run from an isolated OutputPath; this is test-harness path behavior, not a -product failure. Connected shelf/UI/fellowship and combined automation gates -remain open. - -Legacy-profile checkpoint 2026-08-27: native JSON remains MossTank's durable -working format, while every save also emits a genuine VTank compatibility -file. `uTank2 NAV 1.2` routes and CondAct `.met` files round-trip exactly; -the Meta writer was independently accepted and canonicalized byte-identically -by the public `metaf` reference compiler. VTClassic `.utl` v0/v1 now retains -length-delimited unknown requirements/blocks, executes all 31 published -requirement types (including the DAT-resolved ordered-palette color family), -and applies per-material salvage ranges/value modes to the real 0x027D combine -planner. Native-only text rules export disabled rather than becoming -VTClassic's dangerous empty-requirement match-all. Evidence: 290/290 MossTank -tests, 13/13 focused App/plugin tests, and isolated Release App build with zero -warnings/errors. - -External-loot checkpoint 2026-08-27: the BCL-only host now owns an unload-safe -classifier registry. Classifier ids are namespaced to the registering plugin, -all registrations are disposed transactionally with that plugin's session, -and exceptions are isolated at the registry boundary. MossTank exposes the -available engines in Profiles, persists the selection with the macro profile, -and runs Keep/KeepUpTo/Read/Salvage/Sell/User1–User5 decisions through its -existing authoritative corpse executor. An unavailable engine never silently -changes policy by falling back to VTClassic. Evidence: 2 focused Core registry -tests, 55 focused MossTank loot/panel/markup tests, and isolated Release App -build with zero warnings/errors. - -Options/debug checkpoint 2026-08-27: the VTank Options page now uses the -verbatim four-column control arrangement. Normal automatic rebuff, the -separate idle top-off window, Attack→Approach distance navigation, and final -Idle Peace fallback were ported from `fz.cs`, `cLogic.cs`, `g8.cs`, `eb.cs` -and `cm.cs`; Force Buff and Cancel Force Buff remain distinct actions. The -Advanced Options button opens the full ordered 137-setting table. `/vt -clearbusy` decrements exactly one Runtime-owned inventory busy reference, -`clearlocks` clears only MossTank's transient policy locks, and `fakeimp` -records VTank's local 3,000-second Gossamer Flesh debug marker without forging -a server cast. External classifiers now receive authoritative `OnLooted` and -`OnItemRemoved` lifecycle callbacks after inventory publication. Evidence: -298/298 MossTank tests and an isolated Release App build with zero warnings -and zero errors. - -Final automated API/options checkpoint 2026-08-27: every one of the 137 -official advanced-option names has an explicit writable live-policy mapping; -the full catalog, official defaults, case-insensitive lookup and durable -profile propagation are covered. The Monsters page now exposes the three -distinct official cycles for Damage type, Ex. Vuln and PetDmg rather than one -shared internal enum. Prismatic remains an ammunition policy while preserving -automatic magic-element selection; Fists uses Tusker Fists only while its -enchantment is active. `DoJiggle` now ports VTank's PreviousSelection followed -by alternating NextPlayer/PreviousPlayer at 131 ms and no longer moves the -character. `ShowCollisionDebug` publishes bounded projectile samples through -the BCL-only API and renders transient red/green markers in the retained UI. -`WhoYouGonnaCall` is intentionally stored but inert, matching the official -source's explicit `No Function` disposition. - -The plugin API now projects combat, magic, equipment/items, looting, -fellowship, enchantments, navigation, world objects/time, login, network peer -state, recovery, projectile diagnostics and selection through canonical -Runtime/App owners. Startup peer tags are parsed once by `RuntimeOptions`, -portable data paths come from `ApplicationPathSet`, and both graphical and -headless plugin hosts load fixtures correctly from isolated output graphs. -Latest hermetic evidence: App 6,592 passed / 94 environment-dependent skips; -Runtime 1,863/1,863; Core 4,911/4,911; Core.Net 1,042/1,042; Headless -171/171; UI abstractions 880/880; MossTank 320/320 — **15,779 passed, zero -failed** across the selected automated lanes. The Release App build completed -with zero warnings and zero errors. Excluded gates are explicit: manual/live -lanes, Linux-only tests on this Windows host, the machine-local stale bake-tool -4 PAK test, and one registered pre-existing tower-ascent known failure. The -generic shelf, VTank shell, minimization-while-running, reconnect, live combat, -multi-client peer expressions, and collision-marker appearance remain owed in -the combined connected user gate. - -Connected shelf/shell gate 2026-08-27: the first isolated Release launch found -that App's plugin-copy target still assumed each plugin's conventional `bin` -directory when a custom `OutputPath` was active. That caused the packaged -MossTank DLL/markup to be stale even though the root build outputs were current. -Build and publish now resolve both first-party plugin targets through MSBuild's -`GetTargetPath`; MossTank markup copies directly from its source. The rebuilt -package's MossTank DLL and XML matched their build/source SHA-256 hashes and -the boundary regression passed 5/5. - -The next live launch exposed a retained-markup contract mismatch: one field -reused an `Action` button binding where `onsubmit` requires `Action`, -preventing the complete plugin window from mounting. MossTank now has a typed -submit action and its markup contract test validates every interactive binding's -delegate shape. A later visual pass also caught three unsupported inline label -bindings on Meta; all are now whole-value properties, and the contract rejects -future inline interpolation. Focused MossTank evidence is 321/321; isolated -Release build `app-release22` is zero-warning/zero-error with exact packaged -artifact hashes. - -The connected `app-release22` gate then passed: all nine tabs mounted and were -visually inspected; Meta rendered `State: Default`, `N: 0`, and `N2: 0`; the -right-edge `MT` shelf button was fully reachable; minimize hid only the window; -while hidden the live buff pass advanced from 91/97 to 77/97; restore showed -`Stop Macro` and the changed live status; the macro stopped normally. Logs show -92 server-confirmed `UseDone err=0` casts and no plugin/UI exception. Shift+Esc -completed the full logout presentation and returned to character selection. -This supersedes the earlier statement that the shelf, shell, minimization, and -basic reconnect/lifecycle presentation were wholly unproven. At that checkpoint, -still owed were -the accessibility/scale closeout, longer performance/reconnect soak, live -hostile combat matrix, two-client peer expressions/fellowship, and collision- -marker appearance. - -Accessibility/reconnect/peer checkpoint 2026-08-27: textless and terse controls -now carry runtime-bound retained tooltips, and the common window owner clamps -plugin panels to the current viewport (including the 800x600 oversize case). -Focused evidence is 325/325 MossTank tests, 16/16 retained-UI tooltip/geometry -tests, and isolated Release `app-release23` with zero warnings/errors. The live -client displayed the Monster Range help text, completed a same-character -logout/re-entry, restarted the macro, and completed another 92 server-confirmed -casts. Working/private memory stayed approximately 1.59/1.84 GiB across the -combined soak rather than climbing with casts or reconnect. - -The local peer API also passed real two-process expressions in both directions: -the secondary `+Horan` evaluated -`dictgetitem[listgetitem[netclients['mosstank-guard-primary'],0],'Name']` and -received `+Acdream`, while the earlier reciprocal gate returned `+Horan` to -the primary; both heartbeat documents contained the expected names, tags, -vitals and positions. - -That broader gate exposed separate client defect #452. First-chance cdb proof -located it in GLFW's Win32 event pump: temporary cross-process input-queue -attachment let `GetActiveWindow` return the other acdream process's HWND; -GLFW's shared `L"GLFW"` property then returned the other process's private -`_GLFWwindow*`, which the caller dereferenced. `app-release24` installs the -current-process HWND guard at GLFW's own import slot before `glfwInit`; its four -focused tests pass. Two rebuilt graphical clients then entered world, survived -100 rapid forced activation switches—the exact old trigger—and remained -responsive through a 30-minute combined soak with no native error. Issue #452 -remains in-progress only until both sessions complete a graceful-exit gate. - -The secondary-owned fellowship gate also passed: `+Acdream` created -`mosstankgate`, `+Horan` joined, both canonical rosters contained both members, -and the secondary evaluated `getfellowshipcount[]` as `2`. - -Still owed here: the hostile combat matrix and collision-marker appearance. - -Final local validation checkpoint 2026-08-27: the complete Release solution -build passed with zero warnings and zero errors. Focused MossTank passed -325/325 and the App plugin/API/UI/GLFW set passed 35/35. The conservative -Windows hermetic filter passed 15,083 non-network tests; Core.Net then passed -1,042/1,042 in its isolated lane, for 16,125 passing selected tests. The first -max-parallel combined invocation made Core.Net's timing-sensitive two-percent -packet-loss soak exhaust its wall-clock headroom; the same case and complete -Core.Net lane passed immediately when isolated. No MossTank, plugin API, plugin -UI, Runtime-owner, or #452 guard test failed. - -Live hostile discovery checkpoint 2026-08-27: the first surrounded-monster -gate exposed two coupled compatibility defects. Retail's classic `* Lure` -vulnerability names were absent from the debuff classifier, so an attack-only -profile could misclassify Piercing Lure's "piercing damage" description as a -direct attack. The classifier now recognizes all seven classic elemental Lure -families (while excluding the distinct Lure Blade item spell), and the attack -catalog defensively rejects every host-authored debuff. Target evaluation also -now ports official `dz::a`'s previous-target tie-break after priority and manual -TargetLock: a valid chosen monster remains selected while the character turns, -instead of angle rescans alternating between surrounding monsters. The new -Lure/attack and target-stability regressions bring the focused MossTank lane to -337/337. Connected re-test remains part of the hostile combat gate. - -## Requirement-level completion audit (2026-08-27) - -Completion is deliberately **not** claimed while live evidence remains missing. -The authoritative requirement/evidence map is: - -| Objective requirement | Current evidence | Audit result | -| --- | --- | --- | -| Functionally complete VTank behavior | MT2–MT9 implementation ledger; 337 MossTank behavior/format/expression tests; connected MT1 autocombat acceptance | Proven for implemented policy and formats; the combined hostile physical/magic matrix remains live-unproven | -| Visually verbatim nine-tab VTank surface | `mosstank.xml` contains the exact Options, Profiles, Vitals, Monsters, Items, Consumables, Buffs, Route, Meta order; all nine tabs mounted in `app-release22` | Proven for shell/tab presence and first comparison; projectile debug-marker appearance remains live-unproven | -| Every visible control has real behavior | 190 interactive controls expose 202 bindings (191 unique); `MossTankMarkupContractTests` resolves every binding, verifies delegate shape, and rejects handlerless controls; 137/137 advanced options have explicit writable mappings | Proven statically and by focused controller tests. `WhoYouGonnaCall` intentionally stores its value but performs no action because the official VTank source labels it `No Function` | -| Generic plugin sidepanel; minimizing must not stop plugins | retained `PluginSidePanel`/window-manager tests plus connected hide/restore gate where the hidden buff pass advanced from 91/97 to 77/97 | Proven | -| Modern acdream plugin APIs over canonical owners | additive BCL-only combat, magic, equipment, item, loot, fellowship, enchantment, navigation, object, world-time, login, network, recovery, projectile, selection, storage, command and classifier contracts; 35 focused App/API/UI tests and 16,125 selected Release tests | Proven for the graphical live host; older/no-window implementations explicitly report unavailable and never fabricate success | -| UtilityBelt-compatible expression superset | immutable evaluator tests; all 260 audited public names registered; host-action, object, fellowship, time, login/network, UI, persistence, collection and meta tests | Proven by catalog and semantic family tests; bidirectional two-client network expressions passed live | -| Lifecycle, reconnect, multi-client stability | same-character reconnect and hidden execution passed; peer expressions and two-member fellowship passed; #452 exact trigger survived 100 focus switches and a 30-minute dual-client soak | Proven through soak; #452 cannot close until both current sessions exit gracefully | - -Open completion gates: (1) hostile physical and offensive-magic behavior against -a live target at valid configured range; (2) visible green/red projectile -collision markers with `ShowCollisionDebug`; (3) graceful exit of both current -soak clients with no native or managed failure. These are evidence gaps, not -redefined-away acceptance criteria. diff --git a/docs/plans/2026-08-27-pak-v2-resource-campaign.md b/docs/plans/2026-08-27-pak-v2-resource-campaign.md deleted file mode 100644 index 0b266577..00000000 --- a/docs/plans/2026-08-27-pak-v2-resource-campaign.md +++ /dev/null @@ -1,164 +0,0 @@ -# PAK v2 resource campaign - -Status: CLOSED — SHIPPED (2026-08-27) - -## Objective and release gates - -Ship one crash-safe prepared-asset format migration that: - -- reduces the complete installed package from 29,908,271,024 bytes to at - most 5 GiB; -- preserves decoded geometry, material metadata, texture bytes, deterministic - baking, corruption isolation, and random-access loading; -- does not regress cold or warm world-reveal latency or frame-time percentiles; -- reduces offline bake time and live client CPU/GPU memory where the data - permits it, without changing the rendered result; the one-time bake remains - bounded for the user-confirmed 16-32 GiB target machines; -- gives launcher users a clear one-time update message and progress, while - retaining the last verified package until the replacement is validated; -- passes two different worker-count bakes with identical SHA-256, the complete - installed-DAT bake, content equivalence, performance, solution, Windows CI, - and release gates. - -## Measured format-1 baseline - -The installed package was parsed from its actual TOC, not estimated: - -| Partition | Physical blobs | Physical bytes | -|---|---:|---:| -| GfxObj render meshes | 15,318 | 9,306,115,868 | -| Setup render meshes | 5,935 | 4,078,139 | -| EnvCell render meshes | 17,117 | 20,232,745,510 | -| All collision payloads | 12,938 | 30,392,894 | -| EnvCell topology | 729,888 | 255,512,622 | -| TOC | 2,232,170 rows | 53,572,080 | - -Total: 29,908,271,024 bytes. Render payloads account for approximately -29.54 GB and 99% of physical payload bytes. The collision and index data are -not the size problem. Format 1 already aliases duplicate complete EnvCell -blobs, but each remaining mesh embeds another copy of every decoded RGBA -texture it uses. - -Historical complete-bake baseline: 80.5-107.5 seconds, 4.43-4.89 GB peak -working set, and 3.81-4.21 GB peak private bytes. - -## Format 2 contract - -The 64-byte header and 24-byte sorted TOC row remain fixed. Format version is -2 and bake recipe is 6. - -1. A new `TexturePayload` key partition (type 8) owns globally shared texture - byte arrays. Mesh payloads store the texture payload key while retaining - their own exact dimensions, format, upload metadata, surface identity, - translucency, culling, and index data. -2. Texture payload keys are the first 56 bits of SHA-256 under the type-8 - namespace. The writer retains the full SHA-256 digest and length for every - unique texture, making even a truncated-key collision a loud bake failure - rather than silent substitution without pinning another copy of all bytes. -3. Every physical blob is independently encoded. The high bit of the TOC - length marks compression; the low 31 bits are the stored length. A - compressed blob contains a four-byte decoded-length prefix followed by - Brotli. Small or insufficiently compressible blobs remain exactly raw. - CRC-32 covers stored bytes, then decompression is independently validated. -4. Random access remains one binary search plus one mmap copy for raw blobs. - Compressed blobs add decompression only when the writer proved a material - size win. Texture payloads use a bounded, thread-safe 64 MiB / 1,024-entry - LRU; concurrently decoded meshes converge on one shared array instance. -5. Whole-file compression is forbidden. It would destroy random access and - make a small world reveal depend on unrelated content. -6. Unedited DAT DXT1/3/5 surfaces retain their exact BC1/2/3 source blocks - through bake, mmap, and Vulkan upload. Clip maps and surfaces with authored - translucency still decode to RGBA8 because their per-surface alpha edits - require pixels. This is smaller and closer to retail's hardware DXT path - than the former unconditional software decode. - -## Determinism and publication - -Asset traversal and mesh serialization remain sorted. A texture is emitted at -its first deterministic encounter, so its physical order is independent of -worker completion order. Aliases preserve the source row's exact offset, -encoded length/flags, and CRC. - -Recipe 5 to 6 is a mandatory full rebuild. The launcher builds -`acdream.pak.candidate` beside the active package, validates format, recipe, -DAT iterations, TOC counts, size, completion protocol, and SHA-256, then uses -the existing atomic promotion/backup transaction. Cancellation or failure -keeps the verified format-1 package. No overlay may cross this format change. - -## Complete installed-DAT evidence - -The first installed-DAT mixed sample (four GfxObj, three Setup, three EnvCell, -all corresponding collision/topology payloads) produced 58 keys, 29 globally -deduplicated texture payloads, and 57 physical blobs. Decoded payload was -3.5 MiB and stored payload 1.0 MiB (3.62x); output was 1.0 MiB. Eight-worker -and three-worker bakes had the identical SHA-256 -`78886DFA28A3EDF9368A1E25C9B02A3B69ADC5DFCC01358D64A073B549B5B532`. - -The final complete four-worker and nine-worker bakes are byte-identical: - -- 2,237,866 logical keys and 786,892 physical blobs; -- 5,696 globally shared texture payloads; -- zero extraction/validation failures; -- 597,229,424 bytes (569.6 MiB), down 98.0% from 29,908,271,024 bytes; -- SHA-256 - `37BC0EA1778F899AF9E3B2397937D373F69D615B15D3E32041BF389D93B624BF`; -- 28.7 seconds for the final four-worker bake versus 79.1 seconds for the - same-machine recipe-5 format-1 baseline (63.7% faster). - -The exact before/after connected lifecycle route used the same machine, -server, 1280x720 Vulkan presentation, retail render pack, and matching client -code. Both runs passed fresh login, multi-world portal travel, same-location -revisit, fresh-process reconnect, and graceful teardown. - -| Matching live measurement | Format 1 | Format 2 | Change | -|---|---:|---:|---:| -| Heavy-route final working set | 2,621.9 MiB | 1,363.9 MiB | -48.0% | -| Heavy-route final private bytes | 2,470.5 MiB | 1,825.6 MiB | -26.1% | -| Holtburg prepared-mesh GPU bytes | 229.6 MiB | 170.1 MiB | -25.9% | -| Fresh reconnect working set | 974.1 MiB | 869.0 MiB | -10.8% | -| Fresh reconnect private bytes | 1,253.8 MiB | 1,166.6 MiB | -7.0% | -| Fresh reconnect to checkpoint | 59.3 s | 59.3 s | equal | - -Rynthid and Facility transition times were equal; Holtburg completed 2.9 s -faster; Aerlinthe revisit differed by 0.1 s. Matching stable CPU p50/p95 rows -were equal or slightly faster and GPU time remained within 0.1 ms. The first -raw login samples were excluded from comparison because the server started -them in different cells with 6,671 versus 11,799 world entities. Matching -screenshots preserve geometry, materials, lighting, transparency, texture -detail, and mip behavior. - -Automated gates at this checkpoint: 181/181 installed-DAT Content tests, -16,151/16,151 CI-filtered Windows tests, Release build with zero warnings, and -the authoritative connected lifecycle/reconnect gate all pass. - -## Release closeout - -Main fast-forwarded cleanly to `45ba42a3`. Gitea Actions run 206 passed the -Windows gate, Linux portable gate, and release job. The published alpha is -`0.1.0-build.202608271848`; the stable `latest` pointer carries the 45 MiB -Windows client, 78 MiB Windows launcher, and update manifest. - -The first remote run exposed one locale-only contract failure: the launcher's -disk-space guidance rendered `2,0 GiB` under the runner culture. The shipped -fix formats the value invariantly and exercises the failure path under -`sv-SE`; the exact follow-up local Release gate again passed 16,151/16,151. -The exact shipped App binary also passed the connected lifecycle/reconnect -route (`connected-world-gate-20260827-204249`) with only the 25 expected -world-edge misses. - -## Work ledger - -- [x] Measure the format-1 package by TOC partition. -- [x] Implement and unit-test format-2 external texture references, adaptive - independent compression, corruption handling, bounded sharing, and byte - determinism. -- [x] Integrate format-2 accounting and strict validation into the bake. -- [x] Publish the recipe-6 mandatory full-rebuild launcher migration. -- [x] Add launcher disk-space preflight and explicit long-work detail. -- [x] Complete installed-DAT equivalence and dual-worker full bakes. -- [x] Measure/tune package size, bake time, read CPU/allocations, cold - and warm reveal latency, and frame-time percentiles. -- [x] Evaluate source-native BC texture retention only if it remains visually - exact and does not shift mip-generation work into the reveal frame. -- [x] Pass complete local tests and authoritative connected gates. -- [x] Pass Gitea Windows CI, merge, push, and release gates. diff --git a/docs/research/2026-06-26-character-window-retail-reference.md b/docs/research/2026-06-26-character-window-retail-reference.md index 949e3a7c..8ddb9c2a 100644 --- a/docs/research/2026-06-26-character-window-retail-reference.md +++ b/docs/research/2026-06-26-character-window-retail-reference.md @@ -31,20 +31,6 @@ the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x210000 - **Selected row (Strength):** highlighted with a **DARKER background + bars above/below** — retail's selected-row sprite `0x06001397` (Button state 6). (✗ acdream uses a translucent GOLD tint — replace with the dark-bar sprite.) - - > **CORRECTION 2026-08-24 (Campaign CT, CT1 fix round).** The sprite id - > above is WRONG for this element. Campaign CT's live-DAT probe - > (`docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`, §2 - > "SEALED VERDICT") found the attribute/skill row's actual Highlight - > media is `0x06000F93`, drawn via `gmAttributeUI::UpdateSelection - > @0x0049DEE0`'s `SetState(6)` → `InfoRegion::SetState @0x004F0EE0` on - > the row template `0x10000248` itself. `0x06001397` is real, but it - > belongs to a DIFFERENT mechanism: the spellbook row's separate - > selected-overlay child element (`0x10000342` under prototype - > `0x10000343`, via `UIElement_UIItem::SetSelectedState @0x004E1240`). - > This note's "Button state 6" framing was accidentally right about the - > STATE number but wrong about which sprite that state resolves to on - > this element. CT5 is the owning slice for the fix. - **Footer flips to:** - Title: **"Strength: 200"** — **WHITE** text (✗ acdream uses the body/gold color). - "Experience To Raise:" + **"Infinity!"** (Strength is maxed → cost is infinite; ✗ acdream shows a @@ -59,8 +45,7 @@ the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x210000 3. [ ] Add "Total Experience (XP):" caption. 4. [ ] Add "XP for next level:" caption + value (un-consume from the meter, or render alongside). 5. [ ] Row text larger (≈icon height) + rows tighter. -6. [ ] Selection highlight → sprite 0x06000F93 (dark bars), not gold tint. (Corrected 2026-08-24 — - see the CORRECTION note above; the sprite id in this checklist item was originally 0x06001397.) +6. [ ] Selection highlight → sprite 0x06001397 (dark bars), not gold tint. 7. [ ] Selected footer title → white. 8. [ ] Maxed attribute → "Experience To Raise: Infinity!". 9. [ ] Footer title wording = "Select an Attribute to Improve" (Attribute). diff --git a/docs/research/2026-08-08-slice6-vendor-transactions-research.md b/docs/research/2026-08-08-slice6-vendor-transactions-research.md index af23e0c3..47052444 100644 --- a/docs/research/2026-08-08-slice6-vendor-transactions-research.md +++ b/docs/research/2026-08-08-slice6-vendor-transactions-research.md @@ -337,18 +337,44 @@ nothing wrong" surprise, matching the register's existing framing of the ### B.2 — Double-click -**Corrected 2026-08-26:** the original symbol-name search missed the real -mechanism. Retail handles this inside the general -`gmVendorUI::HandleMousePresses @ 0x004C40D0`; it does not require a separately -named `CheckForDoubleClick` function. In the Items-list branch, the retail -double-click condition directly calls `gmVendorUI::BuySingleItem` for the -clicked row. The same function also owns staged Buying/Selling removal and -their `ClientLocal` feedback. +**No dedicated double-click-to-buy mechanism was found for vendor shop +items.** Evidence, not absence-of-search: -**Conclusion:** browse-row double-click-to-buy is verbatim retail behavior. -The acdream binding is a port, not an optional modernization. The previous -absence-of-symbol inference was false and is superseded by the direct function -body. +- `gmVendorUI::ListenToElementMessage` (`pc:204260-204309`, full function + read) dispatches on message id 1 (button click → + `HandleButtonClicks`), 7 (dropdown selection change), `0x2c` (page + change), `0x15` (drop release), and `0x1c` (routes to + `HandleMousePresses` only when `m_itemsUI != 0`) — there is no distinct + "double-click" message id handled at the panel level. +- The base list class `UIElement_ItemList` (every method enumerated via + `docs/research/named-retail/symbols.json`, ~50 symbols) has + `HandleSingleSelection`, `HandleTargetedUseLeftClick`, + `ItemList_SetSelectedItem`, `ItemList_OpenContainer` (for double-clicking + a CONTAINER item specifically — opening it, not buying), but **no + generic double-click handler** and no vendor-specific one either. +- Other retail panels DO have an explicit, separately-named double-click + handler when the mechanism exists — e.g. `gmContractsUI::CheckForDoubleClick` + (`0x00497A10`), `gmPageListUI::CheckForDoubleClick` (`0x00493140`). No + `gmVendorUI::CheckForDoubleClick` or `VendorItemsUI::CheckForDoubleClick` + symbol exists in the 18,366-function named table. + +**Conclusion:** retail's confirmed vendor-item interaction model is +single-click-to-select (→ drives the global `ACCWeenieObject::selectedID`, +B.3 below) plus an explicit Buy/Add button press. There is no evidence +retail supports double-click-to-buy on the shop list. The user's +expectation likely carries over from inventory-panel muscle memory +(double-click = use/equip elsewhere in retail) — but the vendor "Items" +list is not that panel. **This is flagged as an open question for the +contract, not resolved unilaterally**: per the project's +no-invented-mechanisms discipline, do not silently add a double-click-buy +shortcut and call it retail-faithful. The retail-faithful, fully-evidenced +fix for "double-click does nothing" is: (a) make single-click meaningfully +select (today it only sets a private field with no visible effect — see +B.3), and (b) make the Buy button actually work. If the user still wants a +double-click shortcut after seeing single-click+Buy work, that is a +deliberate, flagged acdream UX addition on top of retail, not a retail port +— call it out explicitly in the commit/register the way AP-116 +(Particle Range) or similar user-directed deviations are recorded. ### B.3 — The quantity slider @@ -709,9 +735,10 @@ concretely unblocked by the one before it; skipping ahead reproduces the polish, not correctness — the server is authoritative either way) and file it as a fast follow-up if the user notices the round-trip lag on a refused purchase. -2. **Double-click — RESOLVED 2026-08-26.** Retail's - `gmVendorUI::HandleMousePresses @ 0x004C40D0` directly buys a browse row on - double-click. Keep this behavior and its staged-row siblings. +2. **Double-click** — no retail mechanism found (B.2). Ask the user + directly whether they want a deliberate acdream-only double-click + shortcut once single-click-select + Buy-button-works is verified live, + rather than assuming yes and inventing behavior. 3. **Where does the vendor-owned split-exempt-mask predicate live** — C.1's design question: fold into `SelectedObjectController` directly (it already owns the seeding logic, would need a `Func diff --git a/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md b/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md index aaa846da..9240d7b4 100644 --- a/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md +++ b/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md @@ -5,13 +5,6 @@ Research lane D of the settings-track campaign questions **Q5** (Configure Keyboard: retail's keymap UI + storage) and **Q6** (what every Gameplay Options tab button does). -> **2026-08-26 implementation addendum:** the report below describes the -> pre-OP8 state and its design choices at that date. acdream has now shipped -> Option C end to end: all 306 installed-DAT rows, retail conflicts/capture, -> and real named `.keymap` Load File / Save As/startup/shutdown persistence. -> See `docs/research/2026-08-26-retail-keyboard-routing-audit.md`; AP-202 is -> retired. - **Report only.** No repo code was changed. Every retail claim below carries a named symbol + address from the Sept 2013 EoR PDB-paired build. The PDB/binary pairing was verified first: diff --git a/docs/research/2026-08-24-advancement-wire-and-recompute.md b/docs/research/2026-08-24-advancement-wire-and-recompute.md deleted file mode 100644 index e16dcfb8..00000000 --- a/docs/research/2026-08-24-advancement-wire-and-recompute.md +++ /dev/null @@ -1,1256 +0,0 @@ -# CA1 — advancement wire protocol + retail recompute chain (Campaign CA oracle) - -Assembled 2026-08-24 from two parallel research passes (wire: ACE + -Chorizite + holtburger; recompute: named-retail pseudo-C + live Ghidra -decompiles of the PDB-paired 2013 binary), with every load-bearing claim -independently re-verified before assembly (§0). - -## 0. Verification ledger (claims re-checked by hand before use) - -| Claim | Verified how | Verdict | -|---|---|---| -| `PrivateUpdateSkill = 0x02DD` (our `PropertyString.cs` comment says 0x02DD is PropertyString) | ACE `GameMessageOpcode.cs`: Skill=0x02DD, **PropertyString=0x02D5** | Agent right; OUR comment wrong (doc-only — nothing dispatches on either id today; fix the comment in CA2) | -| `SkillFormula::Calculate @ 0x00591960` = `floor((x·a1+y·a2+w)/z+0.5)`, `z==0 → 0` | Ghidra decompile, read in full | EXACT — and the `z==0` early-out IS the attribute-less-skill (Salvaging) handling; no special case needed | -| `CACQualities::InqSkill @ 0x00592660` computes LIVE (base formula + ranks + init + augmentation bonuses) | Ghidra decompile, read in full | CONFIRMED — includes the per-skill-group +10 augmentation adds (our #268 family) | -| ACE's `HandleRunRateUpdate` wire effect | `Player.cs:975` read in full | Re-broadcasts the movement packet with new ForwardSpeed when rate changed mid-movement → lands in our existing `UpdateMotion.ForwardSpeed` echo → `ApplyServerRunRate` | -| `PrivateUpdateSkillLevel (0x02DF)` producer | grep ACE Source | NO producer anywhere (opcode-name table only) — vestigial for ACE; CA2 skips the parser | -| Retail formula vs our code | `src/AcDream.App/Net/RetailSkillFormula.cs` | **ALREADY PORTED EXACTLY** (cites 0x00591960; unsigned reinterpretation of DAT signed views; Trained+5/Specialized+10 from CC5-F3). ACE's simplified `AttributeFormula` is NOT in our code path | - -**Design verdict binding CA2/CA3:** retail computes skills, vitals maxima -and run rate LIVE at inquiry time; Set* handlers are raw-storage writes; -UI refresh is a value-less observer notification and widgets re-pull. -Therefore acdream's port: inbound parsers write raw stats into the J4 -owners, `SkillSnapshot`'s frozen `FormulaBonus` becomes a live computation -against current attributes, and presentation re-reads on the existing -change events. No recompute cascade, no dirty flags, no cached effective -values. - ---- - -# Campaign CA / Slice CA1 — character-advancement wire research - -Read-only research. All ACE / Chorizite.ACProtocol / holtburger file:line -citations below point at `references//...` **as checked out in the -main repo** (`C:\Users\erikn\source\repos\acdream\references\...`). Those -three reference repos are **not present in this worktree** (they are -untracked/gitignored and worktrees don't get untracked files) — only -`references/WorldBuilder` exists here. When implementing, either read the -main checkout directly or re-verify paths resolve in whatever tree you're -in. - -Our existing outbound builder: `src/AcDream.Core.Net/Messages/CharacterActions.cs` -(this worktree). Our existing (partial) inbound parser: -`src/AcDream.Core.Net/Messages/PrivateUpdateVital.cs` (this worktree, vitals only). - ---- - -## 1. Summary opcode table - -| Dir | Opcode | Name (ACE) | Name (Chorizite) | Purpose | -|---|---|---|---|---| -| C→S | `0xF7B1` | `GameActionOpcode.GameAction` (envelope) | — | Wraps every GameAction: `u32 opcode=0xF7B1, u32 sequence, u32 subOpcode, ...body` | -| C→S | `0x0044` | `GameActionType.RaiseVital` | `Train_TrainAttribute2nd` | Spend XP to raise a vital | -| C→S | `0x0045` | `GameActionType.RaiseAttribute` | `Train_TrainAttribute` | Spend XP to raise an attribute | -| C→S | `0x0046` | `GameActionType.RaiseSkill` | `Train_TrainSkill` | Spend XP to raise a skill | -| C→S | `0x0047` | `GameActionType.TrainSkill` | `Train_TrainSkillAdvancementClass` | Spend skill credits to train a skill | -| S→C | `0x02CD` | `PrivateUpdatePropertyInt` | `Qualities_PrivateUpdateInt` | `AvailableSkillCredits` after TrainSkill / specialize / untrain / reset | -| S→C | `0x02CF` | `PrivateUpdatePropertyInt64` | `Qualities_PrivateUpdateInt64` | `AvailableExperience` after **every** successful RaiseAttribute/RaiseVital/RaiseSkill | -| S→C | `0x02DD` | `PrivateUpdateSkill` | `Qualities_PrivateUpdateSkill` | Full skill record (ranks/SAC/xp/init/resistance/lastUsed) | -| S→C | `0x02DF` | `PrivateUpdateSkillLevel` | (not generated under that name; opcode exists in enum) | Ranks-only skill delta — **NOT used by any of the 4 actions**; not emitted by `HandleActionRaiseSkill`/`HandleActionTrainSkill`/`SkillAlterationDevice`. holtburger still models it (`UpdateSkillLevel`) but no ACE producer for it was found for this feature surface. Flagged as an open question below. | -| S→C | `0x02E3` | `PrivateUpdateAttribute` | `Qualities_PrivateUpdateAttribute` | Full attribute record (ranks/start/xp) | -| S→C | `0x02E7` | `PrivateUpdateVital` | `Qualities_PrivateUpdateAttribute2nd` *(name disagreement, same bytes — see §5)* | Full vital record (ranks/start/xp/current) | -| S→C | `0x02E9` | `PrivateUpdateAttribute2ndLevel` | `Qualities_PrivateUpdateAttribute2ndLevel` | Current-only vital delta (no ranks/xp) | -| S→C | `0xF750` | `Sound` | — | `RaiseTrait` sound cue on rank-up | -| S→C | `0xF7E0` | `ServerMessage` (→ `GameMessageSystemChat`) | — | "Your base X is now N!" text on rank-up; also failure text | -| S→C (GameEvent, envelope `0xF7B0`) | GameEventType `0x028B` | `WeenieErrorWithString` | — | Specialize/untrain/reset success/failure text (routed through a confirmation dialog, not a plain GameMessage) | - -No dedicated opcode exists for **untrain / specialize / reset** — see §4. - ---- - -## 2. Per-message byte layout - -All multi-byte integers are little-endian. Every `GameMessage` subclass in -ACE auto-writes its own `u32 Opcode` first via the `GameMessage` base -class ctor (`references/ACE/Source/ACE.Server/Network/GameMessages/GameMessage.cs:25-26` -/ `:45-46`) — that 4 bytes is **not** written again by the derived class -body; I've included it in each layout below for wire-completeness. - -### 2.1 Outbound — `RaiseVital` (`0x0044`) - -``` -u32 envelope = 0xF7B1 -u32 sequence -u32 subOpcode = 0x0044 -u32 vitalId // PropertyAttribute2nd — MUST be the Max* id (1/3/5), see §2.9 -u32 xpSpent -``` -20 bytes total. Confirmed by: -- ACE parse: `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionRaiseVital.cs:11-12` - (`(PropertyAttribute2nd)message.Payload.ReadUInt32()`, then `ReadUInt32()` for xp — **both u32**, not u64). -- holtburger: `RaiseVitalActionData` — `vital_type: u32, xp_spent: u32` — - `references/holtburger/crates/holtburger-protocol/src/messages/player/actions.rs:38-65`. -- Chorizite: `Train_TrainAttribute2nd` — `Type: VitalId (u32), Experience: uint` — - `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/C2S/Actions/Train_TrainAttribute2nd.generated.cs:14-41`. - -Our builder (`CharacterActions.BuildAttrOrVital`, `src/AcDream.Core.Net/Messages/CharacterActions.cs:94-103`) -matches exactly. **No change needed** for the outbound side. - -### 2.2 Outbound — `RaiseAttribute` (`0x0045`) - -Same shape as 2.1: `u32 attrId (PropertyAttribute) + u32 xpSpent`. -- ACE parse: `GameActionRaiseAttribute.cs:10-11`. -- holtburger: `RaiseAttributeActionData` — `actions.rs:8-27`. -- Chorizite: `Train_TrainAttribute` — `Train_TrainAttribute.generated.cs:14-41`. -Matches our builder. - -### 2.3 Outbound — `RaiseSkill` (`0x0046`) - -`u32 skillId (Skill enum) + u32 xpSpent`. -- ACE parse: `GameActionRaiseSkill.cs:10-11` — `(Skill)message.Payload.ReadUInt32()`, `ReadUInt32()`. -- holtburger: `RaiseSkillActionData` — `skill_type: u32, xp_spent: u32` — `actions.rs:67-94`. -- Chorizite: `Train_TrainSkill` reads `Skill = (SkillId)reader.ReadInt32()` (**signed** read) — - `Train_TrainSkill.generated.cs:30`. Same 4 bytes on the wire; interpretation differs (see §5). -Matches our builder. - -### 2.4 Outbound — `TrainSkill` (`0x0047`) - -`u32 skillId + i32 creditsSpent` (**signed**, unlike the other three actions' xp field). -- ACE parse: `GameActionTrainSkill.cs:10-11` — `(Skill)message.Payload.ReadUInt32()`, then - **`message.Payload.ReadInt32()`** (signed) for `creditsSpent`. -- holtburger: `TrainSkillActionData { skill_type: u32, credits_spent: i32 }`, unpacked with - `read_i32` — `actions.rs:97-116`, and its own unit test packs credits as - `write_i32::` — `actions.rs:277-299`. -- Chorizite: `Train_TrainSkillAdvancementClass.Credits` is declared `uint` and read via - `reader.ReadUInt32()` — `Train_TrainSkillAdvancementClass.generated.cs:23,31`. **Disagreement** with - ACE/holtburger's signed read — see §5. Byte layout is identical either way (credits are always - small positives on the wire). -Our builder (`BuildTrainSkill`, `CharacterActions.cs:51-60`) writes credits as `uint` — bytes are -correct; only the C# type differs from ACE's server-side signedness, which is harmless. - -### 2.5 Inbound — `PrivateUpdateAttribute` (`0x02E3`) - -``` -u32 opcode = 0x02E3 -u8 sequence // ByteSequence, see §3 -u32 attribute // PropertyAttribute (1=Strength..6=Self) -u32 ranks // CreatureAttribute.Ranks (uint) -u32 startingValue // CreatureAttribute.StartingValue == PropertiesAttribute.InitLevel (uint) -u32 experienceSpent // CreatureAttribute.ExperienceSpent == PropertiesAttribute.CPSpent (uint) -``` -21 bytes. ACE producer + field order: -`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateAttribute.cs:8-16` -(`Writer.Write(seq)` line 11 [via `GetNextSequence`, 1 byte — see §3], `(uint)Attribute` line 12, -`Ranks` line 13, `StartingValue` line 14, `ExperienceSpent` line 15). -All four fields are `uint` in `PropertiesAttribute`: -`references/ACE/Source/ACE.Entity/Models/PropertiesAttribute.cs:7-9`. - -Cross-check: Chorizite's `AttributeInfo` struct — `PointsRaised(u32), InnatePoints(u32), -ExperienceSpent(u32)` in that exact order — -`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/AttributeInfo.generated.cs:26-54` -(`PointsRaised`=Ranks, `InnatePoints`=StartingValue). holtburger's `UpdateAttribute` — -`sequence: u8, attribute: u32, ranks: u32, start: u32, xp: u32` — -`references/holtburger/crates/holtburger-protocol/src/messages/player/types.rs:22-53`. -**All three agree exactly**, field-for-field. - -### 2.6 Inbound — `PrivateUpdateVital` (`0x02E7`, "full" update) - -``` -u32 opcode = 0x02E7 -u8 sequence -u32 vitalId // PropertyAttribute2nd — ALWAYS the Max* id (1/3/5), see §2.9 -u32 ranks // CreatureVital.Ranks (uint) -u32 startingValue // CreatureVital.StartingValue (uint) -u32 experienceSpent // CreatureVital.ExperienceSpent (uint) -u32 current // CreatureVital.Current == PropertiesAttribute2nd.CurrentLevel (uint) -``` -25 bytes. ACE producer: -`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateVital.cs:8-18`. -Field source types: `references/ACE/Source/ACE.Entity/Models/PropertiesAttribute2nd.cs:7-10` -(all `uint`, including `CurrentLevel`). - -**IMPORTANT — this is ACE's `GameMessagePrivateUpdateVital`, but Chorizite's generated name for -the SAME opcode/bytes is `Qualities_PrivateUpdateAttribute2nd`** (not `...Vital`). Confirmed -identical layout: `Attribute: AttributeInfo (PointsRaised/InnatePoints/ExperienceSpent) + Current: u32` — -`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateAttribute2nd.generated.cs:14-55` -+ `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/SecondaryAttributeInfo.generated.cs:22-50`. -holtburger's `UpdateVital` (`PrivateUpdateVitalData`) matches field-for-field: -`sequence, vital, ranks, start, xp, current` — `types.rs:137-184`, with a golden-fixture test at -`types.rs:296-307` (`ranks:100, start:12345, xp:67890, current:100`). - -Our existing doc comment in `PrivateUpdateVital.cs:24-33` already has this layout right. -**No correction needed** to the existing `TryParseFull`. - -### 2.7 Inbound — `PrivateUpdateAttribute2ndLevel` (`0x02E9`, "current-only" delta) - -``` -u32 opcode = 0x02E9 -u8 sequence -u32 vitalId // Vital enum (ACE.Entity.Enum.Vital) — the NON-Max variant: Health=2/Stamina=4/Mana=6 -u32 current -``` -13 bytes. ACE producer: -`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateAttribute2ndLevel.cs:8-14`. - -Cross-check: Chorizite's `Qualities_PrivateUpdateAttribute2ndLevel` — `Sequence(byte), Key(u32, -`CurVitalId`), Value(u32)` — -`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateAttribute2ndLevel.generated.cs:14-55`. -holtburger's `UpdateVitalCurrent` (`PrivateUpdateVitalCurrentData`) matches: -`sequence, vital, current` — `types.rs:189-225`, fixture test at `types.rs:330-339`, and an explicit -raw-bytes unit test at `types.rs:377-397` (`0x0C` seq, `0x00000002` vital=Health, `0x00000064` -current=100) confirming the **current-only message's vital id is `Health`=2, not `MaxHealth`=1**. - -Our existing doc (`PrivateUpdateVital.cs:34-40`) already has this layout right, but its comment -"ACE Vital enum (1=MaxHealth..6=Mana)" doesn't disambiguate that this specific opcode (0x02E9) -**always** carries the non-Max id while 0x02E7 **always** carries the Max id — see §2.9 for the -mechanism. Worth tightening that comment when the attribute/skill parsers are added alongside it. - -### 2.8 Inbound — `PrivateUpdateSkill` (`0x02DD`) - -``` -u32 opcode = 0x02DD -u8 sequence -u32 skillId // Skill enum (0=None, ordinal-numbered — see §6 open question) -u16 ranks // CreatureSkill.Ranks == PropertiesSkill.LevelFromPP (ushort!) -u16 adjustPP // hardcoded constant = 1 on every send (see comment below) -u32 advancementClass // SkillAdvancementClass (CreatureSkill.AdvancementClass == PropertiesSkill.SAC) -u32 experienceSpent // CreatureSkill.ExperienceSpent == PropertiesSkill.PP (uint) -u32 initLevel // CreatureSkill.InitLevel == PropertiesSkill.InitLevel (uint) -u32 resistanceAtLastCheck // PropertiesSkill.ResistanceAtLastCheck (uint) -f64 lastUsedTime // PropertiesSkill.LastUsedTime (double, 8 bytes) -``` -37 bytes. ACE producer, with the `adjustPP` local hardcoded to `1` and the comment "If this is -not 0, it appears to trigger the initLevel to be treated as extra XP applied to the skill": -`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateSkill.cs:8-24`. -Field source types: `references/ACE/Source/ACE.Entity/Models/PropertiesSkill.cs:9-14` -(`LevelFromPP: ushort`, `SAC: SkillAdvancementClass`, `PP: uint`, `InitLevel: uint`, -`ResistanceAtLastCheck: uint`, `LastUsedTime: double`). - -Cross-check: Chorizite's `Skill` struct — `PointsRaised(u16), AdjustPP(u16), TrainingLevel(u32), -ExperienceSpent(u32), InnatePoints(u32), ResistanceOfLastCheck(u32), LastUsedTime(f64/double)` in -that exact order — -`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/Skill.generated.cs:22-86`. holtburger's -`UpdateSkill` matches too, reading ranks/adjustPP as `u16` then widening to its `u32` struct -field: `sequence, skill, ranks(u16), adjust_pp(u16), status, xp, init, resistance, last_used(f64)` — -`references/holtburger/crates/holtburger-protocol/src/messages/player/types.rs:71-131`, with a -golden fixture at `types.rs:279-293` (`ranks:50, adjust_pp:1, status:3, xp:1000, init:10, -resistance:0, last_used:0.0`) — confirms `adjustPP` is `1` in a real captured fixture, matching -ACE's hardcoded constant. -**All three agree exactly.** - -### 2.9 Inbound — `PrivateUpdatePropertyInt` (`0x02CD`) - -``` -u32 opcode = 0x02CD -u8 sequence -u32 propertyId // PropertyInt -i32 value -``` -13 bytes. ACE producer: -`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdatePropertyInt.cs:9-15`. -Cross-check: Chorizite's `Qualities_PrivateUpdateInt` — `Sequence(byte), Key(u32, PropertyInt), -Value(int32)` — -`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateInt.generated.cs:14-56`. -Matches. - -`PropertyInt.AvailableSkillCredits = 24` — -`references/ACE/Source/ACE.Entity/Enum/Properties/PropertyInt.cs:43`. - -### 2.10 Inbound — `PrivateUpdatePropertyInt64` (`0x02CF`) - -``` -u32 opcode = 0x02CF -u8 sequence -u32 propertyId // PropertyInt64 -i64 value -``` -17 bytes. ACE producer: -`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdatePropertyInt64.cs:8-14`. -Cross-check: Chorizite's `Qualities_PrivateUpdateInt64` matches -(`Sequence(byte), Key(u32), Value(int64)`) — -`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateInt64.generated.cs:14-56`. - -`PropertyInt64.AvailableExperience = 2` — -`references/ACE/Source/ACE.Entity/Enum/Properties/PropertyInt64.cs:14`. - -### 2.11 The Max-vs-current vital id split (mechanism) - -Two *different* enums, numerically identical, are used depending on message: - -- `PropertyAttribute2nd` (`references/ACE/Source/ACE.Entity/Enum/Properties/PropertyAttribute2nd.cs:8-14`): - `Undef=0, MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6`. This is what - `Creature.Vitals` is keyed by, and **only the Max* keys exist in that dictionary**: - `Vitals[PropertyAttribute2nd.MaxHealth] = ...` etc. — - `references/ACE/Source/ACE.Server/WorldObjects/Creature.cs:98-100`. So `HandleActionRaiseVital`'s - inbound `vital` parameter, and the id embedded in the full `PrivateUpdateVital` (0x02E7) message - (`creatureVital.Vital`), are **always one of {1, 3, 5}**. -- `Vital` (`references/ACE/Source/ACE.Entity/Enum/Vital.cs`): `Undefined=0, MaxHealth=1, Health=2, - MaxStamina=3, Stamina=4, MaxMana=5, Mana=6` — same numbering, used only for the current-only - message. `CreatureVital.ToEnum()` maps `MaxHealth→Vital.Health(2)`, `MaxStamina→Vital.Stamina(4)`, - `MaxMana→Vital.Mana(6)` — - `references/ACE/Source/ACE.Server/WorldObjects/Entity/CreatureVital.cs:203-212`, and - `UpdateVital()` sends `new GameMessagePrivateUpdateAttribute2ndLevel(this, vital.ToEnum(), - vital.Current)` — `references/ACE/Source/ACE.Server/WorldObjects/Player_Vitals.cs:139`. So the - 0x02E9 message's id is **always one of {2, 4, 6}**. - -Net: **a client-side parser must NOT assume a single id space for "vital" across both opcodes** — -0x02E7 always carries {1,3,5}, 0x02E9 always carries {2,4,6}. holtburger's raw-bytes unit test -(`types.rs:377-397`) is the clean confirming fixture for the 0x02E9 case (vital=2=Health). - ---- - -## 3. Sequence-number scheme - -Every "sequence" byte above is **1 byte**, produced by `ISequence.NextBytes` (returns `new byte[] -{ NextValue }`) — `references/ACE/Source/ACE.Server/Network/Sequence/ByteSequence.cs:30-45`. The -containing `GameMessage*` constructors call `Writer.Write(byte[])`, which writes the array's raw -bytes with **no length prefix**, so exactly 1 byte lands on the wire per sequence field. This -matches what our existing `PrivateUpdateVital.cs` doc already asserts ("single byte... not 4 bytes -despite some ACE writer signatures looking like uint"). - -**Per-(type, property) counters.** `SequenceManager.GetSequence(type, property)` keys its -dictionary by `(uint)type << 16 | property` — -`references/ACE/Source/ACE.Server/Network/Sequence/SequenceManager.cs:152-156` — so every distinct -`(SequenceType, propertyId)` pair gets its **own independent** counter. Concretely: raising -Strength and raising Endurance advance two different byte counters (`UpdateAttribute+1` vs -`UpdateAttribute+2`); raising two different skills advance two different counters; the -`AvailableExperience` counter (`UpdatePropertyInt64+2`) is independent of all attribute/skill/vital -counters. - -**Which `SequenceType` bucket each message uses, and its allocation strategy** -(`SequenceManager.GetSequence`, `:160-181`): - -| Wire message | `SequenceType` used | Bucket strategy | -|---|---|---| -| `PrivateUpdateAttribute` (0x02E3) | `UpdateAttribute` | falls to `default:` → `ByteSequence(false)` | -| `PrivateUpdateVital` (0x02E7, full) | `UpdateAttribute2ndLevel` | `default:` → `ByteSequence(false)` | -| `PrivateUpdateAttribute2ndLevel` (0x02E9, current) | `UpdateAttribute2ndLevel` | `default:` → `ByteSequence(false)` — **same bucket as the full message**, keyed by vital property, so 0x02E7 and 0x02E9 traffic for the *same vital* share one incrementing byte counter (see note below) | -| `PrivateUpdateSkill` (0x02DD) | `UpdateSkill` | `default:` → `ByteSequence(false)` | -| `PrivateUpdatePropertyInt` (0x02CD) | `UpdatePropertyInt` | `default:` → `ByteSequence(false)` | -| `PrivateUpdatePropertyInt64` (0x02CF) | `UpdatePropertyInt64` | `default:` → `ByteSequence(false)` | - -`ByteSequence(false)` means `clientPrimed=false` → `CurrentValue` starts at `maxValue` (255); the -first `NextValue` call detects `CurrentValue == maxValue`, resets to 0, and returns 0 — so **every -counter's first observed value is 0**, then increments 1, 2, 3, ... wrapping back to 0 after 255 -(`references/ACE/Source/ACE.Server/Network/Sequence/ByteSequence.cs:19-26,30-41`). None of the -`ObjectPosition/Movement/State/Vector/.../Motion` special-cased 16-bit sequence types apply here — -all six advancement-family sequence types fall through to the byte-sized default. - -**Shared counter caveat (0x02E7 vs 0x02E9):** `GameMessagePrivateUpdateVital`'s ctor calls -`GetNextSequence(SequenceType.UpdateAttribute2ndLevel, creatureVital.Vital)` (line 11 of that file) -— i.e. it uses the **same** `SequenceType.UpdateAttribute2ndLevel` bucket, keyed by the **same** -`PropertyAttribute2nd` vital value, as the current-only message's `GetNextSequence(..., -vital.ToEnum())` call. Because `Vital` (0x02E9's key type) and `PropertyAttribute2nd` (0x02E7's key -type) share numeric values 1:1, and `SequenceManager.GetSequence` takes a raw `uint property` -(`:27-30`, `:152`), a full 0x02E7 update for MaxHealth(1) and a current-only 0x02E9 tick for -Health(2) hit **different dictionary keys** (`key = type<<16 | 1` vs `type<<16 | 2`) — so they do -**not** actually share a counter across message types; each of the six (3 vitals × 2 opcodes) gets -its own independent byte sequence. (Correcting an initial read of mine: same `SequenceType` enum -value, but different `property` argument value, means different dictionary key. No cross-opcode -sharing.) - ---- - -## 4. Action → response mapping - -### 4.1 `RaiseAttribute` (0x0045) → `HandleActionRaiseAttribute` -(`references/ACE/Source/ACE.Server/WorldObjects/Player_Attributes.cs:13-73`) - -1. Validate: attribute exists, `amount <= AvailableExperience`. On failure: **no attribute update - sent**; if `SpendAttributeXp` itself fails, sends `GameMessageSystemChat` ("Your attempt to - raise {attr} has failed.", `ChatMessageType.Broadcast`) and returns. -2. `SpendAttributeXp` → `SpendXP(amount, sendNetworkUpdate=true)` → - **`PrivateUpdatePropertyInt64` (0x02CF)** with `AvailableExperience` (new value) — - `references/ACE/Source/ACE.Server/WorldObjects/Player_Xp.cs:352-363`. This fires on **every** - successful spend, rank-up or not. -3. **`PrivateUpdateAttribute` (0x02E3)** — always sent on success, rank-up or not - (`Player_Attributes.cs:35`). -4. If `prevRank != creatureAttribute.Ranks` (i.e. an actual rank-up occurred): - - if max rank reached: a particle-effect broadcast (`PlayParticleEffect`, not a private - GameMessage to this session) and a `" and has reached its upper limit"` suffix. - - **`GameMessageSound`** (`Sound.RaiseTrait`) + **`GameMessageSystemChat`** - ("Your base {attribute} is now {Base}{suffix}!", `ChatMessageType.Advancement`) — both sent - together (`Player_Attributes.cs:48-51`). - - if `attribute == Endurance`: an **extra `PrivateUpdateVital` (0x02E7) full update for - Health** (`Player_Attributes.cs:56-58`) — comment states this "appears to trigger client to - update both health and stamina" client-side, even though **only Health's full record is sent - on the wire, not Stamina's**. A client parser needs to recompute both Health and Stamina - derived display values from this single Health packet if it wants to match retail's stated - behavior — this is a genuine, non-obvious retail/ACE quirk, not a bug to "fix". - - if `attribute == Self`: an extra `PrivateUpdateVital` (0x02E7) full update for **Mana** - (`Player_Attributes.cs:60-64`). - - if `attribute` is `Strength` or `Quickness` and the `runrate_add_hooks` property is on: - `HandleRunRateUpdate()` (affects run-speed derived state, not itself a GameMessage here). - -Ordered wire sequence for a successful Endurance raise that ranks up: -`PrivateUpdatePropertyInt64(AvailableExperience)` → `PrivateUpdateAttribute(0x02E3)` → -`GameMessageSound` + `GameMessageSystemChat` (same `EnqueueSend` call, so adjacent) → -`PrivateUpdateVital(0x02E7, Health)`. - -### 4.2 `RaiseVital` (0x0044) → `HandleActionRaiseVital` -(`references/ACE/Source/ACE.Server/WorldObjects/Player_Vitals.cs:20-65`) - -1. Validate: vital exists in `Vitals` (so **must** be keyed by a Max* id — see §2.9), - `amount <= AvailableExperience`. On failure (amount check): sends - `GameMessageSystemChat` ("Your attempt to raise {vital} has failed.", - `ChatMessageType.Broadcast`) — **note ACE's own comment**: "there is a client bug for vitals - only, where the client will enable the button to raise a vital by 10 if the player only has - enough AvailableExperience to raise it by 1" (`Player_Vitals.cs:30-33`) — a known retail client - quirk worth preserving if we're porting client-side gating logic, not "fixing". -2. `SpendVitalXp` → `SpendXP` → **`PrivateUpdatePropertyInt64` (0x02CF)** `AvailableExperience`. -3. **`PrivateUpdateVital` (0x02E7)** full update — always sent on success (`Player_Vitals.cs:46`). -4. If rank-up: max-rank particle effect + suffix; **`GameMessageSound`(RaiseTrait)** + - **`GameMessageSystemChat`** ("Your base {vital} is now {Base}{suffix}!", - `ChatMessageType.Advancement`) (`:59-62`). **No cross-vital side effect** here (unlike - RaiseAttribute's Endurance/Self special cases) — raising a vital directly only ever touches that - one vital's full record. - -### 4.3 `RaiseSkill` (0x0046) → `HandleActionRaiseSkill` -(`references/ACE/Source/ACE.Server/WorldObjects/Player_Skills.cs:21-67`) - -1. Validate: creature skill exists and `AdvancementClass >= Trained` (untrained skills can't be - raised by XP directly), `amount <= AvailableExperience`. Failure: silent (`log.Warn` only, - **no chat message sent** — unlike RaiseAttribute/RaiseVital's failure paths, `HandleActionRaiseSkill` - does not send `GameMessageSystemChat` on the "trained/specialized skill not found" or - "amount > AvailableExperience" branches, `:27-35`). -2. `SpendSkillXp` → `SpendXP` → **`PrivateUpdatePropertyInt64` (0x02CF)** `AvailableExperience`. -3. **`PrivateUpdateSkill` (0x02DD)** — always sent on success (`Player_Skills.cs:42`). -4. If rank-up: max-rank particle effect + suffix; **`GameMessageSound`(RaiseTrait)** + - **`GameMessageSystemChat`** ("Your base {skill} skill is now {Base}{suffix}!", - `ChatMessageType.Advancement`) (`:56-59`). If `skill == Run`: `HandleRunRateUpdate()` (same - run-speed hook as RaiseAttribute's Strength/Quickness case, gated by the same - `runrate_add_hooks` property). - -### 4.4 `TrainSkill` (0x0047) → `HandleActionTrainSkill` -(`references/ACE/Source/ACE.Server/WorldObjects/Player_Skills.cs:114-153`) - -1. Validate: `creditsSpent <= AvailableSkillCredits`; skill base exists in the DAT skill table; - **`creditsSpent` must exactly equal `skillBase.TrainedCost`** (server re-derives the cost from - the DAT and rejects any client-sent value that doesn't match — `:129-133`). Any validation - failure: silent server-side `log.Warn`, **no GameMessage sent at all** (function returns before - reaching the `success`/`else` branch that sends anything). -2. Calls `TrainSkill(skill, creditsSpent)` (a different overload, `:171-197`) which sets - `AdvancementClass = Trained`, resets `Ranks`/`InitLevel`/`ExperienceSpent`, and debits - `AvailableSkillCredits -= creditsSpent` **in-process** (no network send inside this inner - method — the caller sends the messages). -3. On success: - - **`PrivateUpdateSkill` (0x02DD)** for the now-trained skill. - - **`PrivateUpdatePropertyInt` (0x02CD)** `AvailableSkillCredits` (new value). - - **`GameMessageSystemChat`** ("{skill} trained. You now have {N} credits available.", - `ChatMessageType.Advancement`). - - All three sent together in one `EnqueueSend(updateSkill, skillCredits, msg)` call - (`:145-147`) — **no `GameMessageSound` here**, unlike Raise*'s rank-up path. - - **Note: `TrainSkill` does NOT send a `PrivateUpdatePropertyInt64` (AvailableExperience) - update** — training costs skill credits, not XP, so `AvailableExperience` is untouched. -4. On failure of the inner `TrainSkill(skill, creditsSpent)` call (only reachable if - `AdvancementClass >= Trained` already, i.e. re-training an already-trained/specialized skill, or - a credits race): **`GameMessageSystemChat`** ("Failed to train {skill}! You now have {N} credits - available.", `ChatMessageType.Advancement`) — **no skill/credit update messages** on this path. - -### 4.5 Specialize / Untrain / Unspecialize / Reset — no dedicated opcode - -Confirmed by exhaustive scan of `GameActionType` (`references/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs`) -— there is **no** `Specialize`/`Untrain`/`ResetSkill`/`ResetAttribute` entry in the 0x00xx–0x03xx -GameAction range. `Player_Skills.cs` has server-side methods `SpecializeSkill`, `UntrainSkill`, -`UnspecializeSkill`, `ResetSkill` (`:199-309`, `:865-921`) but **none of them carry a -`[GameAction]` attribute** — they're invoked from other systems, not directly from a client -opcode: - -- **`SkillAlterationDevice`** (Gem of Enlightenment = specialize, Gem of Forgetfulness = lower) — - the item's `ActOnUse` is reached via the **generic `Use` action (`0x0036`)**, not a - progression-specific opcode - (`references/ACE/Source/ACE.Server/WorldObjects/SkillAlterationDevice.cs:58-103`). It then routes - through a **confirmation dialog** (`ConfirmationManager.EnqueueSend(new - Confirmation_AlterSkill(...))`, `:96`) — a separate ask/confirm wire round-trip (client replies - via `ConfirmationResponse = 0x0275`), not part of this feature's direct action→response chain. - Once confirmed, `AlterSkill` (`:169-230`) sends, per branch: - - Specialize success: **`PrivateUpdateSkill`(0x02DD)** + **`PrivateUpdatePropertyInt`(0x02CD, - AvailableSkillCredits)** + a **`GameEventWeenieErrorWithString`** (GameEvent sub-type - `0x028B`, inside the `0xF7B0` GameEvent envelope — a different envelope from the plain - `GameMessage` family used everywhere else in this doc) carrying - `YouHaveSucceededSpecializing_Skill` + the skill name (`:176-185`). Layout of - `GameEventWeenieErrorWithString`: `u32 errorType + String16L message` — - `references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventWeenieErrorWithString.cs:7-13`. - - Lower (specialized→trained via `UnspecializeSkill`) or (trained→untrained via `UntrainSkill`) - success: same two `PrivateUpdateSkill` + `PrivateUpdatePropertyInt` pair, with a different - `WeenieErrorWithString` message id per exact transition (`:196-227`). -- **`Enlightenment`** (100th-level Fianhe reset path) directly manipulates `player.Vitals[attribute]` - and sends `GameMessagePrivateUpdateVital` — `references/ACE/Source/ACE.Server/Entity/Enlightenment.cs:218-220` — - again reached through a non-progression trigger (an enlightenment/temple interaction, not a - GameAction opcode of its own). -- Generic **`ResetSkill`** (`Player_Skills.cs:865-921`) sends **`PrivateUpdateSkill`(0x02DD)** + - **`PrivateUpdatePropertyInt`(0x02CD, AvailableSkillCredits)** + **`GameMessageSystemChat`** - (plain chat text built in C#, not a `WeenieErrorWithString` id) — same three-message shape as - TrainSkill's success path, minus the `GameMessageSound`. - -**Answer to "is there an UNTRAIN or specialize action opcode":** No. Retail/ACE routes all -specialize/lower/reset flows through item-Use (`0x0036`) + a confirmation round-trip, or through -non-player-initiated systems (Enlightenment). If CA1 needs to support these, the response-message -shapes above (2× GameMessage + 1× GameEvent, all reusing opcodes already covered in §2) are -sufficient — no new opcode needs to be learned, but the **GameEvent envelope (`0xF7B0`) and its -sub-opcode framing** are a different code path from the plain `GameMessage` opcodes this doc -otherwise covers, and weren't otherwise in scope for CA1's four core actions. - ---- - -## 5. Disagreements between references - -1. **`TrainSkill` credits field signedness.** ACE's handler reads it with - `message.Payload.ReadInt32()` (signed) — `GameActionTrainSkill.cs:11` — and holtburger's - `TrainSkillActionData.credits_spent` is `i32`, unpacked with `read_i32` — `actions.rs:99,109`. - Chorizite's `Train_TrainSkillAdvancementClass.Credits` is declared `uint` and read via - `reader.ReadUInt32()` — `Train_TrainSkillAdvancementClass.generated.cs:23,31`. **Genuine - type-level disagreement** (ACE+holtburger say signed, Chorizite says unsigned). No practical - wire effect since credits are always small non-negative values, but if CA1 code asserts a type, - prefer **signed `int`** to match the two independent oracles (ACE server source + a working - client) over the generated Chorizite stub. -2. **Message class naming for opcode `0x02E7`.** ACE calls it `GameMessagePrivateUpdateVital` - (full vital record). Chorizite's generator calls the identical-opcode, identical-byte-layout - type `Qualities_PrivateUpdateAttribute2nd`. Not a byte-level disagreement — purely a naming - collision that could cause confusion when grepping Chorizite for "Vital" and finding nothing (as - happened during this research pass — see §"open questions" for how this was discovered). If our - own parser code introduces names, prefer ACE's naming (`PrivateUpdateVital`) since that's also - what our existing `PrivateUpdateVital.cs` already uses. -3. **`RaiseSkill`/`TrainSkill` skill-id read signedness.** ACE reads skill ids for `RaiseSkill` as - `(Skill)message.Payload.ReadUInt32()` (unsigned) but for `TrainSkill` conceptually the same - field is also `(Skill)message.Payload.ReadUInt32()` — **both unsigned** on the ACE side - (`GameActionRaiseSkill.cs:10`, `GameActionTrainSkill.cs:10`). Chorizite reads **both** as - `(SkillId)reader.ReadInt32()` (signed) — `Train_TrainSkill.generated.cs:30`, - `Train_TrainSkillAdvancementClass.generated.cs:30`. holtburger's `RaiseSkillActionData.skill_type` - is `u32` (unsigned) — `actions.rs:69`, `:78`; its `TrainSkillActionData.skill_type` is also `u32` - (only `credits_spent` is signed) — `actions.rs:98,107`. So Chorizite is the outlier reading - skill id as signed on **both** actions; ACE+holtburger agree it's unsigned. Byte-identical - either way since valid skill ids are small positive ordinals (§6 notes the exact numbering isn't - pinned in this pass). -4. **No disagreement found** on any of the six inbound message byte layouts (§2.5–§2.10) — ACE, - Chorizite, and holtburger's independent implementations agree field-for-field, including the - unusual `ushort ranks / ushort adjustPP` pair inside `PrivateUpdateSkill` and the hardcoded - `adjustPP=1` constant (confirmed present in a holtburger golden-fixture test, not just inferred - from ACE source). - ---- - -## 6. Open questions / not settled from the references - -1. **`PrivateUpdateSkillLevel` (`0x02DF`) / `PublicUpdateSkillLevel` (`0x02E0`) producer not - found.** The opcode exists in ACE's `GameMessageOpcode` enum and holtburger models a full - `UpdateSkillLevel` struct for it (`types.rs:230-269`, with fixture tests at `:353-375`), but I - did not find any ACE call site constructing a `GameMessagePrivateUpdateSkillLevel` (in fact, no - such class file exists in ACE's `GameMessages/Messages/` directory at all — only - `GameMessagePrivateUpdateSkill.cs`, the *full*-opcode 0x02DD one). Either (a) ACE genuinely never - emits this ranks-only delta and it's vestigial/retail-only, or (b) a producer exists elsewhere I - didn't search (e.g. a bulk/batch skill-sync path outside `Player_Skills.cs` and - `SkillAlterationDevice.cs`). **Not required for CA1's four actions** (none of their response - chains use it), but flag before assuming the opcode is dead — a targeted `grep -r - PrivateUpdateSkillLevel references/ACE` beyond what this pass covered would settle it. -2. **Exact numeric `Skill` enum ordinals** were not fully enumerated — I confirmed the enum is - ordinal-numbered starting at `None=0` with several `/* Retired */`/`/* Unimplemented */` gaps - preserved in-place (`references/ACE/Source/ACE.Entity/Enum/Skill.cs:11-40+`), and holtburger's - test comments corroborate two spot values (`6 = MeleeDefense`, `14 = ArcaneLore`, - `references/holtburger/crates/holtburger-protocol/src/messages/player/actions.rs:270,283`), but - I did not transcribe the full ~60-entry table. If acdream doesn't already have a `Skill` enum - under `src/AcDream.Core`, the CA1 implementer should port the full ordinal list directly from - `references/ACE/Source/ACE.Entity/Enum/Skill.cs` verbatim (order matters — it's explicitly - documented as never-reorderable). -3. **`Sound.RaiseTrait`'s numeric id** and **`ChatMessageType.Advancement`'s numeric id** were not - looked up (both are straightforward enum lookups in `references/ACE/Source/ACE.Entity/Enum/` if - needed for exact-byte test fixtures). -4. **Whether raising a vital's rank ever auto-adjusts `Current`** wasn't traced end-to-end. The - full `PrivateUpdateVital` message always includes `creatureVital.Current` as one of its fields - (§2.6), but `HandleActionRaiseVital`/`SpendVitalXp` only touch `Ranks`/`ExperienceSpent` — I did - not find code in that call chain that tops up `Current` to match a newly raised `MaxValue`. If - retail visibly bumps current HP/stamina/mana immediately on a vital raise, that adjustment (if - any) happens somewhere outside the files read in this pass (possibly the vital heartbeat tick - picking it up on the next 5s cycle, given `VitalHeartBeat()` exists as a separate mechanism — - `Player_Vitals.cs:196-205`). Confirm with a live capture before assuming instant top-up. - ---- - -# CA1 — retail attribute/skill/vitals recompute chain - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR -build, PDB-named) + `acclient.h` (verbatim retail structs) + Ghidra MCP clean -decompiles (`patchmem.gpr`, same binary) for every function relied on below. -Cross-checked against `references/ACE/Source/ACE.Server` and -`references/ACE/Source/ACE.DatLoader`. All addresses are file offsets == RVA -(x86, base 0x400000 not added — matches the pseudo-C left column). - -**Headline verdict (drives the whole CA1 design): the retail client computes -skills, vitals maxima, and run rate LIVE, at inquiry time, from raw stored -attribute/skill data. There is no cached "effective skill" field and no -explicit recompute-cascade when an attribute changes. UI refresh is a -separate, decoupled observer mechanism (`QualityRegistrar`) that only tells a -widget "id X changed, re-pull it" — it never carries the new value.** - ---- - -## 1. Inbound handler map - -All inbound private/public quality-update wire messages funnel through one -C++ template, `ClientObjMaintSystem::::UpdateStat`, -instantiated once per (QualityType, wire-value-shape) pair. The **public** -handlers (`Handle_Qualities__Update*`, updates for an arbitrary observed -weenie) and the **private** handlers (`Handle_Qualities__PrivateUpdate*`, -always target the local player via `SmartBox::smartbox->player_id`) both -funnel into the same template body — the private ones just supply -`player_id` instead of a wire-carried target id. - -Confirmed handler entry points (`ClientObjMaintSystem::Handle_Qualities__*`): - -| Handler | Address | Wire value shape | Delegates to | -|---|---|---|---| -| `UpdateAttribute` | `0x00558c80` | full `Attribute` struct | `UpdateStat` | -| `UpdateAttributeLevel` | `0x00558ca0` | raw `unsigned long` | `UpdateStat` | -| `UpdateAttribute2nd` | `0x00558cc0` | full `SecondaryAttribute` struct | `UpdateStat` | -| `UpdateAttribute2ndLevel` | `0x00558ce0` | raw `unsigned long` | `UpdateStat` | -| `UpdateSkill` | `0x00558d00` | full `Skill` struct | `UpdateStat` | -| `UpdateSkillLevel` | `0x00558d20` | raw `unsigned long` | `UpdateStat` | -| `UpdateSkillAC` | `0x00558d40` | `SKILL_ADVANCEMENT_CLASS` enum | `UpdateStat` | -| `PrivateUpdateAttribute` | `0x00558e80` | full `Attribute` struct, target = local player | same template, `player_id` forced | -| `PrivateUpdateAttributeLevel` | `0x00558eb0` | raw ulong, target = local player | " | -| `PrivateUpdateAttribute2nd` | `0x00558ee0` | full struct, target = local player | " | -| `PrivateUpdateAttribute2ndLevel` | `0x00558f10` | raw ulong, target = local player | " | -| (Skill private variants follow the identical pattern at nearby addresses in the same block, `0x00558f4x`–`0x00558fcx`) | | | | - -Body of every `UpdateStat` instantiation for Attribute/Attribute2nd/Skill -(example, the raw-`unsigned long` Attribute overload, `0x00557d00`): - -``` -UpdateStat(this, qualityId, timestampByte, targetWeenieId, newValue): - weenie = CObjectMaint::GetWeenieObject(this, targetWeenieId) - if weenie == null: return 0 - if !ACCWeenieObject::SetupStamper(weenie): return 0 // per-object dedupe/anti-replay stamp - if !WTimeStamper::UpdateTS(weenie.stamper, qualityId | 0x80000, timestampByte): - return 0 // stale/out-of-order update, drop - qualities = weenie.pQualities // CACQualities* at weenie+0x14c - if qualities != null: - qualities.SetAttribute(qualityId, newValue) // <-- PURE RAW STORAGE WRITE (§below) - ACCWeenieObject::OnStatUpdated(weenie, qualityId, newValue) // only on the *raw-value* overloads (see caveat) - if QualityRegistrar::s_pQR != null: - QualityRegistrar::s_pQR.CallChangeHandler(weenie, Attribute_StatType, qualityId) // <-- THE UI notify (§7) - return 0 -``` - -Every `SetX` call (`CACQualities::SetAttribute` `0x00591a50`/`0x00591aa0`, -`SetAttribute2nd` `0x00592530`/`0x005925f0`/`0x00592cf0`, `SetSkill` -`0x00592240`, `SetSkillLevel` `0x00592340`, `SetSkillAdvancementClass` -`0x00592430`) was decompiled directly and **each one is a pure raw-storage -write** — no recompute, no dirty flag, no dependent-field touch: - -- `SetAttribute` → lazily allocates `AttributeCache` if absent, then - `AttributeCache::SetAttribute(id, {init_level, level_from_cp, cp_spent})` - overwrites those three fields verbatim (`0x005cc740`). -- `SetSkill`/`SetSkillLevel` → hash-table upsert into - `CACQualities::_skillStatsTable` (`PackableHashTable`), - copying `_sac, _pp, _init_level, _level_from_pp, - _resistance_of_last_check, _last_used_time` verbatim. -- `SetSkillAdvancementClass` → same hash table, writes only `_sac` via - `Skill::SetSkillAdvancementClass`. - -**`ACCWeenieObject::OnStatUpdated` caveat (OPEN, non-load-bearing):** two -concrete overload bodies were found in the binary, `0x0058c680` (switches on -PropertyBool ids: Stuck=1, Openable=3, Inscribable=0x16, UIHidden=0x18, -CellBarrierImmune=0x19, HiddenAdmin=0x1a) and `0x0058df20` (switches on -PropertyInt ids: Type=1, Priority=4, ItemsCapacity=6, …, HookItemTypes=0x98). -**Neither switch has a case matching the Attribute/Attribute2nd/Skill id -space** (1–6, 0x1f–0x32), so whichever of the two the compiler resolved the -raw-scalar `UpdateStat` call to, it is a silent no-op for attribute/skill ids -— this function does **not** participate in character-advancement recompute. -I could not pin the exact overload resolved at each of the 7 call sites -(BinaryNinja's rendered parameter types are ambiguous between `int32_t` and -`long` at this ABI); it doesn't change the answer to Q1, so left open. - -**Verdict for Q1: an inbound stat update writes the raw number/struct -straight into `CACQualities`'s storage and fires one generic -`QualityRegistrar::CallChangeHandler(weenie, StatType, id)` notification. -It never calls anything that resembles "recompute skill" or "recompute -vitals max" — those are computed by the *reader*, not the writer.** - ---- - -## 2. The skill formula — LIVE at inquiry time (not cached) - -### Call chain - -`CACQualities::InqSkill(this, skillId, out int, includeRaw)` @ `0x00592660` -(the "effective" overload; there's also a bare struct-copy `InqSkill` -overload at `0x005919df` unrelated to computation) calls -`InqSkillBaseLevel` @ `0x00592140` first, then layers bonuses on top. - -**`InqSkillBaseLevel`** (attribute contribution — the "base" the game shows -before augmentations): - -``` -InqSkillBaseLevel(this, skillId, out value, rawAttrFlag): - skillTable = DBObj::GetByEnum(4, 2, 0x10000004) // the portal.dat SkillTable singleton - if skillTable == null: return 0 - base = skillTable.GetSkillBase(skillId) // SkillBase record: _min_level, _formula, costs... - if base == null: return 0 - - sac = UNTRAINED - entry = this._skillStatsTable?.lookup(skillId) - if entry != null: sac = entry._sac - - if (int)sac < (int)base._min_level: // gate: skill not usable at this SAC yet - value = 0 - return 1 // NOTE: still returns success=1, value forced 0 - - a1 = 0; a2 = 0 - if base._formula._attr1 != 0: InqAttribute(this, base._formula._attr1, &a1, rawAttrFlag) - if base._formula._attr2 != 0: InqAttribute(this, base._formula._attr2, &a2, rawAttrFlag) - return SkillFormula::Calculate(base._formula, a1, a2, &value) -``` - -**`SkillFormula::Calculate`** @ `0x00591960` — the actual math, verbatim -(struct `SkillFormula { uint _w, _x, _y, _z, _attr1, _attr2; }`, -`acclient.h:40199`): - -``` -Calculate(formula, attr1Value, attr2Value, out result): - if formula._z == 0: return 0 // divisor 0 => invalid formula, caller treats as failure - numerator = formula._x * attr1Value + formula._y * attr2Value + formula._w - result = floor((float)numerator / (float)formula._z + 0.5) // round-half-up via floor(x+0.5) - return 1 -``` - -**`InqAttribute(this, attrId, out value, rawFlag)`** @ `0x005919d0`/`0x00591a00` -— the value that feeds the formula is itself live: - -``` -InqAttribute(this, attrId, out value, rawFlag): - if this._attribCache == null: return 0 - if !AttributeCache::InqAttribute(this._attribCache, attrId, &value): return 0 - if rawFlag == 0: - EnchantAttribute(this, attrId, &value) // CEnchantmentRegistry::EnchantAttribute — live active-spell lookup - return 1 -``` - -`AttributeCache::InqAttribute` (`0x005cc4e0`) is a flat per-attribute struct -store (`_strength/_endurance/_quickness/_coordination/_focus/_self`, each a -heap `Attribute{_init_level,_level_from_cp,_cp_spent}`); it returns -`_init_level + _level_from_cp` — exactly the two fields `SetAttribute` -overwrites on every wire update. **There is no third place that stores a -"current effective attribute" — the value that comes back is always the -freshly-stored number plus whatever `CEnchantmentRegistry` says right now.** - -### Back in `InqSkill` (`0x00592660`), after the base level: - -``` -InqSkill(this, skillId, out value, rawFlag): - if !InqSkillBaseLevel(this, skillId, &value, rawFlag): return 0 - - entry = this._skillStatsTable?.lookup(skillId) - if entry != null: value += entry._level_from_pp + entry._init_level // trained ranks + chargen bonus - - // PropertyInt 0x16d = LumAugAllSkills — flat, unconditional, ALWAYS applied (even rawFlag==1) - if InqInt(0x16d, &bonus) && bonus > 0: value += bonus - - // category-gated flat +10, keyed by skill id -> which "AugmentationSkilledX" PropertyInt to check - // skill in {0x1f,0x20,0x21,0x22,0x2b} -> PropertyInt 0x12e (302, AugmentationSkilledMagic) - // skill in {0x29,0x2c,0x2d,0x2e,0x31} -> PropertyInt 0x12c (300, AugmentationSkilledMelee) - // skill == 0x2f -> PropertyInt 0x12d (301, AugmentationSkilledMissile) - if augBonusInt > 0: value += 10 - - if rawFlag == 0: // "raw"==0 means "give me the effective/enchanted value" - EnchantSkill(this, skillId, &value) // CEnchantmentRegistry::EnchantSkill — live spell buffs + VITAE live here - // PropertyInt 0x146 (326, AugmentationJackOfAllTrades) -- flat +5, added AFTER enchant/vitae - if InqInt(0x146, &b2) && b2 > 0: value += 5 - // PropertyInt 0x158 (344, LumAugSkilledSpec) -- doubled, SPECIALIZED-only, added AFTER enchant/vitae - InqInt(0x158, &b3) - if b3 > 0 and entry?._sac == SPECIALIZED: - value += b3 * 2 - return 1 -``` - -`EnchantSkill`/`EnchantAttribute`/`EnchantAttribute2nd` (`0x0058f0b0` / -`0x0058f070` / `0x0058f090`) are one-line delegates to -`CEnchantmentRegistry::EnchantSkill/EnchantAttribute/EnchantAttribute2nd` — -the client's live table of currently-active spell effects. Nothing about -these calls is memoized; they walk the registry fresh every call. - -### Verdict - -**LIVE, confirmed by direct trace, not inference.** An attribute raise -(`SetAttribute` overwriting `AttributeCache`) requires **zero** explicit -follow-up to make skills reflect it — the next `InqSkill`/`InqSkillBaseLevel` -call re-reads `AttributeCache` and re-runs `SkillFormula::Calculate`. Same -for a spell buff landing (`CEnchantmentRegistry` update) or an SAC change. -**CA1 should port this as a pure function `Skill.Current(attributes, trainedState, activeEnchantments, augmentations, vitae) -> uint`, called on demand by every UI/gameplay reader — not as a cached field refreshed by an event handler.** - ---- - -## 3. Vitals maxima (MaxHealth/MaxStamina/MaxMana) - -Same formula engine, different DBObj table. `CACQualities::InqAttribute2nd` -overload with `(id, out uint, rawFlag)` @ `0x00592020`: - -``` -InqAttribute2nd(this, id, out value, rawFlag): - base = 0 - if id in {1,3,5}: // 1=MaxHealth,3=MaxStamina,5=MaxMana (odd ids; ACE PropertyAttribute2nd matches exactly) - if !InqAttribute2ndBaseLevel(this, id, &base, rawFlag): return 0 - - if id == 1: // MaxHealth only - if InqInt(0x17b /* 379 = PropertyInt.GearMaxHealth */, &gear) && gear: - base += gear // flat item-granted Max Health bonus, added to "base" tier - - if this._attribCache != null and AttributeCache::InqAttribute2nd(cache, id, &cached) != 0: - value = cached + base // "current" ids (2,4,6) hit ONLY this branch: cached holds the live current HP/SP/mana, base==0 for those ids - else: - if base == 0: return 0 - value = base - - if rawFlag == 0: - EnchantAttribute2nd(this, id, &value) // live spell buffs on the vital (max or current) - return 1 -``` - -`InqAttribute2ndBaseLevel` @ `0x00591d20` mirrors `InqSkillBaseLevel` -exactly but reads from a different global singleton, -`DBObj::GetByEnum(1, 2, 0x10000003)` = the `Attribute2ndTable` DBObj -(`struct Attribute2ndTable { Attribute2ndBase _max_health, _max_stamina, _max_mana; }`, -`acclient.h:40216`, each an `Attribute2ndBase{ SkillFormula _formula; }`) — -id 1→`_max_health`, 3→`_max_stamina`, 5→`_max_mana`, feeding the **same** -`SkillFormula::Calculate` used for skills. Concretely: MaxHealth/MaxStamina -are driven by the Endurance-weighted formula and MaxMana by the Self-weighted -formula, but the exact `_attr1/_attr2/_w/_x/_y/_z` values are DAT data, not -hardcoded in this function — the mechanism is confirmed, the numeric -coefficients were not independently dumped (OPEN, low-value: any live -`portal.dat` read or ACE's `SecondaryAttributeTable` gives them for free). - -Note the asymmetry: **odd ids (max) run the formula; even ids (current -Health/Stamina/Mana) are pure `AttributeCache` reads** (server-pushed current -value), never formula-derived. `Attribute2nd` id 2/4/6 in `AttributeCache` -is exactly what `PrivateUpdateAttribute2ndLevel` (`0x00558f10`) overwrites. - -### Bounds enforcement on the *current* value - -`CACQualities::BoundsCheck` @ `0x005920e0`, called from the -`SetAttribute2nd(id, rawValue, ...)` wrapper (`0x005925f0`) whenever id is -one of the *current* ids (2,4,6): - -``` -BoundsCheck(this, id, ref value, out maxOut): - if id not in {2,4,6}: return 1 // only current HP/SP/mana are clamped - if value < 0: value = 0; return 1 - if !InqAttribute2nd(this, id-1, &maxOut, rawFlag=0): return 0 // id-1 = the paired max id; LIVE lookup - if maxOut < value: value = maxOut - return 1 -``` - -So the current-value SET path itself pulls a **live** Max via -`InqAttribute2nd` to clamp — one more confirmation there's no cached max -anywhere the client trusts. - -### UI refresh trigger for the vitals bar (ties into §7) - -`Attribute2ndInfoRegion::Attribute2ndInfoRegion` (`0x004f1680`) — the vitals -bar row widget's ctor — registers **three or more** separate -`QualityRegistrar::RegisterQualityHandlerForThePlayer(Attribute_2nd_StatType=9, id, this)` -subscriptions: - -1. `m_CurAttribute` (e.g. Health=2) -2. `m_MaxAttribute` (`= m_CurAttribute - 1`, e.g. MaxHealth=1) -3. It then reads the `Attribute2ndTable` record for the max id - (`Attribute2ndTable::InqAttribute2ndBase`) and, **if the formula's - `_attr1`/`_attr2` are non-zero, ALSO registers for those ids** — under - `StatType 9` again, even though the contributing attribute (e.g. - Endurance) is a base `Attribute` that only ever fires - `CallChangeHandler(Attribute_StatType=8, ...)`. This third registration - therefore looks structurally dead/vestigial for the base-attribute case — - see the note at the end of §7. It doesn't change the answer to "what - triggers the bar to redraw": the confirmed, load-bearing triggers are (1) - and (2), i.e. **the vitals bar redraws when the SERVER pushes an explicit - `PrivateUpdateAttribute2nd`/`...Level` wire message for that specific - current-or-max id — not from a local recompute cascade.** - -### Verdict for Q3 - -Formula: **live**, same mechanism/engine as skills (`SkillFormula::Calculate` -over the `Attribute2ndTable` DAT record), confirmed by direct trace. -Trigger: **the vitals bar's redraw is driven by `QualityRegistrar` -notifications tied 1:1 to the wire ids the server actually pushes** (own -current id + own max id). The client is fully capable of computing MaxHealth -locally from Endurance without any server push (§2's live chain applies -here too), but the UI's *redraw* event only fires on an explicit wire update -to that exact (StatType, id) — so **retail's server is the one that decides -when to recompute-and-push an updated MaxHealth/MaxStamina/MaxMana whenever -the underlying attribute changes; the client formula exists for the value -itself, not as the UI's dirty-trigger.** CA1 must mirror this: acdream's -server-authoritative path (ACE) is expected to push its own recomputed -vitals-max update on an Endurance/Self change — verify ACE actually does -this (see §8) rather than relying on a purely-local recompute-on-attribute- -change UI hook, or the vitals bar will silently stop matching retail's -redraw cadence even though the *number* would still be correct next time -anything else forces a redraw. - ---- - -## 4. Run rate — LIVE, re-derived every motion tick (not event-driven at all) - -`CACQualities::InqRunRate` @ `0x00592800` **inlines its own copy** of the -InqSkill formula chain for skill id `0x18` (Run) rather than calling -`InqSkill` — presumably to add one extra rule: - -``` -InqRunRate(this, out rate): - if !InqLoad(this, &loadFactor): return 0 // encumbrance multiplier, 1.0 = unencumbered - if this._attribCache == null: return 0 - if !AttributeCache::InqAttribute2nd(cache, 4 /*Stamina, current*/, &curStamina): return 0 - EnchantAttribute2nd(this, 4, &curStamina) // live buffs on current Stamina - if !InqSkillBaseLevel(this, 0x18, &runSkill, rawFlag=0): return 0 - - entry = _skillStatsTable?.lookup(0x18) - if entry: runSkill += entry._level_from_pp + entry._init_level - if InqInt(0x16d) > 0: runSkill += bonus // LumAugAllSkills - EnchantSkill(this, 0x18, &runSkill) // live spell buffs + vitae - if InqInt(0x146) > 0: runSkill += 5 // JackOfAllTrades - if InqInt(0x158) > 0 and entry?._sac == SPECIALIZED: runSkill += InqInt(0x158) * 2 // LumAugSkilledSpec - - if curStamina == 0: // <-- FATIGUE RULE, Run-specific - runSkill = 0 // exhausted (0 current Stamina) => cannot use Run skill at all - - rate = MovementSystem::GetRunRate(loadFactor, runSkill, 1.0) - return 1 -``` - -`InqMaxRunRate` @ `0x00591b20` is just -`MovementSystem::GetRunRate(0.0 /* full load */, 9999 /* max skill */, 1.0)` -— a theoretical ceiling, not tied to the player at all. - -### Who calls it, and how often - -Four call sites, all in `CMotionInterp` (the per-entity motion interpolator), -all on `weenie_obj->InqRunRate(...)` (virtual dispatch through -`ACCWeenieObject::InqRunRate` `0x0058c560` → `CACQualities::InqRunRate`): - -- `CMotionInterp::apply_run_to_command` @ `0x00527be0` — scales a forward/ - sidestep command's magnitude when a new motion command is applied. -- `CMotionInterp::get_max_speed` @ `0x00527cb0` -- `CMotionInterp::get_adjusted_max_speed` @ `0x00527d00` -- `CMotionInterp::get_state_velocity` @ `0x00527d50` — computes the actual - per-tick velocity vector; this one runs on the motion-interpolation - cadence (every tick the interpolator advances), not just on command - change. - -**All four fall back to `this->my_run_rate` (a plain cached float on -`CMotionInterp`) if `InqRunRate` returns 0.** `InqRunRate` returns 0 whenever -`this._attribCache == null` — i.e. whenever the weenie doesn't carry a live, -fully-populated `CACQualities` (every weenie *other than the local player*: -remote players and monsters only get a thin/partial qualities projection -over the wire, not full trained-attribute data). - -### Where `my_run_rate` (the fallback) is set - -Found exactly two write sites, both inside the inbound movement-event -parser handling `MoveToObject` (case 6) and `MoveToPosition` (case 7) motion -commands (`0x005245e9` / `0x00524656`, inside the function starting near -`0x00524460`): - -``` -case MoveToObject / MoveToPosition: - MovementParameters::UnPackNet(¶ms, kind, wire) // unpacks the wire MovementParameters struct - speed = *(float*)wire; wire += 4 // an explicit speed/run-rate float on the wire - this.motion_interpreter.my_run_rate = speed - ... -``` - -### Verdict for Q4 - -**Local player**: `InqRunRate` is called **every tick** the motion -interpolator needs a velocity — there is no cache, no "recompute on Run -skill change" event at all; the fatigue check (current Stamina == 0) and the -whole skill-formula chain are re-evaluated on every call. **CA1 should port -`InqRunRate` as a per-tick pure query** (attributes + skill state + -enchantments + current Stamina → run rate), exactly mirroring how it already -treats skills. - -**Remote weenies (and any weenie without a full qualities projection)**: -run rate is **not** derived locally at all — it's a cached float taken -verbatim from an explicit `speed` field on the server's `MoveToObject`/ -`MoveToPosition` wire payload (`MovementParameters`), applied once per -motion command, held until the next one. This directly confirms the design -already noted in CLAUDE.md/`ACDREAM_RUN_SKILL` docs -(`PlayerMovementController.ApplyServerRunRate`, echoing -`UpdateMotion.ForwardSpeed`): **retail itself does the same -local-computes/remote-trusts-the-wire split** — acdream's existing dual-path -shape is retail-faithful, not an adaptation. (One nuance to verify against -current acdream code: retail's wire-sourced fallback is populated from the -`MovementParameters` speed field carried on `MoveToObject`/`MoveToPosition` -specifically, not from every `UpdateMotion` — worth a follow-up check that -acdream's actual sync point matches this exact message, not a broader one.) - ---- - -## 5. Attribute-less skills (e.g. Salvaging) - -Confirmed by code inspection of `InqSkillBaseLevel` (§2): the two -`InqAttribute` calls are individually gated — -`if base._formula._attr1 != 0: InqAttribute(...)` and same for `_attr2`. If -a `SkillBase._formula` has `_attr1 == 0` and `_attr2 == 0` (no attribute -contribution authored for that skill), both `a1` and `a2` stay `0`, and -`SkillFormula::Calculate` reduces to: - -``` -result = floor((_x*0 + _y*0 + _w) / _z + 0.5) = floor(_w/_z + 0.5) -``` - -i.e. a pure constant (the formula's `_w`/`_z` bias, presumably 0/1 in -practice for a true attribute-less skill, giving `result = 0`). **This -degrades gracefully — no null-check, no divide-by-zero risk** (the only -divide-by-zero guard is `_z == 0 => return 0`, unrelated to attr1/attr2 -being zero). Everything downstream (trained ranks, augmentations, -enchantments) applies identically regardless of whether the attribute term -contributed anything. - -**OPEN**: which specific skill ids have `_attr1==_attr2==0` in the shipped -`portal.dat` SkillTable was not independently confirmed here (that's DAT -data, not code — the pseudo-C only proves the *mechanism* handles it -correctly). A live DatCollection read of `SkillTable` (0x10000004) would -give the exact list; ACE's own `references/ACE/Source/ACE.DatLoader` reads -the same table and could be probed against a live install if needed for -CA1 test fixtures. - ---- - -## 6. Specialization semantics (SKILL_ADVANCEMENT_CLASS) - -`enum SKILL_ADVANCEMENT_CLASS { UNDEF=0, UNTRAINED=1, TRAINED=2, -SPECIALIZED=3, NUM=4 }` (`acclient.h:2951`). Two, and only two, places the -runtime formula reads SAC (everything else — `_trained_cost`/ -`_specialized_cost` on `SkillBase` — is chargen/skill-credit-spending data, -never touched by `InqSkill`/`InqSkillBaseLevel`/`InqRunRate`): - -1. **Usability gate** (`InqSkillBaseLevel`): `if (int)sac < (int)base._min_level: value = 0`. - A skill authored with `_min_level = TRAINED` reads as 0 for an untrained - character; one authored `_min_level = UNTRAINED` (i.e. always usable) is - never gated. This is purely a floor-to-zero, not a formula change — the - attribute/formula computation for a *usable* skill is identical between - Trained and Specialized. -2. **`LumAugSkilledSpec` (PropertyInt 0x158/344) doubling**, in both - `InqSkill` and the inlined copy in `InqRunRate`: the augmentation's own - int value is added **only if the skill's own SAC == SPECIALIZED**, and - when it applies it's doubled (`value += aug * 2`), vs. not applied at all - for Trained. This is the *only* place SAC changes the numeric formula at - runtime. - -There is **no** "+10 for specialized / +5 for trained" retail-side constant -bonus baked into the client's runtime inquiry math — that folklore number -(confirmed in ACE's own `CreatureSkill.InitLevel` doc comment: "A bonus from -character creation: +5 for trained, +10 for specialized") is a **chargen-time -one-shot** value baked directly into `_init_level` when the character is -created (part of the flat number `SetSkill`/`SetSkillLevel` write into -storage), not something the runtime formula re-derives from SAC on every -call. `InqSkillBaseLevel`/`InqSkill` just add whatever `_init_level` already -holds — they never branch on SAC to decide it. **CA1 must not port a -"branch on SAC, add 5 or 10" step into the runtime formula — that number -belongs entirely to chargen (already covered by -`ChargenSkillAdvancement.cs`/CC's completed work), and shows up in the -runtime formula only as the already-baked `_init_level` field.** - -Trained-cost/specialized-cost fields on `SkillBase` -(`_trained_cost`,`_specialized_cost`) are the CP-cost-to-raise-SAC numbers -used by the chargen/skill-credit UI, confirmed unused by any of the -`Inq*`/`SkillFormula::Calculate` call chains traced above. - ---- - -## 7. UI refresh mechanism - -`QualityRegistrar` (`acclient.h:33347`, -`struct { vfptr; IntrusiveHashTable m_handlers; QualityHandler m_PlayerQualityHandler; QualityHandler m_GlobalQualityHandler; }`) -is a genuine observer/pub-sub registry, singleton `QualityRegistrar::s_pQR`, -built by `CFactory::MakeQualityRegistrar_Internal` (`0x0054af80`). Its vtable -(`acclient.h:33363`): - -``` -RegisterQualityHandler(weenieId, StatType, qualityId, QualityChangeHandler*) -RegisterQualityHandlerForThePlayer(StatType, qualityId, QualityChangeHandler*) -UnRegisterQualityHandler(...) / UnRegisterQualityHandlerForThePlayer(...) -CallChangeHandler(weenie, StatType, qualityId) // fired by every SetX wire handler, §1 -``` - -`QualityChangeHandler`'s vtable (`acclient.h:33237`) is exactly two methods: - -``` -OnQualityChanged(CWeenieObject* owner, StatType type, uint qualityId) -OnQualityRemoved(CWeenieObject* owner, StatType type, uint qualityId) -``` - -**Critically, the callback carries no value** — only "this (StatType, id) -just changed on this owner." Every UI widget that cares about a live number -must pull it itself. Confirmed concretely for the character-panel attribute -row: `AttributeInfoRegion::AttributeInfoRegion` (`0x004f1530`) ends its ctor -with `RegisterQualityHandlerForThePlayer(Attribute_StatType=8, -this->m_Attribute, this)`; `Attribute2ndInfoRegion::Attribute2ndInfoRegion` -(`0x004f1680`) does the equivalent for the vitals bar (§3); -`SkillInfoRegion`-style rows use `Skill_StatType=4` the same way (seen at -`0x004f2172`: `InfoRegion::InfoRegion(this, ..., Skill_StatType, skillId, -iconDID)`, base ctor only builds the label/value `UIElement`s — the -subclass ctor is what registers). - -`StatType` enum (`acclient.h:2879`) used as the registry's namespace key: -`Int=1, Float=2, Position=3, Skill=4, String=5, DataID=6, InstanceID=7, -Attribute=8, Attribute_2nd=9, BodyDamageValue=0xA, BodyDamageVariance=0xB, -BodyArmorValue=0xC, Bool=0xD, Int64=0xE`. - -### Verdict for Q7 - -Event-driven, not per-frame polling and not a dirty-flag scan. Registration -is per-widget, per-(StatType,id), keyed either to a specific weenie or to -"whichever weenie is currently the local player" -(`RegisterQualityHandlerForThePlayer`, which is what every character-panel/ -vitals-bar row uses — it survives player-object swaps, e.g. on -login/character-switch, without re-registering). **The push only says -"something changed"; the UI re-derives the actual number via the same live -`Inq*` chain described in §2/§3/§4.** This is the cleanest possible design -for CA1 to port: one `IQualityChangeNotifier` (weenie, StatType, id) event, -fired from the same write path identified in §1, with every panel/HUD -element subscribing per-id and re-pulling its value on notification rather -than caching a pushed value. - -**Loose end (non-blocking, flagged for awareness):** `Attribute2ndInfoRegion` -also registers under `Attribute_2nd_StatType` for the *base-attribute* ids -that feed the max-vital formula (e.g. Endurance's id, when the vitals row is -MaxHealth). Base-attribute changes fire `CallChangeHandler` under -`Attribute_StatType=8`, not `9` — so that third registration looks like it -never actually fires from a base-attribute change in this build. Given §3's -finding that the vitals bar's real, working trigger is the server's own -explicit MaxHealth/MaxStamina/MaxMana push, this looks like either -vestigial/defensive code or a targeted id-space that happens to coincide by -accident; it is not load-bearing for CA1's design and I would not port it -without further evidence it does anything. - ---- - -## 8. ACE / chargen cross-check - -### `src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs` - -Already ported the **correct** formula *shape*: -`ChargenSkillFormula(AdditiveBonus, Attribute1Multiplier, Attribute2Multiplier, -Divisor, Attribute1, Attribute2)` is documented as sourced from -`gmCGSkillsPage::MakeSkillFormula @0x00480e10` and maps 1:1 onto -`SkillFormula{_w,_x,_y,_z,_attr1,_attr2}` — verified: chargen's own compute -path (`0x005c4bfb`, inside `CharGenState`-related code) calls the **same** -`SkillFormula::Calculate` (`0x00591960`) used by the live runtime path in §2. -So chargen and post-login runtime share one formula engine in retail, and -`ChargenSkillAdvancement.cs`'s field layout is provably correct for CA1 to -reuse/extend. - -**Gap found**: `ChargenSkillFormula` today is *data only* — the one consumer -(`CharacterCreationSkillsPage.cs::ComposeFormula`) only builds a **display -string** ("Str + End / 2") from it; there is no C# port yet of the actual -`SkillFormula::Calculate` numeric math (`floor((x*a1 + y*a2 + w)/z + 0.5)`). -CA1 needs to add this — and per the finding below, it should **not** copy -ACE's simplified C# version. - -### ACE cross-check: strong structural match, one formula-fidelity gap - -`references/ACE/Source/ACE.Server/WorldObjects/Entity/CreatureSkill.cs` -(`Current` property) matches the retail decompile's *structure* almost -term-for-term: - -- `IsUsable` gate ≈ retail's `sac < min_level` (ACE additionally special- - cases `min_level==1` meaning "usable while untrained" — consistent). -- `total += InitLevel + Ranks` ≈ retail's `_init_level + _level_from_pp`. -- `GetAugBonus_Base` (LumAugAllSkills flat, AugmentationSkilled{Melee, - Missile,Magic}*10 gated by skill category, **plus `Enlightenment`**) is - added **before** the vitae/enchantment multiplier — same tier as retail's - pre-`EnchantSkill` additions (0x16d, 300/301/302). -- `GetAugBonus_Current` (`AugmentationJackOfAllTrades*5`, - `LumAugSkilledSpec*2` gated on `SAC==Specialized`) is added **after** - vitae/multiplier — same tier as retail's post-`EnchantSkill` additions - (0x146, 0x158). This before/after placement matching retail exactly (down - to which two bonuses are on which side of the vitae multiply) is strong - independent confirmation the decompile trace above is read correctly. -- **Divergence**: ACE's `AugmentationSkilledMelee/Missile/Magic` bonus is - `aug_level * 10`; retail's is a **flat +10 gated on `>0`**, not scaled by - the stored int's magnitude. In practice these augmentations are one-shot - unlocks (0 or 1), so the two are almost certainly equivalent at runtime — - but they are not the same formula, and if the augmentation is ever - stackable this would diverge. Not important enough to block CA1, but do - not copy ACE's `* 10` verbatim without checking the real PropertyInt's - range. -- **Version-era gap, not a bug**: ACE adds an `Enlightenment` bonus - (`AdvancementClass >= Trained && Enlightenment != 0`) that **does not - exist anywhere in the Sept-2013 client's `InqSkill`/`InqSkillBaseLevel`**. - Either Enlightenment postdates this build, or it's applied exclusively - server-side and baked into a field the client already stores (unconfirmed - either way — flagged OPEN). **Do not port an Enlightenment skill bonus - into the client-side live formula unless/until acdream's target retail era - is confirmed to include it; if ported, it needs its own divergence-register - row either way** since our reference build doesn't show it. - -`references/ACE/Source/ACE.Server/Entity/AttributeFormula.cs` — **real -divergence, load-bearing**: - -```csharp -public static uint GetFormula(Creature creature, DatLoader.Entity.SkillFormula formula, bool current = true) -{ - if (formula.X == 0) return 0; - var total = current ? creature.Attributes[attr1].Current : creature.Attributes[attr1].Base; - if (attr2 != Undef) total += current ? creature.Attributes[attr2].Current : ...Base; - if (divisor != 1) total = (uint)((float)total / divisor).Round(); - return total; -} -``` - -This **ignores `_w` (the additive constant) entirely, and ignores `_x`/`_y` -as per-attribute weights** — it just sums the raw attribute values -unweighted (using `_x` only as an on/off gate) then divides. Retail's -`SkillFormula::Calculate` is `floor((_x*a1 + _y*a2 + _w)/_z + 0.5)` — a -strict superset. If the real `portal.dat` SkillTable/`Attribute2ndTable` -records ever have `_w != 0` or `_x != _y` (a genuinely weighted two-attribute -formula, e.g. "mostly Quickness, some Coordination"), ACE's simplified -version silently produces a different number than retail. **CA1 must port -the verbatim retail formula (`floor((x*a1+y*a2+w)/z + 0.5)`), not ACE's -`AttributeFormula.GetFormula` — the verbatim version is safe even in the -common case where `x=y=1,w=0` reduces to the same thing, so there's no -downside to using the more complete formula.** This is exactly the kind of -"verify shared math before substituting" trap the project's own -`feedback_wb_migration_formulas.md` lesson warns about — flag as a MUST- -VERIFY-AGAINST-LIVE-DAT item before shipping (a live `SkillTable`/ -`Attribute2ndTable` dump via `DatCollection` would resolve it in one probe: -grep for any record with non-1 `_x`/`_y` or non-zero `_w`). - -`CreatureVital.cs` (`GetMaxValue`) independently reaches the **same** -structural conclusion the decompile trace did for `GearMaxHealth`/ -`Enlightenment` placement — ACE's own code comment: *"Enlightenment and -GearMaxHealth were an exception, and added in beforehand... this means -[they] would get scaled by multipliers... and vitae as well."* This matches -§3's finding that the client's PropertyInt-0x17b (`GearMaxHealth`) addition -happens **before** the `AttributeCache`/`EnchantAttribute2nd` step — i.e. -before whatever multiplier/vitae scaling lives inside `EnchantAttribute2nd`. -Strong independent cross-confirmation. - ---- - -## 9. Open items - -1. **`ACCWeenieObject::OnStatUpdated` overload resolution** for the raw- - scalar Attribute/Attribute2nd/Skill `UpdateStat` instantiations — which - of the two concrete bodies (`0x0058c680` bool-id switch, `0x0058df20` - int-id switch) actually gets called. Tried: grepped both bodies' switch - cases (neither has an Attribute/Skill-id case, so it's a no-op either - way); tried Ghidra `function_xrefs` on the symbolic name (no results — - binary has no relocation-based xref table entry for the overloaded - name). Non-blocking: confirmed harmless for character-advancement - regardless of which one resolves. -2. **Exact `Attribute2ndTable`/`SkillTable` DAT coefficients** (which skills - have `_attr1==_attr2==0`; whether any real record has `_w!=0` or - `_x!=_y`) — code-level mechanism confirmed (§5, §8), specific numbers are - DAT data and need a live `DatCollection` probe, not decomp. -3. **Whether ACE's server actually pushes a fresh `PrivateUpdateAttribute2nd` - for MaxHealth/MaxStamina/MaxMana when the underlying Endurance/Self - changes** (§3's load-bearing assumption about what drives the vitals bar - redraw in retail). Traced ACE's `CreatureVital`/`AttributeFormula` C# - math but did not trace ACE's *push/dirty* path (`Player_Vitals.cs` / - wherever ACE decides to re-send `GameMessagePrivateUpdateAttribute2nd`) — - out of scope for this decomp-focused pass; recommend a short follow-up - read of `references/ACE/Source/ACE.Server/WorldObjects/Player_Vitals.cs` - and `Creature_Skills.cs`'s raise-skill path before implementing CA1's - server-sync expectations. -4. **Enlightenment's absence from the 2013 client formula** (§8) — need to - confirm which retail era introduced client-visible Enlightenment skill - bonuses, if acdream ever targets that era. diff --git a/docs/research/2026-08-24-campaign-ca-test-script.md b/docs/research/2026-08-24-campaign-ca-test-script.md deleted file mode 100644 index cae336db..00000000 --- a/docs/research/2026-08-24-campaign-ca-test-script.md +++ /dev/null @@ -1,142 +0,0 @@ -# Campaign CA — connected gate script (CA5, user-driven) - -**Purpose:** live verification of the whole advancement chain against ACE -after CA1–CA4 (`1fc64984`, `65430d4c`, `57818959`, `08b77e20`). Everything -below should be visible **without a relog** — that is the entire point of -the campaign. - -**Character:** use a SCRATCH character (owner decision 2026-08-24 — XP and -skill credits will be spent, and the respec checks are destructive). -A fresh character is ideal: low attribute costs mean many cheap raises. - -**Launch:** the normal connected launch (`ACDREAM_RETAIL_UI=1`, live ACE at -`127.0.0.1:9000`). No diagnostic env vars needed. Useful ACE console -helpers: `@ci ` to spawn gems, `@grantxp`, `@grantskillcredits` -(see `claude-memory/../memory/reference_ace_commands.md`). - -Open the Character panel (F9 / toolbar) before starting; keep the vitals -bar visible throughout. - ---- - -## 1. Attribute raise → derived skills + run speed (the original #431) - -Prep: bank some XP (`@grantxp` if the scratch character is too poor). -Note current run speed by running a straight line; note the Run skill's -displayed value and one other Quickness-fed skill (e.g. Melee Defense). - -1. Raise **Quickness** by 1. - - PASS: the attribute value updates when the server record lands (a - round trip, not a relog); **Run and every Quickness-fed skill row - update in the same moment**; XP remaining drops by the server's - accounting. -2. Raise Quickness repeatedly until the formula contribution crosses a - point (attribute current +2 → skill formula +1 for /2-divisor skills). - - PASS: skill rows tick up as the attribute crosses each threshold. -3. **Run before and after.** With `runrate_add_hooks` active on ACE the - server also re-broadcasts your movement speed mid-run. - - PASS: run speed visibly increases after the raise — at latest on the - next movement start. FAIL if speed only changes after relog. -4. While the raise is in flight (click and watch closely): the raise - buttons ghost momentarily and un-ghost when the record lands. - - PASS: brief ghost; no double-send on rapid double-click (the second - click does nothing). - -## 2. Endurance/Self raises → vitals maxima (the single-record quirk) - -Note max health / max stamina / max mana from the vitals bar. - -1. Raise **Endurance** by 1–2 points. - - PASS: **max health AND max stamina both move** on the bar and the - panel (ACE only pushes a Health record; the client-side fan-out must - cover Stamina — research doc §4.1). -2. Raise **Self**. - - PASS: max mana moves. -3. Sanity: current values don't jump wrongly (regen keeps ticking - normally; watch ~10 s). - -## 3. Direct vital raise + the retail raise-10 client bug (AP-73 CHECK) - -1. Raise **Max Health** directly by 1. - - PASS: bar max + panel update on the record; XP debits. -2. **The deliberate failure case** — this resolves the narrowed AP-73 row. - Arrange XP so you can afford exactly ONE vital raise but not ten - (spend down; the cost curve is steep so this is easy). The retail - client bug ACE documents: the raise-10 button is enabled anyway. - Click **raise ×10**. - - EXPECTED from ACE: chat line "Your attempt to raise ... has failed." - and NO stat change. - - **RECORD: do the raise buttons stay ghosted afterward?** - - If they un-ghost on their own → note what un-ghosted them (a regen - tick counts as a quality change — that is retail-plausible and - AP-73 can then RETIRE with that mechanism recorded). - - If they stay ghosted until you close/reopen the panel → AP-73's - symptom confirmed; report it and we decide the fix against the - retail oracle (cdb on the live retail client if needed). - -## 4. Skill raise - -1. Select a TRAINED skill, raise ×1 and ×10. - - PASS: ranks/value update on the record; XP debits; the - "Your base skill is now N!" advancement line appears in the - SpewBox with the advancement color. -2. Confirm an attribute-less skill (e.g. **Salvaging**, if trained) shows - value = ranks-only progression and raises normally. - -## 5. Train a new skill - -Prep: ensure ≥ the DAT cost in skill credits (`@grantskillcredits`). - -1. Select an UNTRAINED skill, click Train. - - PASS: the skill flips to Trained on the record; credits drop by the - DAT cost; chat: " trained. You now have N credits available." -2. NEGATIVE (silent-failure probe): nothing client-side should allow a - wrong cost, so simply confirm no double-send on rapid clicks and that - the button ghosts while awaiting. - -## 6. Specialize / lower (SkillAlterationDevice) - -Prep: `@ci` a **Gem of Enlightenment** (specialize) and a -**Gem of Forgetfulness** (lower) — wcids per ACE's db (ask the console -with `@acecommands` if unsure). - -1. Use the Enlightenment gem on a TRAINED skill. - - PASS: retail confirmation dialog appears; on accept, the skill flips - to Specialized, credits drop, and the "You have succeeded - specializing..." notice lands in chat (the 0x028B - WeenieErrorWithString route). - - Also confirm DECLINING the dialog changes nothing. -2. Use the Forgetfulness gem: Specialized → Trained, then Trained → - Untrained. - - PASS: each step updates the panel on the record and refunds credits - per ACE's accounting; an untrained skill row returns to the - untrained section with formula-only value. - -## 7. Respec-adjacent (as far as ACE supports) - -If ACE's Enlightenment/attribute-reset paths are reachable on this server -(level requirements may block a scratch character — skip if so), exercise -one and confirm the client tracks every pushed record without a relog. -Otherwise mark N/A — the message shapes are identical to §6's, so §6 -passing covers the client-side mechanism. - -## 8. Regression sweep (5 minutes) - -- Vitae/buff display still correct after raises (buff an attribute; panel - shows effective + base pair; skill values include the buff through the - formula). -- Logout/login: everything you raised persists and PlayerDescription - agrees with what the live records showed (any mismatch = a parser bug — - report exact numbers). -- Ordinary play smoke: run, jump, cast, fight one mob — nothing about - movement feel changed outside the raises. - ---- - -## Report back - -Per section: PASS/FAIL plus anything odd. The three answers that matter -most: -1. §1.3 — did run speed change live? -2. §2.1 — did max STAMINA move on an Endurance raise? -3. §3.2 — the AP-73 ghost question (un-ghosted by what / stayed stuck?). diff --git a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md deleted file mode 100644 index 1c332e57..00000000 --- a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md +++ /dev/null @@ -1,797 +0,0 @@ -# Campaign CT slice CT1 — DAT ground truth for the character panel (0x2100002E) - -**Status:** RESEARCH COMPLETE 2026-08-24. No production code changed in this -slice. Findings feed CT2–CT6 (`docs/plans/2026-08-24-character-panel-parity-campaign.md`). - -Method: temporary `Assert.Fail` probe tests (deleted before commit; the -pattern is preserved in `tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs` -and its sibling `CharacterPanelLiveDatTests.cs` written by this slice) against -the installed DAT set (`%USERPROFILE%\Documents\Asheron's Call`), driven -through `LayoutImporter.ImportInfos` / `ElementInfo` -(`src/AcDream.App/UI/Layout/ElementReader.cs`, -`src/AcDream.App/UI/Layout/LayoutImporter.cs`). - -## 1. Header elements (gmStatManagementUI content, sub-layout under 0x2100002E) - -All header elements are DUPLICATED — the imported 0x2100002E tree carries -two structurally identical copies of the whole header block, one reached -through the Attributes page chain (`0x10000227 > 0x1000022B > 0x10000226 > -0x10000230`) and one through the Skills page chain (`... > 0x1000022C > -...`). **The two copies are geometrically and stylistically IDENTICAL** — -this is the "duplicated stat-management branches" quirk documented on -`PrepareSkillScrollbar` in `CharacterStatController.cs`. **Caveat: this -"identical" claim rests on the deleted probe tests, not the committed -pins.** `CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors` -only asserts font/color/margin equality across the two copies (and the -2-count) — it does not assert X/Y/W/H geometric equality between them. -Treat "geometrically identical" as probe-session observation, not a -pinned fact, until a future slice adds a geometry-equality assertion. - -**Coordinate frame:** every `X,Y` below is **parent-relative**, not -page-relative — e.g. Name `0,0` means the top-left of its immediate -parent container (`0x10000230`), not the top-left of the Attributes/ -Skills page. Cross-reference -`docs/research/2026-06-25-character-window-faithful-spec.md` for the -page-relative numbers if you need the header block's position within -the page itself; do not mix the two frames when placing elements. - -| Element | Id | X,Y | W×H | HJustify | VJustify | FontDid | FontColor | Outline | Margins | -|---|---|---|---|---|---|---|---|---|---| -| Name | 0x10000231 | 0,0 | 230×20 | Center | Center | 0x40000001 | white (1,1,1,1) | false | 0 | -| Heritage line | 0x10000232 | 0,20 | 230×15 | Center | Center | 0x40000002 | white (1,1,1,1) | false | L5 R5 | -| PK line | 0x10000233 | 0,35 | 230×15 | Center | Center | 0x40000002 | **white (1,1,1,1)** | false | 0 | -| Level | 0x1000023B | 235,35 | 65×50 | Center | Center | 0x40000010 | **(1, 0.9490196, 0.49803922, 1)** | **true** | 0 | -| Total XP | 0x10000235 | 130,70 | 100×18 | Right | Center | 0x40000000 | white | false | 0 | -| XP meter | 0x10000236 (Type 7) | 0,88 | 230×17 | — | — | — | — | — | — | -| XP-next label (child of meter) | 0x10000237 | 0,0 | 130×17 | Left | Center | 0x40000000 | white | false | 0 | -| XP-to-level value (child of meter) | 0x10000238 | 130,0 | 100×17 | Right | Center | 0x40000000 | white | false | 0 | -| Luminance label | 0x100005C5 | 0,52 | 110×18 | Left | Center | 0x40000000 | white | false | 0 | -| Luminance value | 0x100005C6 | 110,52 | 120×18 | Right | Center | 0x40000000 | white | false | 0 | - -Header block container (0x10000230) is 300×110 inside the shared prototype -layout 0x21000045 — the character content column itself is only 230px wide -(name/heritage/PK/XP all live in that 230px column), with a 5px vertical -divider (0x10000239, X=230, W=5) separating it from the level box -(X=235..300, matching the plan's "Level area (65,50)" spec). - -**Confirmations vs. the owner report / plan:** -- Item 4 (PK line pure white): CONFIRMED — `0x10000233` FontColor is - exactly white, not the "color off" state the owner reported. The bug is - purely in `CharacterStatController.Bind`'s runtime color choice (`Body` - = parchment `(0.92,0.90,0.82,1)`), not a DAT-reading gap. CT4 must switch - the PK-line color to `Vector4.One`. -- Item 5 (level color): CONFIRMED DIVERGENT. DAT-authored level color is - `(1, 0.9490196, 0.49803922, 1)` (a pale gold, ~RGB 255/242/127) **with - Outline=true**. `CharacterStatController.Gold` is currently - `(1, 0.82, 0.36, 1)` (a deeper orange-gold) with no outline applied. CT4 - should read the DAT FontColor + Outline directly instead of hand-picking - a runtime color, matching how `LevelId`'s dat FontDid is already honored. - -## 2. Stat list (0x1000023D) + row templates - -`0x1000023D` (Type 5 ListBox) is duplicated the same harmless way as the -header (once per Attributes/Skills page chain, identical geometry both -times): `X=0 Y=112 W=300 H=160`, `ScrollbarElementId=0x1000023E` -(scrollbar at `X=281 W=16 H=160`, i.e. always reserved, whether or not it's -shown). Its authored `TemplateList` (dat property 0x64) names FIVE -same-layout-family entries, all in **LayoutDesc 0x21000045**: - -``` -0x10000248, 0x10000249, 0x1000024A, 0x1000024B, 0x1000024C -``` - -These are NOT reachable by walking `ImportInfos(dats, 0x21000045u)`'s built -tree — `LayoutImporter.ImportInfos` intentionally filters out same-layout -template-list targets (the `#375` fix documented in -`LayoutImporter.ImportInfos`'s own comment: "retail never instantiates a -template-list element as a live widget… building them here parked two live -prototype rows… over and outside the framed panel"). The correct read path -— and the one CT5 must use — is the **targeted single-root overload**: -`LayoutImporter.ImportInfos(dats, 0x21000045u, templateElementId)`, the same -seam `UiTemplateListBox`'s `TemplateResolver` already uses for other -authored row templates. - -Dumping all five with that overload: - -### 0x10000248 — the ONE shared data-row template (icon + name + value) - -``` -Id=0x10000248 Type=3 (container) X=0 Y=0 W=282 H=20 - StateMedia[Normal] File=0x06004CC2 DrawMode=1 - StateMedia[Highlight] File=0x06000F93 DrawMode=1 - Id=0x10000129 Type=3 (icon slot) X=0 Y=0 W=20 H=20 (no own media — set per-row at runtime) - Id=0x1000012A Type=0xC (name text) X=25 Y=0 W=150 H=20 HJustify=Left FontDid=0x40000001 white - Id=0x1000012B Type=0xC (value text)X=175 Y=0 W=100 H=20 HJustify=Right FontDid=0x40000001 white -``` - -This is the single row template used for BOTH the attribute rows AND the -skill rows (retail's `gmAttributeUI`/`gmSkillUI` share it). Ground truth -for item 1 (icon alignment) and item 2 (value-column gutter): - -- **Icon: 20×20, flush at the row's left edge (X=0), full row height.** - Current code (`CharacterStatController.IconSize = 16f`, - `RowPadX = 4f`) draws a 16×16 icon at X=4 — smaller AND offset from - retail's flush-left 20×20. This is the "icons misaligned" bug (item 1). -- **Name column: X=25, W=150 (fixed pixel widths, not a width fraction).** - Current code computes `nameX = RowPadX + IconSize + IconGap` (4+16+6=26, - off by one from retail's 25) and `nameW = width * 0.60` (a - content-relative fraction retail does not use at all — retail's name - column is a FIXED 150px regardless of the 282px row width). -- **Value column: X=175, W=100, right-justified.** Value's right edge - sits at X=275. **The row template itself is 282px wide, so there is a - 7px gap between the value's right edge and the row's own right edge** - — this is the "authored margin between the value column and the - border" the owner reported (item 2), confirmed as exactly 7px at the - row-template level. -- **The bare authored rectangles** (no derived arithmetic — see the - caveat below): ListBox `0x1000023D` is `X=0 Y=112 W=300 H=160`; its - scrollbar `0x1000023E` is `X=281 W=16 H=160`; the data row template is - `W=282`; the value column inside the row is `X=175 W=100`, - right-justified, 7px from the row's own right edge (`282 - (175+100) = - 7`). -- **These numbers do NOT compose into a tidy "gutter" story — do not - infer one.** `300 - 281 = 19`, not the row's 282px width's complement - (`300 - 282 = 18`); and the 282px row actually OVERLAPS the 281px - scrollbar band by 1px (`281 < 282`). An earlier draft of this doc - described the row as "inset by 18px to clear an always-reserved - scrollbar gutter" and summed 18+7=25 — that decomposition does not - close against the authored numbers above and is **inference, not - fact**; withdrawn. CT5 must implement the authored numbers directly - (row width 282 — matches the existing `SkillContentWidth = 282f` - constant already in the code; value column X=175 W=100; scrollbar - X=281 W=16), never a derived `listWidth - 18` or similar formula. -- Row background: `Normal` state file `0x06004CC2`. **The description - "generic panel-chrome fill used elsewhere client-wide" is UNVERIFIED** - — no cross-reference sweep for other consumers of `0x06004CC2` was run - this slice; only the file id itself, as authored on this specific - template, is pinned. `Highlight` state file **`0x06000F93`**. - -### SEALED VERDICT: RowHighlightSprite is wrong, not merely flagged - -Retail's selected attribute/skill row draws the row template's Highlight -state media — `0x06000F93`, not `CharacterStatController`'s current -`0x06001397u`. This is no longer a hedge; the decomp confirms the -mechanism end to end: - -- `gmAttributeUI::UpdateSelection @0x0049DEE0` calls - `SetState(selected ? 6 : 1)` on the row. -- `InfoRegion::SetState @0x004F0EE0` forwards that state to the row - element instantiated from template `0x10000248` — the exact template - this slice dumped, whose Highlight-state file is `0x06000F93`. -- State `6` IS `UIStateId.Highlight` — so the selected row draws - `0x10000248`'s own `Highlight` media, not a separately-chosen sprite. - -`CharacterStatController.cs`'s comment near lines 111–113 ("matches -retail... sprite 0x06001397 visual intent") is **falsified for this -element**. CT5 must correct `RowHighlightSprite` to `0x06000F93` for the -STAT rows and should reconsider `UseSelectionBars`/`HighlightBg`, which -currently emulate the wrong sprite's art (a translucent gold tint tuned -to look like `0x06001397`'s dark bars, not `0x06000F93`'s actual look). - -**`0x06001397` is not a phantom constant — it is legitimate ELSEWHERE.** -The spellbook row prototype `0x10000343` has a separate selected-overlay -CHILD element `0x10000342` whose media IS `0x06001397` -(`UIElement_UIItem::SetSelectedState @0x004E1240` mechanism — a -different code path from `InfoRegion::SetState`, and a different visual -composition: an overlay child, not a state-swap on the row itself). CT5 -must correct the STAT rows ONLY and must NOT touch `SpellbookRowStyle.cs` -or its tests — `0x06001397` is correct there. - -### 0x10000249 / 0x1000024A / 0x1000024B / 0x1000024C — skill SECTION HEADER captions - -``` -Id=0x10000249 Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F90 -Id=0x1000024A Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F86 -Id=0x1000024B Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F98 -Id=0x1000024C Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F89 -``` - -These are single full-width caption bars (no icon/name/value split), 280px -wide (2px narrower than the data row — no scrollbar-gutter inset needed -since they never scroll independently). Their sprites are an EXACT match -for the existing constants already in `CharacterStatController.cs`: - -| Constant | Value | Probe file | Match | -|---|---|---|---| -| `SkillHeaderSpecializedSprite` | 0x06000F90 | 0x10000249 | YES | -| `SkillHeaderTrainedSprite` | 0x06000F86 | 0x1000024A | YES | -| `SkillHeaderUntrainedSprite` | 0x06000F98 | 0x1000024B | YES | -| `SkillHeaderUnusableSprite` | 0x06000F89 | 0x1000024C | YES | - -These four are correctly ported already; no CT5 work needed here. - -### Row-height divergence (CT5 gold, found this fix round) - -The row template above authors `H=20` (line `Id=0x10000248 Type=3 -(container) X=0 Y=0 W=282 H=20`, already pinned by -`CharacterPanelLiveDatTests.AttributeRowTemplate_...`). Current code -matches this for skill rows (`CharacterStatController.SkillRowHeight = -20f`) but NOT for attribute rows, which use a separate -`CharacterStatController.RowHeight = 22f` constant. CT5 must fix the -attribute-row path to 20px; there is no authored basis for 22 anywhere -in the row template. - -### Row instantiation + icon-DID anchors (CT5 gold, found this fix round) - -`InfoRegion::InfoRegion @0x004F1450` instantiates each stat row via -`AddItemFromTemplateList(listBox, 0, ...)` — template index **0**, i.e. -`0x10000248`, the shared data row confirmed above — and binds -`0x1000012A` (label), `0x1000012B` (value), `0x10000129` (icon) via -`UIRegion::SetImageByDID(icon, did, 3)` (icon draw mode 3). Per-attribute -icon DIDs come from `DBObj::GetDIDByEnum(statEnum, category -0x10000002)` in `gmAttributeUI::PostInit @0x0049DB70` — a THIRD consumer -of the `GetDIDByEnum` master-map mechanism documented in §5 below -(alongside the title EnumMapper/StringTable pair and `RetailKeyNames`), -which is enough precedent that CT2/CT5 should factor a shared -`GetDIDByEnum(enumValue, category)` helper instead of hardcoding a third -independent DID pair. - -`gmSkillUI::RebuildSkillList @0x0049C3A0` adds template indices **1–4** -(`0x10000249..0x1000024C`) for the section-header captions, confirming -the section-header order already pinned by -`SkillSectionHeaderTemplates_MatchExistingSpriteConstants` above: -Specialized, Trained, Untrained, Unusable. - -## 3. Titles page (0x10000539 subtree, imported as part of 0x2100002E) - -`0x10000539` (Type 0x10000046, the Titles page container) is NOT -duplicated like the Attributes/Skills content — it appears once, as a -direct child of the tab-control root `0x10000227` (siblings with the -Attributes/Skills tab buttons and the Titles tab button `0x10000538`). -Geometry: `X=0 Y=25 W=300 H=575` (fills the window below the 25px tab bar). - -| Element | Id | X,Y | W×H | Notes | -|---|---|---|---|---| -| (unlabeled caption) | 0x1000052E | 8,20 | 270×18 | Left, margin L6, FontDid 0x40000001 white — likely "Current Title:" caption | -| **Current display title text** | **0x1000052F** | 8,40 | 270×18 | Center, margins L5 R5, FontDid 0x40000001 white | -| divider | 0x10000530 | 0,60 | 300×9 | sprite 0x06001420 | -| (unlabeled caption) | 0x10000531 | 8,70 | 270×18 | Left, margin L6 — likely "Titles Earned:" caption | -| **Title ListBox** | **0x10000532** | 8,90 | 270×455 | ScrollbarElementId=0x10000533; TemplateList: LayoutDid=0x2100005E, ElementId=0x10000536 | -| Title scrollbar | 0x10000533 | 280,90 | 16×455 | shared `RetailScrollbarChrome` media (thumb/up/down ids identical to the base skin pinned by `ScrollbarSkinLiveDatTests`) | -| divider | 0x10000534 | 0,550 | 300×9 | sprite 0x06001420 | -| **"Set as Display Title" button** | **0x10000535** | 53,560 | 200×32 | MinWidth=65, margins L7 R7, FontDid 0x40000001 white, DefaultState=**Ghosted** (matches the plan's `UpdateButtons` ghost-when-current contract); three-slice chrome children 0x100002CE/CF/D0 with Normal/Normal_rollover/Normal_pressed/**Ghosted** states each | - -### Title row template — LayoutDesc 0x2100005E, element 0x10000536 - -``` -Id=0x10000536 Type=3 (container) X=0 Y=0 W=270 H=24 - StateMedia[DirectState, key ""] File=0x06004CCA DrawMode=3 - StateMedia[Highlight] File=0x06001AAF DrawMode=1 - Id=0x10000537 Type=0xC (text) X=0 Y=0 W=270 H=24 HJustify=Left Margins L6 R6 FontDid=0x40000001 white -``` - -A single-line text row, no icon column (titles have no per-row icon in -retail) — 24px tall vs. the stat rows' 20px. Row width 270 matches the -ListBox content width exactly (`0x10000532` is 270 wide, with its -scrollbar `0x10000533` living OUTSIDE that width at X=280 — unlike the -stat list, the title row template does NOT need its own internal -scrollbar-gutter inset because the ListBox width itself already excludes -the scrollbar column). - -Same targeted-root import gotcha as the stat templates applies here: -`LayoutImporter.ImportInfos(dats, 0x2100005Eu)` (the whole-layout -overload) returns the SAME element `0x1000052D`, but does NOT surface -`0x10000536` as a reachable child. **`0x1000052D` is not a throwaway -container** — per the `BaseElement`/`BaseLayoutId` table in -`docs/research/2026-06-25-character-window-faithful-spec.md` (line -~19), `0x1000052D` is the authored `BaseElement` that the Titles page -root `0x10000539` inherits its content from (`0x10000539`'s -`BaseLayoutId` is `0x2100005E`, `BaseElement` is `0x1000052D`) — it is -the real authored Titles-page content, just reached by a different path -than the live-mounted tree. Use -`LayoutImporter.ImportInfos(dats, 0x2100005Eu, 0x10000536u)` (the -targeted single-root overload) to reach the row template `0x10000536` -underneath it. - -## 4. Window min/max constraints - -### Character window (0x2100002E) root - -`LayoutImporter.ImportInfos(dats, 0x2100002Eu)` returns element -`0x10000227` (Type 0x8, TabControl) as the tree root — this IS the -top-level element retail's `RetailUiRuntime.MountCharacter()` mounts via -`RetailWindowFrame.Mount(..., layout.Root, ...)`. **It authors NO -MinWidth/MinHeight/MaxWidth/MaxHeight properties** (dat properties -0x3F/0x3E/0x3D/0x3C all absent — probe shows every one of `MinW/MinH/MaxW/MaxH` -blank for 0x10000227 and every element under it, including the footer, -header, and Titles page). - -### Chat window (0x2100006F) root, for comparison - -`LayoutImporter.ImportInfos(dats, 0x2100006Fu)` returns element -`0x10000600` (Type 0x10000050, a self-contained "window" element that -directly includes its own dragbar (Type 2), border frame (Type 3), and -FOUR resize-grip corners (Type 9) as children — none of which the -character layout's root has). It DOES author constraints: -**MinWidth=300, MinHeight=100, MaxWidth=2000, MaxHeight=2000.** - -### Correction to the plan - -The plan's "Already in-tree" section states: *"The character window -registers with `DatConstraintSource` — authored min/max plumbing exists in -`RetailWindowFrame`; Y-resize for this window and the list-scrollbar -contract do not."* Read literally this implies the character window's -`RetailWindowFrame.Mount` call already sets `DatConstraintSource`. **It -does not.** `RetailUiRuntime.MountCharacter()` (`src/AcDream.App/UI/RetailUiRuntime.cs`, -~line 4043) constructs `RetailWindowFrame.Options` with `ResizeY = true`, -`ResizableEdges = ResizeEdges.Bottom`, `ConstrainResizeToParent = true` — -but **no `DatConstraintSource`, `MinHeight`, or `MaxHeight` field at -all**, unlike e.g. `MountSideVitals()` (~line 1474) which explicitly sets -`DatConstraintSource = info` from its own imported root. This is -consistent with what CT1 also found in the DAT itself: 0x2100002E's root -authors no size constraints to plumb through in the first place — chat's -window-frame elements are its own self-contained LayoutDesc, while -0x2100002E is CONTENT ONLY (tab bar + pages), with retail's window chrome -supplied by a separate mechanism. - -Treat the "Already in-tree" plan bullet as **inaccurate**: start CT6 -from the decomp for the window-frame class instead of assuming the -wiring is already 90% done. The paragraph below replaces this doc's -earlier "likely a hardcoded ResizeTo/SetMinSize call" guess with the -verified mechanism. - -### Verified resize mechanism (2026-08-24, supersedes the hypothesis above) - -`UIElement::ResizeTo @0x00463C30` clamps ONLY via element attributes — -`0x3C` (clamp-max-height), `0x3E` (clamp-min-height), `0x3D` -(clamp-max-width), `0x3F` (clamp-min-width) — read off `this`, the -element actually being resized. A decomp-wide grep for writers of those -four attributes turns up NOTHING: no runtime code ever sets them at -runtime. The clamp source is exclusively **authored DAT properties on -whichever element `ResizeTo` is called against, full stop.** There is no -hardcoded `SetMinSize` call anywhere in the class hierarchy; the earlier -"likely a hardcoded ResizeTo/SetMinSize call" phrasing in this doc was a -guess and is WRONG. - -The element `ResizeTo` is called against is not `0x2100002E`'s own root. -Per `docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md` -(lines ~83-107) and the slot table in -`docs/research/2026-08-11-fa-panel-structure.md` (row for `0x1000018E`), -retail's Character/Skills tab content is one child slot inside the -SHARED `gmPanelUI` host, LayoutDesc `0x2100006E`: - -```text -gmPanelUI host 0x100005FE 310 x 372 -content parent 0x10000180 300 x 362 (anchored all edges) -Character/Skills slot 0x1000018E panel id 11, 300 x 362 -top-center Dragbar 0x1000065C Type 2 -bottom-center Resizebar 0x10000660 Type 9 -``` - -`gmPanelUI::ResizeTo @0x004BC6E0` is a bare tailcall into -`UIElement::ResizeTo` — the HOST is what gets resized (via its -Resizebar), not `0x2100002E`'s content root; the content root's own -absent MinHeight/MaxHeight (confirmed above) is therefore consistent -with retail's actual mechanism, not evidence of a missing DAT property. - -**NOT PROBED by CT1.** The host elements above (`0x100005FE`, -`0x10000180`, `0x1000018E`, `0x1000065C`, `0x10000660`) were read from -the cited pseudocode doc, not re-probed live against the installed DAT -this session. CT6's first step is to probe those host slots directly -(min/max + resize authoring) before porting anything, then read -`UIElement_Resizebar::StartMouseResizing @0x0046B7E0` verbatim for the -drag-time clamp application. - -**Size tension for CT3/CT6 to resolve.** `0x2100002E`'s own root is -authored **300×600** (the Titles page alone is 300×575, plus the 25px -tab bar = 600 — §3), but retail mounts that content into the host's -**300×362** slot (`0x1000018E`). CT3's title ListBox height (`0x10000532` -is 270×455 per §3) and CT6's resize contract both assume a taller -available area than the host slot's authored 362px. This doc does not -resolve which number governs at runtime (scroll-clipped content inside a -fixed slot vs. the slot itself growing to accommodate) — CT6 must -resolve it from the decomp before implementing the resize contract, not -infer it from either number in isolation. - -## 5. The title-string table (DAT ground truth for `CharacterTitleTable::GetCharacterTitleFromID`) - -### Decomp chain (named-retail, `docs/research/named-retail/acclient_2013_pseudo_c.txt`) - -`CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0` does **not** -read a StringTable directly. It goes through TWO independent -enum-mapper indirections: - -1. `EnumMapper::GetString(0x10000006, titleId, &rawName)` (the static - 3-arg overload @`0x0041ac40`) — internally calls - `DBObj::GetDIDByEnum(&did, 0x10000006, /*category*/1)` to resolve the - **title EnumMapper object's DID**, then dispatches on - `MasterDBMap::DivineType` (0x24 = EnumMapper) to call - `EnumMapper::GetString(titleId, &rawName)` on it, giving a raw - canonical string name (NOT yet localized/hashed). -2. `compute_str_hash(rawName)` (ELF-style hash, already ported byte-exact - as `DatStringResolver.ComputeHash` — see its own citation of - `compute_str_hash @ 0x00413110`). -3. `StringInfo::SetStringIDandTableEnum(&info, hash, 0x10000007)` - (`@0x0042c760`) — internally calls - `DBObj::GetDIDByEnum(&did, 0x10000007, /*category*/4)` to resolve the - **title StringTable's DID**. -4. `StringInfo::GetString(&info)` (`@0x0042e760` → `InqString` → - `StringTableMetaLanguage::UnescapeString`) resolves the final localized - text — the same `StringTable.Strings[hash]` lookup - `DatStringResolver.Resolve(tableId, stringId)` already performs. - -`DBObj::GetDIDByEnum(enumValue, category)` (`@0x004153a0` → -`DBCache::GetDIDFromEnum @0x00413940`) is itself a **two-level indirection** -through a master map object (`this->m_MasterMapID`): look up `category` -in the master map to get an intermediate category-map DID, then look up -`enumValue` in THAT map to get the final DID. This is the exact same -mechanism already ported (empirically, not by name) as `RetailKeyNames`' -`0x2300000A`/`0x2300000B`/`0x23000007` constants -(`src/AcDream.App/UI/Layout/RetailKeyNames.cs`, citing "`DBCache::GetDIDFromEnumStatic` -category 4") — this slice confirms those three constants ARE exactly the -category-4 (STRINGTABLE) map's enum 4/5/3 entries (see table below), so -the existing `RetailKeyNames` port is independently cross-validated by -this investigation. - -### Live-DAT resolution (verified end-to-end this session) - -**The master-map / category-map tables and the `RetailKeyNames` -cross-validation below are UNPINNED probe output** — they come from the -same deleted `Assert.Fail` probe tests as the rest of this doc and are -not backed by a committed `InstalledDatFact` assertion (unlike the -`TitleStringTable_ResolvesWarMageEndToEnd` pin, which DOES commit the -final two DIDs and the end-to-end string resolution). Treat the -category-4 dump and the `RetailKeyNames` match column as this session's -observation, re-derivable from the DAT but not regression-guarded. - -`DatReaderWriter.DBObjs.EnumIDMap` (ACE's historical name: `DidMapper`, -file-type byte `0x25`) is the object type both master and category maps -use; `DatReaderWriter.DBObjs.EnumMapper` (file-type byte `0x22` on the -installed dat) is the flat id→string table type. - -**Master map, DID `0x25000000`** (`ClientEnumToID`/`ClientEnumToName`, -22 entries) — the categories relevant here: - -| category enum | name | category-map DID | -|---|---|---| -| 1 | EMAPPER | 0x25000001 | -| 4 | STRINGTABLE | 0x25000004 | - -**Category 1 (EMAPPER) map, DID `0x25000001`** — relevant entry: - -| enum | name | DID | -|---|---|---| -| 0x10000006 | CharacterTitle | **0x22000041** | - -**Category 4 (STRINGTABLE) map, DID `0x25000004`** — full dump (12 entries), -confirming the `RetailKeyNames` constants along the way: - -| enum | name | DID | cross-check | -|---|---|---|---| -| 0x00000003 | KeyMap | **0x23000007** | = `RetailKeyNames.DelimiterTableId` ✓ | -| 0x00000004 | KeyNameOverride | **0x2300000A** | = `RetailKeyNames.KeyNameTableId` ✓ | -| 0x00000005 | MetakeyNameOverride | **0x2300000B** | = `RetailKeyNames.MetaKeyNameTableId` ✓ | -| 0x10000007 | CharacterTitle | **0x2300000E** | (this slice's target) | -| 0x10000001 | UI | 0x23000001 | | -| 0x10000002 | UI_Pregame | 0x23000002 | | -| 0x10000003 | Preference | 0x23000003 | | -| 0x10000004 | UI_Options | 0x23000004 | | -| 0x10000006 | Options | 0x2300000D | | -| 0x00000002 | Calendar | 0x23000006 | | -| 0x00000006 | CommandSetup | 0x2300000C | | -| 0x00000007 | ActionDescription | 0x23000005 | | -| 0x00000008 | ServerEngine | 0x23000010 | | - -So: **the title EnumMapper is DID `0x22000041`; the title StringTable is -DID `0x2300000E`.** - -### End-to-end verification (ACE's `CharacterTitle` enum, `WarMage = 13`) - -``` -EnumMapper(0x22000041).IdToStringMap has 873 entries, including: - titleId 0 -> ID_CharacterTitle_Invalid - titleId 1 -> ID_CharacterTitle_Adventurer - titleId 5 -> ID_CharacterTitle_Life_Mage - titleId 13 -> ID_CharacterTitle_War_Mage - titleId 14 -> ID_CharacterTitle_Wayfarer - -ComputeHash("ID_CharacterTitle_War_Mage") = 0x0543AF05 -DatStringResolver(dats).Resolve(0x2300000E, 0x0543AF05) = "War Mage" -``` - -Byte-exact confirmation the chain is understood correctly end to end — -titleId 13 round-trips through the EnumMapper canonical-name lookup, the -retail hash function, and the StringTable localization lookup to produce -exactly "War Mage". - -### What CT2 needs to port - -1. Two `GetDIDByEnum`-shaped lookups (master map `0x25000000` → category - map → target DID) — CT2 can either hardcode the two resolved DIDs - (`0x22000041` for the EnumMapper, `0x2300000E` for the StringTable, the - way `RetailKeyNames` hardcodes its three) or port the two-level - indirection generically. **A THIRD consumer has now appeared** (found - this fix round): `gmAttributeUI::PostInit @0x0049DB70` resolves - per-attribute icon DIDs via `DBObj::GetDIDByEnum(statEnum, category - 0x10000002)` — see the "Row instantiation + icon-DID anchors" note in - §2. With `RetailKeyNames` (category 4) and the title chain (categories - 1 and 4) already hardcoding resolved DIDs, this third independent - category (`0x10000002`) is the point where CT2/CT5 should factor a - shared `GetDIDByEnum(enumValue, category)` helper instead of adding a - fourth ad-hoc hardcoded pair. -2. `EnumMapper.IdToStringMap[titleId]` → raw canonical name (already - readable via `dats.Portal.TryGet`). -3. `DatStringResolver.ComputeHash(rawName)` (already exists, no new code). -4. `DatStringResolver.Resolve(0x2300000Eu, hash)` (already exists, no new - code) for the final localized display string. - -No new DAT-reading primitives are required — `EnumMapper`/`EnumIDMap` are -already exposed by `DatReaderWriter.DBObjs`, and `DatStringResolver` -already does steps 3–4 for other consumers. - -## Corrections to the plan (summary) - -1. **Window constraints are NOT already 90% wired — and the clamp - mechanism is now VERIFIED, not guessed.** The plan's "Already in-tree" - bullet claims `DatConstraintSource` registration for the character - window; the actual `MountCharacter()` call sets no such field, and the - DAT layout itself authors no MinHeight/MaxHeight on its root to source - one from even if it were wired. `UIElement::ResizeTo @0x00463C30` - clamps only via element attributes `0x3C`–`0x3F`, which nothing writes - at runtime — the clamp source is always authored DAT properties on the - resized element, full stop, and the resized element is the SHARED - `gmPanelUI` host (`0x2100006E`, slot `0x1000018E`), not `0x2100002E`'s - own root. See §4's "Verified resize mechanism" for the full chain and - the unresolved 300×600-vs-300×362 size tension CT6 must still resolve. -2. **The row-template elements are not walkable via the normal - `ImportInfos(dats, layoutId)` overload.** `#375`'s prototype-skip logic - deliberately excludes same-layout template-list targets from the built - tree. CT5 must use `ImportInfos(dats, layoutId, elementId)` (the - targeted single-root overload) to read `0x10000248` - (`LayoutDesc 0x21000045`) and `0x10000536` (`LayoutDesc 0x2100005E`) — - documented here so CT5 doesn't waste a cycle rediscovering the same - "NOT FOUND" dead end this slice hit first. -3. **`RowHighlightSprite` IS wrong — SEALED, not merely flagged.** The - DAT's row-template Highlight state is `0x06000F93`, reached via - `gmAttributeUI::UpdateSelection`'s `SetState(6)` → - `InfoRegion::SetState` on the row itself; the current constant in - `CharacterStatController.cs` is `0x06001397`, which belongs to a - DIFFERENT mechanism (the spellbook row's selected-overlay child, - `UIElement_UIItem::SetSelectedState`). CT5 must correct the STAT rows' - `RowHighlightSprite` to `0x06000F93` and must NOT touch - `SpellbookRowStyle.cs` — see §2's "SEALED VERDICT" note for the full - anchor chain. -4. Everything else in the plan's "Retail recon" section (the Titles page - element roster, the header element ids, the PostInit binding order) - checks out exactly against the live DAT — no other corrections. -5. **Several findings above are probe-session observations, not - committed pins** — flagged this fix round so CT2–CT6 don't cite them - as regression-guarded facts: the header block's "two copies - geometrically identical" claim (only fonts/colors are pinned, not - full geometry — §1), the `0x06004CC2` "generic panel chrome" - characterization (§2), and the master-map/category-map dump plus the - `RetailKeyNames` cross-validation table (§5). The title chain's final - two DIDs and end-to-end string resolution ARE pinned - (`TitleStringTable_ResolvesWarMageEndToEnd`). - -## §CT6 — the shared-host resize clamp (2026-08-25 live probe) - -**Verdict: the resize clamp source is the shared `gmPanelUI` host -(`0x100005FE` in LayoutDesc `0x2100006E`), not `0x2100002E`'s own root -and not the Character/Skills slot `0x1000018E` either.** Probed with a -temporary test dumping layout `0x2100006E` via both the whole-layout -walk and the targeted single-root overload (deleted before commit; the -pattern is preserved by the committed pin -`CharacterPanelLiveDatTests.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract`). - -### Host element `0x100005FE` - -``` -Type=0x1000002F (gmPanelUI) X=0 Y=0 W=310 H=372 -MinWidth=310 MaxWidth=310 (fixed — no horizontal Resizebar authored) -MinHeight=372 MaxHeight=1000 -17 children, including (all DIRECT children of the host, siblings of -the content parent, not nested under it): - 0x10000180 content parent Type=3 X=5 Y=5 W=300 H=362 - 0x1000065C top-center Dragbar Type=2 X=5 Y=0 W=300 H=5 - 0x10000660 bottom-center Resizebar Type=9 X=5 Y=367 W=300 H=5 - (+ 14 border/corner chrome pieces, 0x10000653-0x10000662) -``` - -`MinHeight == 372 == the host's own authored default height`: retail's -Character/Skills window can only be resized TALLER (up to 1000px), never -shorter than its own authored default — this IS the "resizable in Y down -to an authored minimum" the owner reported; 372 is that floor, not an -arbitrary smaller number. - -The Character/Skills slot `0x1000018E` (the SAME structural role CT1 -probed for `0x2100002E`'s standalone root `0x10000227`, but reached -through the shared host this time) itself authors **no** MinWidth/ -MinHeight/MaxWidth/MaxHeight — confirming the clamp is exclusively the -HOST's, not layered again on the slot: - -``` -0x1000018E Type=8 (TabControl) X=0 Y=0 W=300 H=362 - Min=(null,null) Max=(null,null) - children: 0x10000228/29 (tab buttons), 0x1000022A (close button), - 0x10000538 (Titles tab), 0x10000539 (Titles page), - 0x1000022B/2C (Attributes/Skills pages) — same ids `0x2100002E` - imports, reached here via `0x1000018E`'s BaseElement inheritance - from `0x10000227` (same mechanism CT1 §3 documents for the Titles - row template's `0x1000052D`/`0x10000536` pair). -``` - -### Decomp chain confirming which element the drag clamp applies to - -- `UIElement_Resizebar::StartMouseResizing @0x0046B7E0`: `eax_1 = - this->vtable->GetParent()` then `UIElement::StartResizing(eax_1, - border, x, y)` — the drag state (`m_DragStartWidth/Height`, - `m_currentBorder`) is stashed on the RESIZEBAR'S PARENT, confirmed - live to be the host `0x100005FE` (§ above), not the content parent. -- `UIElement::StartResizing @0x0045fca0`: pure state setup - (`m_DragStartX/Y/Width/Height`, `m_currentBorder`) on `this` — no - clamp read here. -- `UIElement::MouseResizeElement @0x00461130`: the actual per-mouse-move - resize application. Reads `GetAttribute_Int(this, 0x3F)` (min width), - `0x3D` (max width), `0x3E` (min height), `0x3C` (max height) — all off - `this`, the SAME element `StartResizing` was called against. Since - that element is the host (per the GetParent() call above), the host's - own authored 0x3C..0x3F values are what govern every live drag. - -This matches — and completes — the "Verified resize mechanism" section -already in this doc (`UIElement::ResizeTo @0x00463C30`'s equivalent -clamp for the programmatic path): both the interactive drag -(`MouseResizeElement`) and the programmatic call (`ResizeTo`) read -0x3C..0x3F off the SAME element, and that element is always whichever -one is actually being resized — the host, never the character content -root or the slot. - -### Production wiring (`RetailUiRuntime.MountCharacter`) - -`MountCharacter` now imports `ElementInfo? hostConstraint = -LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x100005FEu)` (the same -targeted single-root overload CT1 established for row templates) and -passes it as `RetailWindowFrame.Options.DatConstraintSource`. - -**CORRECTED (CT6 fix round, BLOCKER B1, 2026-08-25):** this section -originally claimed the NineSlice chrome inset (`2 * -RetailChromeSprites.Border` = 10px) "is added automatically by -`RetailWindowFrame.ResolveConstraint`, giving the mounted outer frame -MinWidth=MaxWidth=320, MinHeight=382, MaxHeight=1010." That was WRONG. -Host `0x100005FE` is not a bare content element our own NineSlice -wrapper adds chrome to — per the geometry dumped above, it IS retail's -own complete outer window frame: 5px bevel + the 300×362 content parent -(`0x10000180`) + 5px = 310×372 exactly. Its authored 0x3C..0x3F values -are therefore already CHROME-INCLUSIVE. Composing the wrapper's own 10px -inset on top of an already chrome-inclusive source double-counted the -bevel: the mounted window's clamp said MinWidth=320 while its actual -mounted outer width was only 310 — the window opened already violating -its own minimum, silently "fixed" at runtime only because -`RetailWindowManager.ResizeTo`'s main-panel geometry sync forcibly -widened it to 320 despite `ResizeX=false`, which would have produced a -visible 15px right-bevel seam against the other eight main panels -sharing that sync. - -**Fix:** a new opt-out, -`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` (default -`false`, preserving every other window's existing content-plus-chrome -behavior), tells `ResolveConstraint` to apply a chrome inset of 0 when -the DAT source is itself already the outer frame. `MountCharacter` sets -it `true` for `hostConstraint`. The mounted outer clamps are now EXACTLY -the host's four raw values, no composed arithmetic: **MinWidth= -MaxWidth=310, MinHeight=372, MaxHeight=1000.** A new mount-time -invariant in `RetailWindowFrame.Mount` (throws if the just-mounted outer -extent falls outside its own just-computed clamp) guards against this -class of bug recurring for any window this path mounts. -`ResizeX=false`/`ResizableEdges=Bottom` (already correct in the -pre-CT6 code) match the fixed-width/bottom-only-Resizebar authoring -exactly — no change needed there. - -### Which size governs the mount default (S4, campaign-lead ruling, 2026-08-25) - -The "Size tension" flagged earlier in this doc (§4, "Correction to the -plan") — `0x2100002E`'s own root authored 300×600 vs. the host slot's -300×362 — is now resolved: **372px outer (362px content) is the number -that governs the MOUNTED DEFAULT**, and 600 is the content's own -authored design canvas that retail scroll-clips into the much smaller -host slot, never the size the window actually opens at. Concretely: -`0x2100002E`'s 300×600 root exists because its Titles page alone is -authored 300×575 (Y=25 offset + the 25px tab bar — §3 above) — that is -real, authored geometry, and the Titles page correctly stretches to fill -whatever height the mounted window offers via its own `LayoutPolicy` -(confirmed below). But retail never displays that full 600px canvas at -once outside of the Titles tab's own internal scroll: the shared -`gmPanelUI` host's content parent (`0x10000180`) is fixed at 300×362, -and 372 (362 + the 10px chrome bevel) is exactly the host's own -authored MinHeight — i.e., retail's Character/Skills window OPENS at -its own resize floor and can only be dragged taller, never shorter. -Pre-fix, `MountCharacter` left `Options.ContentHeight` unset, so it fell -back to `content.Width`/`content.Height` — the raw 600px canvas — giving -a stale 610px (600 + 10 chrome) mounted default that was never retail's -actual opening size and was 238px taller than the true floor. Fixed by -setting `Options.ContentHeight = 362f` explicitly (the same host -content-parent height this section already probed and cited, not a new -number). At the corrected 372px default, the authored page composition -(header 112px + list 160px + divider + footer) is the true 362px design; -the 9 attribute/vital rows (9 × 20 = 180px content) OVERFLOW the 160px -list immediately, so the stat list's scrollbar is active from the moment -the window opens — this is retail-correct (see §S2 below for what -"active" means given the corrected `HideWhenDisabled` finding), not a -regression introduced by the fix. - -### Titles-page list reflow - -The Titles page's own container (`0x10000539`) and its ListBox -(`0x10000532`) both already carry a REAL authored `LayoutPolicy` in the -committed/installed layout (live-verified: `LayoutPolicy is not null` -for both) that correctly stretches with the mounted content's height — -no code was needed to make the PAGE itself reflow. - -**CORRECTED (CT6 fix round, S3, 2026-08-25):** this section originally -went on to claim "the only missing piece was the ListBox's own -compatibility fallback (`Anchors = Left|Top|Bottom`, engaged only when -`LayoutPolicy is null` — a no-op on the real DAT, but needed for -synthetic/test layouts...), added in `CharacterTitlesController.Bind`." -That framing was wrong: both `0x10000532` and `0x10000539` author -`HasOriginalParentSize=true` (the field `DatWidgetFactory` gates -`LayoutPolicy` assignment on), confirmed both on the real installed DAT -and the committed fixture — `LayoutImporter`/`DatWidgetFactory` ALWAYS -assigns a real `LayoutPolicy` to these elements, so the `if -(listBox.LayoutPolicy is null)` branch never ran anywhere, not even in -the "synthetic/test layouts" case it was written to cover. It was dead -code, not a harmless no-op fallback. Deleted from -`CharacterTitlesController.Bind`; a new -`CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize` -pin asserts `HasOriginalParentSize` on both `0x10000532` and `0x10000539` -to guard the deletion against future DAT drift. - -### Correction to the scrollbar-visibility finding (S2, 2026-08-25) - -The CT6 landing notes (in the campaign plan ledger) claimed -`UiScrollbar`'s own `IsPresentationVisible`/`IsModelDisabled` "already -draw the correct full-track 'disabled' thumb when content fits -(`HideWhenDisabled` defaults false)." That had the authored default -BACKWARDS. Both `0x1000023E` (the shared Attributes/Skills list -scrollbar) and `0x10000533` (the Titles list's own scrollbar) author -property `0x79` (`HideWhenDisabled`) **TRUE** in the committed fixture — -verified directly against `tests/AcDream.App.Tests/UI/Layout/fixtures/character_2100002E.json`, -property `"121"` (=0x79) carries `"BoolValue": true` on both elements. A -fitting list HIDES the bar entirely (`IsPresentationVisible` false); it -does not leave a full-track "disabled" thumb on screen. The CODE in -`CharacterStatController.RebuildActiveList` was already correct — -`.Visible = true` only keeps the bar bound in the tree, and -`IsPresentationVisible` is the actual show/hide computation — only the -description was wrong. Given the S4 correction above (mount default is -now the compact 372px floor), the practical consequence is: the stat -list's scrollbar is VISIBLE and interactive from the moment the window -opens (rows overflow at the default), and DISAPPEARS once the window is -grown enough that all rows fit — the opposite of what the uncorrected -description implied. - -### A latent anchor-baseline bug this slice surfaced and fixed - -`CharacterStatController.RebuildActiveList`'s (and, before CT6, only the -Skills tab's) `UiScrollablePanel` viewport is constructed with `Height = -statList.Height` **before** the window's first anchor pass ever grows -`statList` from its raw DAT-authored height (160px) up to its actual -mounted height. Left alone, the viewport's own `Left|Top|Bottom` anchor -captures its baseline margins lazily on ITS OWN first `ApplyAnchor` call -— which happens AFTER `statList` has already grown — measuring a bogus -non-zero bottom margin that then permanently caps the viewport short on -every later resize (the exact `#372`/`#412`-class bug `UiTemplateListBox -.Viewport`'s own lazy getter already works around). Fixed by calling -`viewport.CaptureCurrentAnchorBaseline()` immediately after -`statList.AddChild(viewport)`, while `viewport.Height` still exactly -equals `statList`'s own current (pre-reflow, zero-margin) height. This -was previously unexercised/untested for Skills (no test asserted its -viewport's exact height against a real window resize) and is now proven -by `CharacterStatControllerTests -.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar`. - -### No register row - -Every number in this section is either a live-probed authored DAT value -or a structural wiring/anchor-capture-correctness fix — nothing here is -inferred or approximated. - -**CORRECTED (CT6 fix round, N5, 2026-08-25):** at initial landing, this -sentence was not actually true — the mounted 320/382/1010 clamp WAS an -inference (BLOCKER B1: the host's already chrome-inclusive values with a -second, redundant chrome inset composed on top). After the B1 fix -removes that composition, the mounted clamp is now literally the host's -own four probed values (310/310/372/1000) with zero arithmetic applied, -so the sentence holds for real. The S4 content-height default (362) is -likewise not a new inferred number — it is the same host content-parent -width/height (`0x10000180`, 300×362) this section already probed and -cited above, applied to `Options.ContentHeight` instead of being left -unset. diff --git a/docs/research/2026-08-25-campaign-as-ground-truth.md b/docs/research/2026-08-25-campaign-as-ground-truth.md deleted file mode 100644 index 2b09c667..00000000 --- a/docs/research/2026-08-25-campaign-as-ground-truth.md +++ /dev/null @@ -1,250 +0,0 @@ -# Campaign AS ground truth — assess/examination window, PLAYER targets - -**AS1 synthesis (2026-08-25).** Three-lens research: our pipeline map, ACE -wire truth (`references/ACE`, cross-checked byte-identical against -`references/Chorizite.ACProtocol`), and the named 2013 retail decomp -(`docs/research/named-retail/acclient_2013_pseudo_c.txt`). This document is -the ORACLE for every Campaign AS slice. Addresses are acclient 2013 -v11.4186; property ids verified against ACE enums. - -## 0. The headline findings - -1. **The 0x00C9 parse is already complete.** `AppraiseInfoParser` - (`src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`) parses every - flag retail parses, in retail's exact section order, INCLUDING - `ArmorLevels = 0x4000` (nine u32s). `Parsed.ArmorLevels` is a dead - field — no production reader. **No wire/parse work is needed.** -2. **Everything missing already arrives from ACE** on a successful player - assess (see §3/§4). The whole campaign is client-side composition. -3. **The core defect is an element MIS-MAPPING** in - `AppraisalUiController.ApplyCreature(character: true)` - (`src/AcDream.App/UI/Layout/AppraisalUiController.cs:673-708`): retail's - `CharExamineUI` ctor `@0x004AD3C0` binds `0x10000150` = Heritage, - `0x10000151` = Profession(title), `0x10000152` = PlayerKiller, - `0x1000053A` = AllegianceName — we feed them raw-HeritageGroup-string / - AllegianceName / MonarchsName / the invented literal - `"Assessment incomplete"` (zero retail provenance; the only occurrence - in the repo). -4. **Zero tests exercise `AppraisalView.Character`** — how the mis-mapping - survived the original Slice 3 gate (that gate checked page selection - only). -5. Retail composes these lines from `u"..."` code literals (not - StringTables), same as the item report — matching retail means literals - here, with DAT/EnumMapper lookups exactly where retail does them - (gender/heritage/title chains, attribute names). - -## 1. Retail class map - -`gmExaminationUI` (element-type 0x1000001C, `Register @0x004AB780`; -pinnable `gmFloatyExaminationUI` = 0x1000004C). LayoutDesc `0x2100006B` -(DAT-side; already our mount). Sub-UIs implement `ExamineSubUI` -(`acclient.h:54747`): `Init` / `SetAppraiseInfo(profile, isNewObject)` / -`Show`. - -| Class | Methods that matter | -|---|---| -| `gmExaminationUI` | ctor `0x004AB2B0`; `PostInit 0x004AD6A0`; `RecvNotice_ExamineObject 0x004AB7B0`; dispatcher `SetAppraiseInfo 0x004ADAE0`; `SetTitleText 0x004AD930`; `UseTime 0x004AB530` (0.75 s combat refresh) | -| `BasicCreatureExamineUI` | ctor `0x004ACD30`; `SetAppraiseInfo 0x004B3F70`; `SetLevelValueText 0x004B3E70`; `AddLineToMiscInfo 0x004ABBB0` | -| `CreatureExamineUI` (monsters) | `SetAppraiseInfo 0x004B3FF0` | -| **`CharExamineUI` (players)** | ctor `0x004AD3C0`; **`SetAppraiseInfo 0x004B45F0`** — the full player line composer | - -Sub-UI dispatch (`0x004ADAE0`): creature profile AND (String 5 Template OR -Int 261 CharacterTitleId) → CharExamineUI. Our `SelectView` -(`AppraisalUiController.cs:920-928`) already matches. - -Widget ids: title `0x1000012D`; level value `0x1000014C`; attribute list -`0x10000149`; extra list `0x10000335`; row template label `0x1000012A` / -value `0x1000012B`; header texts `0x10000150/0x10000151/0x10000152/0x1000053A`; -viewport `0x10000148`. - -## 2. Player line composition (CharExamineUI::SetAppraiseInfo @0x004B45F0) - -"colorIdx" = 4th arg of `UIElement_Text::SetTextWithFont @0x0046A500` — -the authored alternate-font-color index (attr 0x1B/0x1D list): 0 default, -**1 = buffed/green, 2 = debuffed/red, 3 = unknown/failed** (semantics -pinned by ACE `CreatureProfile.cs:44-45`). Exact authored RGBA lives in -LayoutDesc 0x2100006B (DAT), not the binary. - -### 2a. Fixed header elements - -| Element | Source | Composition | -|---|---|---| -| Title bar `0x1000012D` | `SetTitleText @0x004AD930`: String 52 override else appropriate object name; stack-count prefix | then the char path OVERWRITES with `AllegianceData::GetFullName @0x005B6950` = `AllegianceSystem::GetTitle(rank=Int 30, heritage=Int 188, gender=Int 113) + " " + name` ("Baroness Aluvia"); plain name when no rank title | -| Heritage `0x10000150` | Int 113 Gender, Int 188 HeritageGroup (Int 2 CreatureType only if heritage==0) | `AppraisalSystem::InqGenderHeritageDisplay @0x005B5AE0` → `" "`. Gender: EnumMapper `0x10000001`; heritage: EnumMapper `0x10000002` with hardcoded overrides `2→"Gharu'ndim"`, `5→"Umbraen"`, `13→"Olthoi"`; creature fallback EnumMapper `0x10000005` (underscore→space) | -| Title `0x10000151` | Int 261 CharacterTitleId, fallback String 5 Template | `CharacterTitleTable::GetCharacterTitleFromID @0x005C6ED0`: EnumMapper `0x10000006` → `compute_str_hash` → StringTable behind table-enum `0x10000007`. Current display title only. Same pipeline as `CharacterTitleResolver` (CT campaign). | -| PK `0x10000152` | **NOT from the appraisal payload** — local `cur_weenobj` PWD bits: `IsPK() @0x0058C8B0` → `u"Player Killer"`, `IsPKLite() @0x0058C8A0` → `u"Player Killer Lite"`, else `u"Non-Player Killer"`. Skipped (left cleared) if the weenie is gone. Ours: `ClientObject.PublicWeenieBitfield` + `PlayerKillerStatusBitfield` (bits 0x20 / 0x02000000) — the exact port already exists (`ClientObject.cs:439-451`, per AP-109's CT4 correction). | -| Allegiance `0x1000053A` | String 47 AllegianceName | plain set, element cleared first; only inside the `Int 30 AllegianceRank >= 1` gate | -| Level `0x1000014C` | Int 25 | `SetLevelValueText @0x004B3E70`: >0 → comma-grouped; ≤0/absent → `"???"` | - -### 2b. Extra-info list rows (list `0x10000335`), EXACT ORDER - -Every row = `AddLineToMiscInfo(label, value, colorIdx) @0x004ABBB0`. -Literals: `"%d"` @0x794344/0x7A0184; `"*%d"` @0x7B110C; `u"???"` @0x7B0F34; -`L""` spacer @0x794320. - -| # | Gate | Label | Value | Color | -|---|---|---|---|---| -| 1 | **PRESENCE of Int 281** Faction1Bits — `AppraisalProfile::InqInt @0x005B3830` returns found/not-found, NOT value≠0 (AS4-review adjudicated); present-and-zero → `Society: ???` | `Society:` | society name (+` ~ ` band): bit1→"Celestial Hand"+Int 287, bit2→"Eldrytch Web"+Int 288, then the `???` arm, THEN bit4→"Radiant Blood"+Int 289 (retail's odd test order); unrecognized→`???`. Bands: 1–100 Initiate, 101–300 Adept, 301–600 Knight, 601–1000 Lord, 1001–1500 Master; outside bands → name alone | vs LOCAL player's Faction1Bits, SAME-BIT-FIRST (`@0x004b49fd/@0x004b4a49/@0x004b4a8b`): local carries the target's bit → 1 (green) even when local also carries other bits; else local carries any other society bit → 2 (red); else 0 | -| 2 | Int 30 ≥ 1 | allegiance rows | Str 21 MonarchsTitle absent → `Alleg. Monarch:` + `%d Follower`/`%d Followers` (Int 35, clamp ≥0). Str 21 present, Str 35 PatronsTitle absent → `Monarch:`+Str21. Both present, equal → one row `Monarch/Patron:`. Different → two rows `Monarch:` / `Patron:` | 0 | -| 3 | any of 9 AL > 0 | spacer, then `Head/Chest/Groin`, `Bicep/Wrist/Hand`, `Thigh/Shin/Foot` | per part `"%d"`, or `"*%d"` with (value−9999) when ≥9999 (unenchantable sentinel); value cell = `"AL: %s/%s/%s"`. Nine dwords in order head, chest, groin(=Abdomen), bicep(=UpperArm), wrist(=LowerArm), hand, thigh(=UpperLeg), shin(=LowerLeg), foot | 0 | -| 4 | Int 307\|313\|314 > 0 | spacer, then `Dmg/CritDmg` | `Rating: %d/%d` ← (307 DamageRating, 314 CritDamageRating); 313 gates only | 0 | -| 5 | Int 308\|315\|316 > 0 | [spacer if row 4 absent] `Dmg/CritDmg` | `Resist: %d/%d` ← (308, 316); 315 gates only | 0 | -| 6 | Int 350\|351 > 0 | [spacer if none yet] `DoT/Life:` | `Resist: %d/%d` ← (350, 351) | 0 | -| 7 | any of rows 4–6 shown | trailing spacer | | | -| 8 | Str 10 Fellowship | `Fellowship:` | verbatim | 0 | -| 9 | Str 43 DateOfBirth | `Arrived in Dereth:` | verbatim (server-formatted) | 0 | -| 10 | Int 125 Age | `Time in Dereth:` | `ClientUISystem::DeltaTimeToString(seconds)` | 0 | -| 11 | Int 181 ChessRank | `Chess Rank:` | `%d` | 0 | -| 12 | Int 192 FakeFishingSkill | `Fishing Skill:` | `%d` | 0 | -| 13 | Int 43 NumDeaths | `Deaths:` | ≤0 → `Has never died`, else `%d` | 0 | -| 14 | Int 262 NumCharacterTitles | `Titles Earned:` | `%d` | 0 | -| 15 | unconditional (per decomp — see R3) | `* = Unenchantable` in the LABEL slot, empty value | legend | 0 | - -Then the tail calls `BasicCreatureExamineUI::SetAppraiseInfo @0x004B3F70` -(level + attribute token update). - -Note: retail's ratings format strings are `"%Rating: %d/%d"` / -`"%Resist: %d/%d"` — the leading `%R` is invalid printf that msvcrt renders -as literal `R…`, so the display text is `Rating: x/y` / `Resist: x/y`. Our -existing `CreatureAppraisalRows.BuildExtra` output matches the display. - -### 2c. Attribute/vital rows (list `0x10000149`) - -Created once in ctor order **Str, End, Coord, Quick, Focus, Self** then -**Health (percent shown), Stamina, Mana**; labels via -`SkillSystem::InqAttributeName @0x005C8D90` (StringTable-backed). -`AttributeInfoRegion::Update @0x004F1D90`: `"%d"`, 0 → `"???"`; color: -success==0 → 3, else enchant HI bit → 1 (green) / LO-without-HI → 2 (red) -(`InqAttributeEnchantmentMod @0x005B5EC0`). -`Attribute2ndInfoRegion::Update @0x004F1E80`: `%d/%d`, Health -`%d/%d (%d %%)`; **success==0: Health shows `%d %%` ONLY; Stamina/Mana show -`???`**; same color rule on the MAX-vital bits. - -## 3. ACE wire truth (what the local server sends for a player) - -Serialization: flags dword, Success dword, then sections in retail's parse -order (Int 0x1 → Int64 0x2000 → Bool 0x2 → Float 0x4 → String 0x8 → DID -0x1000 → SpellBook 0x10 → ArmorProfile 0x80 → CreatureProfile 0x100 → -WeaponProfile 0x20 → HookProfile 0x40 → ArmorEnch 0x200 → WeaponEnch 0x800 -→ ResistEnch 0x400 → ArmorLevels 0x4000). Byte-identical vs Chorizite. -Player targets set: Int (always), String (usually), DID (appearance dids), -CreatureProfile (always, even failed), ArmorLevels (Success only). - -**ArmorLevels** (`ACE .../Structure/ArmorLevel.cs:85-96`): nine u32 -Head, Chest, Abdomen, UpperArm, LowerArm, Hand, UpperLeg, LowerLeg, Foot. -Sent when `Success && (Player || !Attackable)`. Values are **BUFFED** -(base + enchant mod per covering Clothing layer, clamped ≥0); if EVERY -covering layer is unenchantable the part gets **+9999** (the `*` sentinel). -Chorizite's "BaseArmor*" naming is misleading — trust ACE. - -**Identity ints:** Gender **113**, HeritageGroup **188**, -PlayerKillerStatus **134** (ACE enum NPK 0x02 / PK 0x04 / PKLite 0x40 — NOT -the PWD bit layout; retail reads the local weenie's PWD bits instead, and so -do we), CharacterTitleId **261**, NumCharacterTitles **262**, Level **25**. -The display strings never ride the wire — client-composed. - -**Option-gated extras** (removed from the tables when the TARGET's option -is off; `AppraiseInfo.cs:352-366`): DateOfBirth str **43** -(AllowOthersToSeeYourDateOfBirth), Age int **125**, ChessRank int **181**, -FakeFishingSkill int **192**, NumDeaths int **43**, NumCharacterTitles int -**262**. Client renders what's present — no client-side option logic. - -**Ratings** (Success only, nonzero only, straight into IntStats): -Damage **307** (+5 heritage-weapon bonus applied server-side), -DamageResist **308**, Crit **313**, CritDamage **314**, CritResist **315**, -CritDamageResist **316**, HealingBoost **323**, NetherResist **331**, -DotResist **350**, LifeResist **351**, GearMaxHealth **379**, PKDamage -**381**, PKDamageResist **382**. Retail renders only -307/314 · 308/316 · 350/351 (313/315 gate; 323 read but never rendered). - -**Allegiance/faction:** AllegianceName str **47**; monarch targets get -AllegianceFollowers int **35**; non-monarch get MonarchsTitle str **21** -(server-composed " ") + PatronsTitle str **35**; -AllegianceRank int **30**; Faction1Bits int **281** (masked to 0x7); -Society ranks ints **287/288/289**; Fellowship str **10**. - -**Failure semantics:** failed assess (target's Deception beats examiner's -AssessPerson, target opted into AttemptToDeceiveOtherPlayers) → Success=0 -but ACE STILL sends full int/string/DID tables; only CreatureProfile -attributes (ShowAttributes flag 0x8), ratings, and ArmorLevels are gated. -Target sees "X tried and failed to assess you!"; retries inside 5 s -auto-fail. Guid-not-found → `Flags=0, Success=0` only. - -## 4. Current acdream state (what exists, what's wrong) - -- Window: `AppraisalUiController` (LayoutDesc 0x2100006B, root 0x100005F2), - mounted as independent top-level floaty; subviews Item/Creature/ - Character/Spell; `UiText` + `UiItemList`/`UiTemplateListSlot` rows from - DAT template `0x10000166`; `CreatureAppraisalLayeredList` two-list - chrome/foreground split over one `UiScrollable`; animated clone in - `UiViewport 0x10000148` (`CreatureAppraisalPresentation`). -- Route: `GameEventWiring.cs:1003` → `LiveSessionEventRouter.OnAppraisal` → - `RetailUiRuntime.HandleAppraisal` → `Apply(Parsed)`. -- Stale-response rejection, 0.75 s visible-combat refresh, busy-cursor - balance: present and pinned. -- Ratings rows exist (`CreatureAppraisalRows.BuildExtra`, - `CreatureAppraisalRows.cs:97-146`) with the retail grouping pinned by - `ExtraRatingsFollowRetailGroupingFormattingAndSeparators`. BuildExtra - takes only a `PropertyBundle` — it cannot see `Parsed.ArmorLevels` - (signature change needed; armor rows precede rating rows). -- Attribute rows: nine ordered rows + `???` failure semantics + enchant - bit styles pinned in `CreatureAppraisalRowsTests`. -- Reusable CT ports: `CharacterIdentityText` (gender/heritage composition, - `CharacterIdentityText.cs:81-117`), `CharacterTitleResolver` (EnumMapper - 0x22000041→hash→StringTable 0x2300000E — the SAME retail pipeline; the - decomp's 0x10000006/0x10000007 are element-scope enum ids resolved to - those DAT ids), `PlayerKillerStatusBitfield`. - -### Gap ledger - -| # | Gap | Fix slice | -|---|---|---| -| G1 | Heritage slot fed raw string prop instead of composed gender+heritage | AS2 | -| G2 | Title slot fed AllegianceName; title never displayed | AS2 | -| G3 | PK slot fed MonarchsName; PK never displayed | AS2 | -| G4 | Armor-level trio absent (parsed, never read) | AS3 | -| G5 | `0x1000053A` shows invented "Assessment incomplete" instead of AllegianceName | AS2 | -| G6 | Society/faction row absent | AS4 | -| G7 | Monarch/Patron/Followers rows absent | AS4 | -| G8 | Configurable extras absent (Fellowship/DOB/Age/Chess/Fishing/Deaths/Titles + legend) | AS3 (legend) + AS4 | -| G9 | Title-bar allegiance rank prefix absent (AP-109's 17-function table) | AS5 | -| G10 | Zero Character-subview test coverage | every slice adds | - -## 5. AS1 rulings on the open questions - -- **R1 (String 52 override):** keep current `BuildTitle` behavior — String - 52 is absent for players; harmless either way. -- **R2 (Int 323 HealingBoost):** retail reads it and never renders it; we - keep discarding it. Do not add a row. -- **R3 (`* = Unenchantable` legend unconditional):** SETTLED AT SOURCE at - the AS3 review — the legend at `004b5d7d-004b5dcd` sits OUTSIDE the - `if (InqCreature(...))` block (opened `004b4638`, closed at pseudo-C line - 189962), so the BN-flattening theory is dead: retail adds it - unconditionally, into the same `m_extraInfoList` (0x10000335), text in - the LABEL slot. Retail's outside-the-gate placement is unobservable in - practice (the dispatcher only routes to CharExamineUI when a creature - profile exists), so "always, on the character path" is the faithful - port. The connected gate keeps its side-by-side check for the VISUAL - question only; do not re-investigate the structure. -- **R4 (ratings spacer discipline):** the BN output lost the flag - assignments in CharExamineUI; use `CreatureExamineUI::SetAppraiseInfo - @0x004B3FF0`'s clean version of the SAME logic: one spacer before the - first ratings row, one trailing spacer if any rating row was emitted. -- **R5 (colorIdx RGBA):** authored in LayoutDesc 0x2100006B text attrs - (0x1B/0x1D). CORRECTED at the AS4 review: the row model CARRIES the - style (`CreatureAppraisalValueStyle`), but `ResolveColor` is a - deliberate no-op — every row renders the authored default color until - AP-110's "creature FontInfo-list selection" residual lands. Compute the - semantic state, never hardcode RGBA; the Society green/red is therefore - MODEL-ONLY today and INVISIBLE at the connected gate (the AS6 script - must not gate on it). -- **R6 (literals):** the header/extras labels are code literals in retail — - match them as literals (consistent with the item report), with DAT - lookups only where retail does them (gender/heritage EnumMappers, title - chain, attribute names). -- **R7 (PK source):** from the assessed `ClientObject`'s PWD bits via - `PlayerKillerStatusBitfield`, NOT PropertyInt 134 (AP-109's CT4 lesson); - leave the element cleared when the object is no longer in the table. -- **R8 (rank source for GetFullName):** `props.GetInt(0x1E)` — the - appraisal bundle carries AllegianceRank; never read - `RuntimeAllegianceState` for another player's rank. diff --git a/docs/research/2026-08-25-campaign-as-test-script.md b/docs/research/2026-08-25-campaign-as-test-script.md deleted file mode 100644 index 38e75ece..00000000 --- a/docs/research/2026-08-25-campaign-as-test-script.md +++ /dev/null @@ -1,147 +0,0 @@ -# Campaign AS — connected gate script (AS6, user-driven) - -**Purpose:** live verification of the assess/examination-window parity -campaign (AS2–AS5) against ACE, retail side-by-side as the oracle. -Launch: the normal connected launch (`ACDREAM_RETAIL_UI=1`, live ACE at -`127.0.0.1:9000`, `ACDREAM_PAK_PATH=\artifacts\owner-gate\acdream-v5.pak`). -Sections marked **[TWO-CLIENT]** use the FA-campaign account pair -(`testaccount`/`+Acdream` + `testaccount2`/`+Horan`) — assess the OTHER -player. Where a second retail client is available, compare the same assess -performed from retail. - -**Two standing rulings, read before judging:** -- **Row COLORS are out of scope this gate.** The Society green/red (and any - buffed/debuffed row tint) is MODEL-ONLY today — `ResolveColor` renders - the authored default until AP-110's FontInfo-list residual lands. Judge - TEXT content, ordering, and presence only. -- **The paperdoll is our registered intentional deviation (AD-114):** ours - mirrors the target's live motion; retail's clone plays its own decoupled - idle cycle. A mismatch there is EXPECTED and correct. - ---- - -## 1. Header identity block (AS2) [TWO-CLIENT] - -Assess the other player. Under the title bar, four lines: - -1. **Gender+heritage**: " " composed (e.g. "Female - Aluvian") — not a raw property string. PASS: matches retail's line for - the same target. -2. **Title**: the target's CURRENT display title (e.g. "War Mage"). Have - the target change their display title (Character panel → Titles → Set - as Display Title), re-assess: the new title shows. No title set → - the line is empty (ours deliberately clears where retail can show a - stale previous target's title — register AD-115; do not fail us for - being cleaner than retail here). -3. **PK status**: "Non-Player Killer" for a normal character. (If a PK or - PKLite character is available, verify its variant.) -4. **Allegiance name**: shown only when the target is in a named - allegiance (sworn, rank ≥ 1); otherwise empty. -5. **Failed assess**: on the TARGET client enable Deception's - "Attempt to Deceive" option (retail Options → Character) if available - with a high-Deception target; a failed assess shows "???" attributes - BUT the four header lines above still render (identity rides even on - failure). Best-effort — needs a target whose Deception beats the - examiner's Assess Person. -6. **The invented literal is gone**: assess a MONSTER and fail (or any - monster assess) — the line that used to read "Assessment incomplete" - shows nothing, matching retail. - -## 2. Per-bodypart armor levels (AS3) [TWO-CLIENT] - -1. Target wearing armor: three rows appear — - "Head/Chest/Groin AL: a/b/c", "Bicep/Wrist/Hand AL: a/b/c", - "Thigh/Shin/Foot AL: a/b/c" — values matching retail's assess of the - same target (buffed values: cast an armor buff on the target and - re-assess; numbers rise). -2. Target naked (bank the armor): the three rows AND their leading blank - line disappear entirely. -3. Unenchantable coverage (if an unenchantable piece is available): the - affected group shows "*N" (N = the level without the sentinel). -4. **The R3 legend check (explicit retail side-by-side):** "* = - Unenchantable" renders as the LAST line of a player assess. Our port - shows it UNCONDITIONALLY (decomp-proven structure). Confirm retail - does the same on a player with NO starred values and again with NO - armor at all. If retail hides it in either case, report it — that - flips ruling R3 and we change ours. - -## 3. Ratings block (regression) - -On a target with ratings (augmented/geared): "Dmg/CritDmg Rating: x/y", -"Dmg/CritDmg Resist: x/y", "DoT/Life: Resist: x/y" rows as before, -each appearing only when its family is nonzero. Unchanged behavior — -spot-check only. - -## 4. Society / allegiance / fellowship rows (AS4) [TWO-CLIENT] - -1. **Fellowship**: form a fellowship (FA campaign flow), assess the other - member: "Fellowship: " row appears; disband → row gone on - re-assess. -2. **Allegiance cascade** (best-effort — ACE's swear-at-close-range quirk - #384 may block creating fresh vassals): assess a sworn character — - "Monarch:" (and "Patron:" when different, "Monarch/Patron:" when the - same person). Assess a MONARCH: "Alleg. Monarch:" + "N Follower(s)". -3. **Society** (only if a faction-joined character exists on this ACE): - "Society: " with the rank suffix (" ~ Initiate" … " ~ Master") - per the target's standing. TEXT only — ignore colors (standing ruling). - -## 5. Target-configurable extras (AS4) [TWO-CLIENT] - -On the TARGET client open Options → the retail Character tab and toggle -each "Allow others to see..." option, re-assessing from the other client -after each change (each row appears iff the target allows it — the server -gates; we render what arrives): - -| Toggle on target | Row | Expected value shape | -|---|---|---| -| Date of Birth | `Arrived in Dereth:` | server-formatted date, verbatim | -| Age | `Time in Dereth:` | retail duration format — bare units, e.g. "3mo 2d 5h 12m 40s"; zero components omitted except seconds | -| Chess Rank | `Chess Rank:` | number | -| Fishing Skill | `Fishing Skill:` | number | -| Number of Deaths | `Deaths:` | number; a deathless character shows "Has never died" as the value (label stays "Deaths:") | -| Number of Titles | `Titles Earned:` | number | - -PASS: each row toggles with its option, the labels match retail exactly, -and the full extras ORDER matches retail: Society → allegiance rows → -[blank] → armor trio → [blank] → ratings → [blank] → Fellowship → Arrived -→ Time → Chess → Fishing → Deaths → Titles → "* = Unenchantable". - -## 6. Title-bar allegiance rank prefix (AS5) [TWO-CLIENT, best-effort] - -Assess a sworn character with an allegiance rank: the WINDOW TITLE reads -" " (e.g. "Yeoman Horan") — heritage- and -gender-specific title from retail's table. The character panel (F9) name -line of YOUR OWN sworn character shows the same prefix. Unsworn/rank 0 → -plain name in both places. (Creating a fresh sworn pair may be blocked by -#384 — use any already-sworn character; otherwise mark SKIPPED.) - -## 7. Combat auto-refresh - -Enter combat mode with the exam window open on a player: the window -refreshes ~every 0.75 s; the AL trio, extras, and header lines persist -and update (buff the target's armor mid-watch: values change without -re-assessing manually). - -## 8. Regression sweep (5 minutes) - -- Monster assess: species line + stat rows + ratings as before; NO armor - trio, NO legend, nothing where the old invented literal was. -- Item assess: the full item report unchanged; inscription flow intact. -- Spell assess unchanged. -- The animated paperdoll mirrors the target's motion (our AD-114 - deviation — expected). -- Character panel (F9): CT-campaign behaviors intact (titles page, header - identity block, resize clamps 372–1000, scrollbar hand-off). - ---- - -## Report back - -Per section PASS/FAIL plus anything odd. The three answers that matter -most: -1. §5 — does every option-gated row toggle correctly with the target's - own options, in retail's exact order? -2. §2.4 — does retail show the "* = Unenchantable" legend unconditionally - (our R3 reading), or does it hide it in some case? -3. §1 — is the identity block exact against retail for the same target - (composition, title live-update, PK text)? diff --git a/docs/research/2026-08-25-campaign-ct-test-script.md b/docs/research/2026-08-25-campaign-ct-test-script.md deleted file mode 100644 index 1f5094ba..00000000 --- a/docs/research/2026-08-25-campaign-ct-test-script.md +++ /dev/null @@ -1,179 +0,0 @@ -# Campaign CT — connected gate script (CT7, user-driven) - -**Purpose:** live verification of the character-panel parity campaign -(CT1–CT6) against ACE. Launch: the normal connected launch -(`ACDREAM_RETAIL_UI=1`, live ACE at `127.0.0.1:9000`, -`ACDREAM_PAK_PATH=\artifacts\owner-gate\acdream-v5.pak`). -Open the character panel (F9 / toolbar). Retail side-by-side comparison -is the oracle for every visual item. - -Useful ACE console helpers: title grants come from quests/admin — check -`@acecommands` for a title-grant command; `@grantxp` for levels. - ---- - -## 1. Header identity block (CT4) — Attributes AND Skills tabs - -1. **Name line**: the plain character name (rankless characters — the - allegiance rank prefix is a registered deferral, AP-109). -2. **Heritage line**: " " — e.g. - "Female Aluvian War Mage" when a display title is set; just - "Female Aluvian" when none. PASS: matches retail's composition and - spacing exactly; a title beginning with "The" shows unmangled. -3. **PK line**: "Non-Player Killer" (or "Player Killer" / "Player - Killer Lite" on a PK/PKL character) in PURE WHITE. PASS: correct - text + color on BOTH the Attributes and Skills tabs. -4. **Level number**: pale gold with outline (authored color — compare - against retail's level display side-by-side; the owner reported ours - was previously off). -5. **Luminance pair**: on a sub-200 character, NO luminance caption or - value renders. (A level ≥ 200 character with MaximumLuminance shows - "Luminance:" and " / " — verify only if such a - character is available.) -6. **Live update**: set a display title (see §2) — the heritage line - updates the moment the server confirms, with no relog and no panel - re-open. - -## 2. Titles tab (CT3) - -1. Click the **Titles** tab. PASS: the page shows "Current Display - Title:" + the current title (or "Unknown" only when the server's - title id fails to resolve — normally a real title or the authored - empty state), the "All Available Titles:" list, and the - "Set as Display Title" button. -2. **List content**: every earned title, alphabetically sorted, - readable rows. With few titles the scrollbar shows retail's - full-track thumb; with many (if available) the thumb sizes - proportionally and scrolls. -3. **Ghost rule**: with NOTHING selected the button is ghosted. Select - the title that IS the current display title — button stays ghosted. - Select a DIFFERENT title — button un-ghosts. -4. **Set round trip**: click Set as Display Title. PASS: the display - title text updates on the server's confirmation, the selection - CLEARS (row highlight goes dark — retail behavior), the button - re-ghosts, and the §1 heritage line updates live. -5. **Row selection visual**: the selected row highlights with retail's - row highlight art (full-row background swap), not a synthesized bar. - -## 3. Attribute/skill rows (CT5) - -1. **Icon alignment**: row icons sit flush left (20x20 at the row's - left edge), matching retail — the previous inset/smaller icons are - gone. Compare a few rows side-by-side against retail. -2. **Value gutter**: the numbers column ends with a visible margin - before the panel border (the scrollbar band) — retail's 7px gutter. -3. **Row height**: rows are retail-height (slightly tighter than - before); section headers (Trained/Untrained/Unusable) unchanged. -4. **Selection highlight**: clicking a row highlights with the retail - full-row art; the spellbook's selection visuals are UNCHANGED - (regression check — open the spellbook and select a spell). -5. **Raise buttons / tooltips / footer**: regression sweep — raise ×1 - and ×10 still work with correct ghosting, skill tooltips still show - formula + description wrapped correctly, the footer numbers update. - -## 4. Resize + scrollbar (CT6) - -Ground truth (2026-08-25 live probe against layout `0x2100006E`, host -`0x100005FE` — `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md` -§CT6, corrected by the CT6 fix round's BLOCKER B1): the resize clamp is -authored on the SHARED `gmPanelUI` host, not the character content -itself, and the host IS retail's own outer window frame — its authored -values are chrome-INCLUSIVE, not a content size our own chrome adds on -top of. Host authors **MinWidth=MaxWidth=310** (fixed width — no -horizontal Resizebar) and **MinHeight=372, MaxHeight=1000**. The -MOUNTED window's outer bounds are EXACTLY those same numbers: width -fixed **310px**, floor **372px**, ceiling **1000px** — no inset is -added on top (`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` -now tells the mount path this source already includes the bevel). -**Starting height:** the window OPENS at retail's authored default, -**372px** — its own resize floor. It cannot open any shorter; it can -only be dragged taller. - -1. **Grab the bottom edge and drag up (shrink).** PASS: the window - stops shrinking at its authored floor (372px outer / the point where - further dragging has no visible effect) — since the window already - OPENS at that floor, this step should show no shrink at all (there - is no room below the default to shrink into). It does NOT collapse - arbitrarily small. Retail comparison: drag retail's own Character/ - Skills window down from its own default; it should likewise refuse - to shrink further immediately. -2. **Keep dragging down (grow).** PASS: the window keeps growing until - its authored ceiling (1000px outer) — same side-by-side comparison - against retail's own ceiling. -3. **Left/right edges do not resize.** Only the bottom edge (and top - Dragbar for moving, not resizing) responds — matches retail's - fixed-width authoring (no horizontal Resizebar). -4. **Scrollbar hand-off, Attributes tab.** At the default window size - (372px) the 9 attribute/vital rows (180px content) OVERFLOW the - 160px list — the scrollbar is ACTIVE (visible + interactive) - IMMEDIATELY on open, not after shrinking. PASS: this is the owner's - item 2 fix — previously the scrollbar never appeared on Attributes - at all. Grow the window until the rows fit without scrolling: the - bar DISAPPEARS entirely (0x1000023E authors 0x79 hide-when-disabled - TRUE — a fitting list hides the bar, it does not leave a full-track - "disabled" thumb visible). Shrink back down and the bar reappears. -5. **Scrollbar hand-off, Skills tab.** Same immediate-overflow-at- - default check (a longer skills list only makes the overflow more - obvious); grow until it fits and confirm the bar disappears the same - way. -6. **Scrollbar hand-off, Titles tab.** With several earned titles, the - Titles list (authored 455px, inside the 575px page) is scroll-clipped - into the same 372px-default window and its own scrollbar - (`0x10000533`, also hide-when-disabled — fixture-verified) takes over - the same way: active when titles overflow, hidden when the window is - grown enough that they all fit. -7. **Footer stays bottom-docked.** While shrinking/growing on the - Attributes/Skills tabs, the footer (raise buttons / selected-stat - info) stays pinned to the bottom edge — it does not float mid-window - or get clipped early. -8. **Grow back restores.** Drag back down to the original default - (372px): the lists return to their default OVERFLOWING state - (scrollbar reactivates — this is the default, not "all rows fit") - and the window returns to its original proportions. -9. Other windows (chat, social) still clamp at their own authored - minimums — regression check (chat: min 300×100, max 2000×2000 per - `CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints`). - -## 5. Regression sweep (5 minutes) - -- Attributes/Skills tab switching unaffected; CA5 behaviors intact - (raise round trips, live run-speed update on Quickness). -- Logout/login: titles and display title persist; the header matches - PlayerDescription's values. -- Chat window: the CH-round fixes hold (input rails on focus, "Gen" - caption, button flick, no vibrating text while dragging). -- **CT-GF1 fix round (client-wide ancestor clip) — eyeball items.** The new - default clips every element to its own box by default; these are the - windows most likely to show a silent over-clip (content trimmed that - should be visible) if the port has an edge case the automated suite - didn't catch: - - **Collapsed toolbar**: collapse the combat/spell toolbar to its narrow - strip and back — confirm nothing inside it (icons, the collapse grip) - gets cut off or fails to reappear on expand. - - **Combat/vitals bar**: at its default size, confirm the health/ - stamina/mana bars and their numeric overlays render in full, not - trimmed at an edge. - - **Options panel bottom-button row** (Gameplay tab): confirm all seven - buttons (Exit to Character Selection, Configure Keyboard, In-Game - Help, Urgent Assistance, Report Abuse, mouse-turning checkbox, Exit - Game) render completely, none clipped at the panel's bottom edge. - - **Map/house page**: confirm the map image and player/house icons - render in full across the page's own scroll/zoom range, not clipped - at the viewport edge. - - **Floaty chat** (a detached floating chat window, Alt+1..4): confirm - the transcript and input row render in full at both a small and a - resized-larger window size — the same class of symptom CT-GF1's own - `ChatLayoutConformanceTests` regression-pinned for the main chat - window's input row. - ---- - -## Report back - -Per section: PASS/FAIL plus anything odd. The three answers that matter -most: -1. §2.4 — does the set-title round trip clear the selection and update - the header live? -2. §3.1/§3.2 — do icons and the value gutter now match retail - side-by-side? -3. §1.3 — is the PK line present, white, and correct on both tabs? diff --git a/docs/research/2026-08-26-combined-client-parity-gate.md b/docs/research/2026-08-26-combined-client-parity-gate.md deleted file mode 100644 index ed1db04e..00000000 --- a/docs/research/2026-08-26-combined-client-parity-gate.md +++ /dev/null @@ -1,131 +0,0 @@ -# Combined client parity owner gate - -**Date prepared:** 2026-08-26 - -**Run:** OWNER-COMPLETE 2026-08-26 on one exact Release binary. - -**Scope:** #443–#450 plus the complete inventory/vendor interaction audit. - -**Outcome:** Sections A–C and E–F passed, including the mid-drag cursor-icon -re-test added during the round. Issues #444–#450 are owner-accepted. Section D -failed: the private paperdoll remained missing, so #443 stays open as the only -surviving defect. Tested executable SHA-256: -`173989F3C85C05C0746D628CDF9C6194F6A5E3EFD4597806BF83417483C37B42`. - -Keep the client log for the whole run and take a screenshot for any visual or -text mismatch. For every refused item action, record the cursor color, exact -SpewBox line, and whether the item visibly moved before the refusal. - -## A. Keyboard, camera, combat input, and relog — #446/#450 - -1. In Configure Keyboard, bind Move Forward to bare Shift while Toggle - Walk/Run already owns it. Verify the retail conflict dialog appears and the - chosen resolution is honored. Repeat with a known allowed shared chord. -2. Apply a changed binding, close/reopen Options, then restart the client. - Verify it survives. Test Revert, Defaults, Cancel, and Load/Save `.keymap`. -3. Press regular Enter: chat input must focus. Press keypad Enter outside chat: - it must perform only its configured camera action and must not focus chat. -4. Press bare Escape through the retail ladder: cancel target/focus/selection - first, then toggle Gameplay Options. It must never enter a developer orbit - or bird's-eye mode. Preserve the approved mouse-wheel zoom range. -5. Hold End, Page Down, or Delete in melee mode. The bar must charge while the - key is held and attack only on release, using the selected height. -6. Shift+Escape to character selection, immediately re-enter, and verify the - destination loads and exits portal space. Repeat twice in one process. - -## B. Chat and combat text — #447/#448 - -1. Run `@acecommands`. Every non-empty server line must be visible; an - oversized response must retain the newest complete lines, not blank the - transcript. -2. Run `@acehelp acecommands` and send ordinary chat afterward. Verify normal - text, filtering, and scrolling remain intact. -3. Land ordinary and critical melee hits. Outgoing lines must match retail - wording and punctuation and contain no acdream-added percentage. - -## C. Selection, use, containers, movement, and splitting — #445/#449 - -Use a normal item, unusable item, wearable, weapon, two mergeable stacks, a -Pyreal stack, one side pack, a full main pack, a full side pack, an open -external container, and a creature/player target. - -1. Single-click and right-click inventory, side-pack, external-container, and - paperdoll items. Verify one global selection, stable highlight, status text, - and right-click examination. -2. Select an owned Pyreal stack. The toolbar must read - ` (of )`, with no comma - insertion added by acdream. -3. Single- and double-click a carried side pack. It must open on press, issue - no generic item-use request, and remain stably selected. -4. Double-click usable, unusable, wearable, and wieldable items. Verify one - action. Any local refusal must appear once in SpewBox, not in normal chat. -5. Move a full item between main pack and side pack. Before acknowledgement, - the source must remain canonical with retail's waiting/ghost presentation; - after success it appears only at the destination. Force one rejection and - verify no duplicate, disappearance, or speculative capacity change. - While holding the item under the cursor, move across the inventory and wait - through several ordinary object updates: the cursor icon must remain visible - until release. This re-gates the mid-drag procedural-refresh fix found during - the first 2026-08-26 owner pass. -6. Fill the main pack, observe a rejected move, drop one loose item, then move - an item from a side pack into the freed slot immediately. It must accept - without reopening the inventory window (#449). -7. From a stack of 10, select 2 and split into an empty main-pack slot, an open - side pack, and an external container. Each successful result must be 8+2. -8. Merge full and partial stacks. Verify selected quantity, target selection, - target-cap clamp, source remainder, and exact refusal text for a full target. -9. Drop full and partial stacks to the world, then pick them up. Verify pending - visuals, authoritative commit, failure cleanup, and no duplicate object. -10. Give a full and partial stack to a creature; drag onto another player and - verify secure-trade routing. Hover rejection must stay silent; release - rejection must print the exact ClientLocal reason. -11. Equip once by double-click and once by paperdoll drag. Test a clothing - conflict and weapon replacement. Canonical inventory/paperdoll ownership - must not change before the authoritative response. - -## D. Private paperdoll viewport — #443 - -1. From a fresh process, open inventory and assess one monster and one player. -2. The animated doll must be present on first open, not appear only after a - delay. Close/reopen each view several times and change equipment once. -3. Record #443 independently if the viewport is late or missing even when the - underlying equip/inventory transaction is correct. - -## E. Vendor parity and alternate currency — #444/#445 - -1. On the browse list, single-click selects, right-click examines, and - double-click buys exactly one/current-slider unit through the normal retail - purchase path. -2. Add a stack quantity greater than one to Buying. Double-click its staged - row: remove exactly one unit and print - `Removing from shopping list` once in SpewBox. -3. Stage an owned item in Selling. Right-click examines it; double-click - removes the whole staged entry and prints the same removal form. -4. Drag a staged Selling row: it must unstage. Repeat after selecting only part - of its stack: SpewBox must print - `You cannot split items from this panel` and the slider must reset to max. -5. From an owned stack of 10, select 2 and drag into Selling. Verify the - temporary row resolves to the new authoritative stack of 2, Sell All sells - exactly 2, and 8 remain (#445). -6. At an alternate-currency vendor, note holdings in the Items cost sentence - and Buying/Selling purse lines. Buy once: every visible holding must decrease - immediately and remain correct after authoritative inventory refresh, - tab changes, and vendor reopen (#444). -7. Buy All once with enough currency and once without enough. The first uses - the refreshed balance; the second prints retail's insufficient-funds notice - and sends no purchase. - -## F. Re-entrant and lifecycle stress - -1. Change selection during a pending split, close an external container during - a pending move, and retry immediately after a refusal. -2. Attempt a second inventory operation while one request is pending. Verify a - clean retail refusal/no-op, never duplicated wire action or stuck busy state. -3. Log out or portal with a recently completed interaction, re-enter, and - verify pending projections and the request ledger converge to zero. - -## Pass rule - -The pass rule was satisfied for #444–#450 on the exact binary recorded above. -#443 remains open by itself because the transaction rows passed and the failure -was confined to private viewport residency. diff --git a/docs/research/2026-08-26-issues-444-445-447-test-script.md b/docs/research/2026-08-26-issues-444-445-447-test-script.md deleted file mode 100644 index 7ec5bac3..00000000 --- a/docs/research/2026-08-26-issues-444-445-447-test-script.md +++ /dev/null @@ -1,43 +0,0 @@ -# Issues #444, #445, #447 — consolidated owner gate - -Run these checks together on the next connected test build. They deliberately -require no special probes; record the client log and one screenshot per -section. If a split fails, also record the exact visible error text. - -## #444 — alternate-currency vendor balance - -1. Open a vendor that accepts an alternate currency and note the amount shown - in both the Items cost sentence and the Buying-tab purse line. -2. Buy one item. -3. Confirm both visible amounts decrease immediately and remain correct after - the server refresh. Close/reopen the tab and vendor and confirm the amount - does not bounce back to the old snapshot. -4. Buy All once with enough currency, then once without enough. Confirm the - first uses the refreshed holding and the second shows retail's insufficient- - money notice without sending a purchase. - -## #445 — inventory and vendor partial stack splits - -1. In the main pack, select a stack of 10, set the slider to 2, and drag it to - an empty main-pack slot while at least one side bag is equipped. -2. Confirm the source becomes 8 and a new stack of exactly 2 appears at the - chosen loose-item position; no error should appear. -3. Repeat into an open side bag and confirm the same 8+2 result. -4. Reset to a stack of 10, select 2, and drag it onto the vendor Selling list. - Confirm the client prints `Splitting the before selling them`, then - the staged row resolves to the new stack of 2 rather than the source stack. -5. Press Sell All and confirm exactly 2 are sold and 8 remain. - -## #447 — `@acecommands` multiline response - -1. Run `@acecommands` on the test account. -2. Confirm command text is visible, consecutive server lines are readable, - and there is no screen of blank chat rows. -3. Scroll through the retained result. A response beyond retail's 10,000- - character transcript cap should retain the newest complete command lines - instead of blanking the entire response. -4. Run `@acehelp acecommands` and one ordinary chat command afterward; confirm - their text and normal chat presentation remain intact. - -Pass all three sections on one exact binary, then mark #444/#445/#447 -owner-accepted together. diff --git a/docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md b/docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md deleted file mode 100644 index 2556a7f6..00000000 --- a/docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md +++ /dev/null @@ -1,447 +0,0 @@ -# MossTank research: Virindi Tank parity and UtilityBelt expressions - -Date: 2026-08-26 - -This report is the requirements baseline for turning MossTank from the small -self-buffing sample into acdream's full automation plugin. The product target is -deliberately broad: **all Virindi Tank functionality**, with UtilityBelt's more -capable expression dialect as the scripting baseline. File compatibility is a -separate decision; behavioral capability is not. - -## 1. Evidence and limits - -The following primary VTank pages were read and cross-checked (the live wiki -and its indexed historical revisions were both used where a mirror was -temporarily unavailable): - -- `http://virindi.net/wiki/index.php/Virindi_Tank` -- `http://virindi.net/wiki/index.php/Virindi_Tank_Standard_Options` -- `http://virindi.net/wiki/index.php/Virindi_Tank_Advanced_Options` -- `http://virindi.net/wiki/index.php/Virindi_Tank_Commands` -- `http://virindi.net/wiki/index.php/Virindi_Tank_Meta_System` -- `http://virindi.net/wiki/index.php/Meta_Expressions` -- `https://utilitybelt.gitlab.io/docs/expressions/` - -The 2026-08-27 MT2 follow-up also verified the exact documented distinctions -that drive the combat scheduler: - -- A+R requires `MinimumRingTargets` inside Ring Range; R without A rings with - any configured target inside range and falls back to standard war outside; -- `UseArcs` prefers an arc over a bolt only at/above `ArcRange`; -- `Void Basic`, `Drain Auto`, and `Harm` are distinct Monsters damage choices; -- `GhostMonsterSpellAttemptCount` counts spell attempts which never start, - while `BlacklistMonsterAttemptCount` counts successful attacks which miss; -- the health-tracker ghost detector is independent and applies to melee, - missile, and magic. - -Sources: the official `Virindi_Tank_Standard_Options`, -`Virindi_Tank_Advanced_Options`, `Virindi_Tank_FAQ`, `Options_List`, and -`Virindi_Tank_Changelog` pages listed above. - -For UtilityBelt, documentation was checked against the primary source rather -than relying on the generated web page alone. The inspected repository was -`https://gitlab.com/utilitybelt/utilitybelt`, commit -`5fe9825a82f38047737768fd92c61dd47d88e467` (2026-03-05). The grammar is -`UtilityBelt/Lib/Expressions/MetaExpressions.g4`; every method carrying an -`ExpressionMethod` attribute was enumerated. The source is MIT licensed. - -This report extends, rather than replaces, -`2026-07-29-vtank-plugin-automation-requirements.md`. That earlier report -already decoded VTank's `.met`, `.nav`, and `.utl` structures from primary -sources and remains the format reference. - -## 2. Complete VTank capability map - -### 2.1 Combat - -VTank is a priority-driven combat controller, not merely an auto-attack loop. -Its supported combat family includes: - -- melee, missile, mage, hybrid, two-handed, Void and Summoning characters; -- Life harm/martyr attacks, grenades, lenses, cast-on-strike weapons, streaks; -- automatic damage/weapon choice and monster-specific weapon, offhand and pet - element overrides; -- monster rules with `DEFAULT` plus ordered first-match expressions; -- per-rule priority from ignore (`-1`) through `4`, attack/debuff flags, - damage type, attack height, ring/streak choices, and void curses; -- target selection by distance, angular deviation, or the hybrid method using - angle inside a configurable cutoff and distance outside it; -- target lock, blacklist/retry behavior, and ghost-target retirement after - failed casts or missing health updates; -- debuff scheduling by one target, priority group, or all targets before - attack; spell-level versus skill-based debuff choice and reapply windows; -- automatic ring use by nearby-target density and arc/bolt choice by range; -- melee high/middle/low attacks, automatic or explicit power, Recklessness; -- pet density, element, refill and test behavior. - -VTank's macro scheduler checks multiple action lists in priority order. Combat -therefore cannot be implemented as an isolated timer: healing, buffing, -navigation, looting, fellowship assistance and combat all need one arbiter. - -### 2.2 Buffing and vitals - -- automatic trained attribute/skill buffs, protections, banes, auras, - regeneration and configured extra buffs; -- protection/bane profile sets, exclusions, level/tier selection, signed - skill-over-difficulty thresholds, force buff and idle top-off; -- time-remaining rebuff and persisted item-buff duration knowledge; -- combat, idle and fellowship-helper vital thresholds; -- kits, vital transfers, post-switch recharge behavior and special healing - items; -- self-dispel in response to high-level vulnerabilities. - -MossTank's current buff engine already owns the first useful subset: known -self buffs, tier/difficulty choice, in-force enchantment timing, force buff, -and stamina/mana upkeep. It remains plugin policy over host primitives. - -### 2.3 Inventory and crafting - -- AutoStack and AutoCram; -- pea splitting and priority rules; -- crafting of kits, foods, arrowheads and special ammunition; -- mana-stone acquisition, filling and application to equipped items; -- lockpick selection and use; -- component, consumable, tool and ammunition upkeep. - -### 2.4 Looting - -- corpse approach/open/retry/timeout/blacklist; -- all/fellow/rare loot modes and priority boosts; -- appraisal/ID wait, unknown-scroll reading and salvage combining; -- a loot-plugin seam, with VTClassic as the canonical ordered, first-match - rule engine over raw and computed item properties; -- actions including no-loot, keep, keep-up-to, salvage, sell, read and custom - user actions. - -The host must expose object property bags, appraisal completion and -transaction primitives. Rule ordering and loot-profile policy belong in -MossTank. - -### 2.5 Navigation - -- circular, linear, once/runback and follow routes; -- points, portals, recalls, pauses, chat, vendor, repeated NPC talk/use, - server-confirmed checkpoints and charged/shift/strafe jumps; -- closest-entry, reversal, arrival/off-course ranges, door use and - follow-around-corners; -- combat/nav priority interaction. - -Route storage belongs to the plugin. The host owes move-to, follow, turn, -jump, use and authoritative-arrival primitives. - -Direct inspection of the official assembly on 2026-08-27 pinned the route -contract more tightly: - -- `eNavType` is Circular, Linear, Target and Once; Once destructively removes - its first completed row and Linear deliberately visits each endpoint once - while flipping direction; -- `eWaypointType` assigns Point/Portal/Recall/Pause/ChatCommand/OpenVendor/ - Portal2/UseNPC/Checkpoint/Jump to numeric ids 0..9; -- `fd.cs` turns outside 4°, moves while turning only within 45° beyond 3 m or - 15° inside 3 m, and stops at `NavCloseStopRange` (default 2 m); -- `gr.cs` compares the checkpoint against the last server position rather - than client prediction and nudges forward after 15 seconds without an - acknowledgement; -- `gl.cs` records the followed player's path by approximately 9.6 cm and - drops old breadcrumbs when the follower comes within 2.4 m of a later path - segment, preserving follow-around-corners; -- `e9.cs` and `fa.cs` reacquire exact-name/class objects within 2.5 m of the - stored position. Portal2 retries when portal exit remains within 15 m of - its origin; UseNPC repeats until the named NPC tells or gives to the player; -- `b7.cs` is a rule independent of the route node list. `OpenDoors` defaults - false; it IDs doors at 20 m, opens at 4 m, and accepts a lock when Lockpick - is at least `difficulty - 50` using an owned lockpick. - -These are plugin policies over additive canonical projections, not a second -movement model. The host applies semantic movement intent through Runtime's -existing command interpreter and supplies the accepted server position needed -only by Checkpoint. - -### 2.6 Fellowship and social automation - -- tell-driven recruitment and waiting lists; -- fellowship leader/member/state queries and leader replacement voting; -- fellowship healing, corpse permissions and coordinated target/debuff policy; -- multi-client composition through chat rather than a privileged macro API. - -### External loot-classifier seam - -VTank loads one `LootPluginBase`, asks `DoesPotentialItemNeedID`, and then -calls `GetLootDecision(GameItemInfo)`. Its public result vocabulary is -NoLoot, Keep, Salvage, Sell, Read, User1–User5 and KeepUpTo with `Data1` as the -limit. MossTank modernizes discovery into a host-owned classifier registry: -plugins register a namespaced classifier for their own lifetime, while -MossTank remains the corpse/appraisal/pickup/action executor. The selected -engine is durable policy; if it unloads, MossTank returns no classifier match -instead of silently applying the built-in profile. - -Direct inspection of the official `hv.cs` also shows that a custom loot -plugin's per-item action is retained only after the item enters owned -inventory and is removed when the item leaves. The modern registry therefore -has matching `OnLooted` and `OnItemRemoved` callbacks. MossTank invokes them -only from authoritative inventory publication/removal, never when pickup is -merely dispatched. - -### Options-page scheduler findings - -The official Options controls are not merely presentation aliases: - -- `fz.cs` runs the ordinary `RebuffTimeRemainingSeconds` rule before combat; -- `cLogic.cs` runs a second `IdleBuffTopoffTimeSeconds` pass only behind - `IdleBuffTopoff`, after attack/loot work has gone idle; -- the PRETARGETAPPROACH `g8` rule navigates only between `AttackDistance` and - `ApproachDistance`, and requires both combat and navigation to be enabled; -- `cm.cs` changes to Peace only as the final no-target/no-work fallback. - -The UI displays AC-distance settings multiplied by 240. MossTank stores metres -in its typed controllers and converts only at the VTank option boundary. - -### 2.7 Meta state machine - -- named states beginning at `Default`; -- state-local rules, each firing once per state entry; -- nested conditions (`All`, `Any`, `Not`) and conditions for chat regex, - inventory, timers, nav state, death, vendors, monsters, buffs, coordinates, - portals, burden, route distance, expressions and captured chat groups; -- actions for state transition, chat, grouped actions, embedded navigation, - call/return stack, expression execution, expression-derived chat, watchdogs, - option read/write and runtime-created views; -- a roughly 293 ms decision cadence plus evaluation when the macro asks for - its next action. - -### 2.8 Profiles, commands and companion behavior - -- independent settings, navigation, loot and meta profiles; global and - per-character variants; hot loading and automatic persistence; -- command parity for macro state, options, buffing, meta, item testing, - property dumps, monster/spell diagnostics, route editing, attack power and - debug output; -- extensibility equivalent to VTClassic, VI2, item tools, follower/status HUD, - alerts and cross-character inventory. Some belong as separate acdream - plugins, but MossTank's API must permit them without privileged host code. - -## 3. Expression language target - -### 3.1 Why UtilityBelt is the baseline - -VTank expressions are enough to power classic metas, but UtilityBelt preserves -the familiar syntax while adding typed lists and dictionaries, slicing, -higher-order collection functions, broader object queries and more action -primitives. MossTank should implement the UtilityBelt-compatible semantic -superset and offer a VTank compatibility mode for old expressions. - -### 3.2 Grammar and evaluation semantics - -The audited UtilityBelt grammar supports: - -- multiple `;`-separated statements, returning the final result; -- session (`$`), persistent (`@`) and global (`&`) variables; -- decimal and hexadecimal numbers, booleans and two string forms; -- function calls using `name[...]`; -- typed values: number, string, boolean, list, dictionary, coordinate, world - object, stopwatch and UI control; -- list/string/dictionary indexing, slices and negative indices; -- complement, shifts, bitwise operators, exponentiation, arithmetic, regex - match (`#`), comparison, short-circuit `&&` and `||`; -- registered function metadata, arity/type validation and documented return - types; -- collection creation/mutation/copying plus map/filter/reduce/sort/range. - -Implementation requirements follow directly: parse into an immutable AST; -compile or interpret without ambient reflection; use explicit value kinds; -short-circuit logical nodes; attach cancellation and an instruction budget; -make all world/action functions capabilities supplied by the MossTank engine; -and serialize only persistent/global variable stores. - -### 3.3 Audited UtilityBelt function catalog (260 declarations) - -The declaration count includes aliases/overloads. Grouped by capability, the -public names are: - -- **language/conversion/math:** `abs`, `acos`, `asin`, `atan`, `atan2`, - `ceiling`, `chr`, `cnumber`, `cos`, `cosh`, `cstr`, `cstrf`, `floor`, - `hexstr`, `iif`, `ifthen`, `isfalse`, `istrue`, `lumavg`, `lumtotal`, - `ord`, `randint`, `round`, `sin`, `sinh`, `sqrt`, `strlen`, `tan`, `tanh`, - `tostring`, `vitae`; -- **variables:** `getvar`, `setvar`, `testvar`, `touchvar`, `clearvar`, - `clearallvars` and the corresponding `pvar` and `gvar` families; -- **execution/chat:** `exec`, `delayexec`, `clearexec`, `echo`, `chatbox`, - `chatboxpaste`; -- **lists:** `listcreate`, `listadd`, `listinsert`, `listremove`, - `listremoveat`, `listgetitem`, `listcontains`, `listindexof`, - `listlastindexof`, `listcopy`, `listreverse`, `listpop`, `listcount`, - `listclear`, `listfilter`, `listmap`, `listreduce`, `listsort`, - `listfromrange`; -- **dictionaries:** `dictcreate`, `dictgetitem`, `dictadditem`, `dicthaskey`, - `dictremovekey`, `dictkeys`, `dictvalues`, `dictsize`, `dictclear`, - `dictcopy`; -- **time/location:** `getdatetimelocal`, `getdatetimeutc`, `getunixtime`, - `getworldname`, `getplayercoordinates`, `getplayerlandblock`, - `getplayerlandcell`, coordinate parse/get/distance/string functions, - stopwatch functions, and the eleven `getgame*`/day/night functions; -- **character:** raw typed property reads, base/buffed skills, training level, - base/current/buffed-max vitals, base/buffed attributes, burden, free slots, - cooldown expiration, account hash and character index; -- **spells/components:** `getknownspells`, `getisspellknown`, - `getcancastspell_buff`, `getcancastspell_hunt`, `getspellexpiration`, - `getspellexpirationbyname`, `spelldata`, `spellname`, `componentdata`, - `componentname`; -- **world objects:** validity/data/ID-time, raw typed properties, identity, - health/vitals, spells, coordinates, selection/player/open-container, door - state, nearest monster/door/by class/name/template, and `wobjectfindall*` - variants over world, landscape, inventory and containers; -- **actions:** select, use, apply, give, equip wand, cast, cast-on-target, - move, split and drop; -- **combat/movement:** combat state get/set, busy state, equipped weapon type, - heading/get-heading-to, motion get/set/clear and portal-state query; -- **inventory/loot/salvage:** counts by name/regex/type, give-profile, - unopened corpse queries, `ustadd`, `ustopen`, `ustsalvage`; -- **fellowship/quest/XP:** thirteen fellowship queries, quest state/progress, - seven XP-meter operations; -- **UI/options/network/login:** status HUD, view/control get/set/visibility, - VT option/meta get/set, macro status, UtilityBelt options, regex capture, - network clients and next-login control. - -This catalog is a compatibility test ledger. Each name must eventually be -implemented, deliberately aliased, or marked unsupported with a documented -reason; silent omission is not acceptable. - -## 4. acdream mapping after the 2026-08 campaigns - -The 2026-07 report's architecture remains correct, but its gap table is stale. -The Runtime now owns inventory transactions, selection, combat mode and power -state, casting, fellowship, allegiance, vendor and secure-trade state. MossTank -already consumes a small BCL-only `IAutomationSurface` for vitals, skills, -spells, enchantments, casting and local chat. - -The gaps relevant to the first autocombat milestone are narrower: - -| Need | Canonical owner today | Plugin gap | -|---|---|---| -| hostile query and live position | `RuntimeEntityDirectory` + `ClientObjectTable` | no target snapshot/query | -| health and selected target | `RuntimeActionState` | no combat view | -| melee/missile charge/release | `RuntimeCombatAttackState` | no command surface | -| combat-mode transition | `RuntimeCombatModeState` | no command surface | -| known offensive spells | `Spellbook` | only self buffs are enumerated | -| target-specific cast | selection + `RuntimeSpellCastState` | possible only by composing two old services | -| polished plugin controls | retained `IUiRegistry` markup | markup lacks bound visibility/enabled/style affordances | - -The first implementation therefore does not need a second runtime bridge or a -second object model. It needs a narrow additive projection of those exact -owners. - -## 5. Decisions for MossTank - -1. MossTank remains an ordinary plugin. It never references App, Runtime, - rendering, networking or DAT assemblies. -2. The host API exposes snapshots and attempt-style commands; MossTank owns - target scoring, rule ordering, spell/attack choice and timing. -3. The first combat milestone supports melee, missile and direct offensive - magic, target lock, range/angle/hybrid scoring, priority rules, attack - height and power. Navigation, weapon swapping, debuffs, vulnerabilities, - pets and monster expressions are later combat slices, not hidden stubs. -4. Expressions will use UtilityBelt's richer typed semantics. Compatibility - is defined by parser/evaluator tests and the audited function ledger, not by - copying UtilityBelt implementation code. -5. Native MossTank profiles will be versioned JSON. Importers for VTank files - can be added later without constraining the internal model. -6. The UI uses acdream's retained plugin UI contract. Missing generic controls - should improve that contract/markup rather than making MossTank depend on a - presentation implementation. - -### 5.1 Follow-up implementation findings (2026-08-27) - -VTank's official `e0.d(name)` first looks in `MonsterDamageOverrides`, then -maps the monster to `SpeciesDamages`; `ga.g(...)` walks that ordered preference -list and finally tries the unlisted elements 0..6. acdream already projects -retail `CreatureType` as `PluginCombatTarget.SpeciesId`, so MossTank can bypass -VTank's name-to-species compatibility table while preserving the same ordered -damage result. Exact name overrides still win. The imported official feed has -59 overrides and 103 species rows. - -### 5.2 Official inventory and loot findings (2026-08-27) - -The official VTank assembly and its GameInfoDB were inspected rather than -inferring behavior from the UI labels: - -- `el.cs`/`cf.cs` supply 757 exact craft rows; prerequisites are recursive and - share the canonical item-use transaction; -- `fo.cs` identifies every corpse before selection, parses `Killed by ...`, - admits the player's own corpse immediately, admits a Share Loot fellow - immediately, waits 100 seconds for a non-sharing fellow or unrelated public - corpse, and never crosses ownership on another player's rare-generating - corpse; -- the default corpse-open retry contract is 30 attempts, then a 200-second - blacklist; completed corpse records expire after 60 minutes; -- `hv.cs` applies the ordered loot rule first, then falls back to readable - unknown scrolls and automatic mana-stone/tank acquisition; -- `dy.cs` proves that ManaTank is a mana-bearing donor target, not a worn-item - recharge consumable. A ManaStone is used on that donor when its mana is at - least `ManaTankMinimumMana` (default 1000); -- `c7.cs` combines only same-material salvage bags in exact workmanship bands - `<7`, `7–<9`, `9–<10`, and exactly `10`; one bugged source is abandoned after - 40 failed combine attempts; -- `gmSalvageUI::Salvage` calls - `CM_Inventory::Event_CreateTinkeringTool`: game action `0x027D`, tool id, - then `PackableList` (count plus ordered item ids). This same - operation handles ordinary source salvage and salvage-bag combination. - -The native implementation keeps settings, loot, route, and meta documents -independent, matching VTank's profile model while using versioned JSON as the -working format. It also emits and imports exact compatibility files: `uTank2 -NAV 1.2`, CondAct `.met`, and VTClassic `UTL 1` (plus legacy UTL v0 reads). -The UTL port preserves unknown length-delimited requirement and extra-block -payloads, executes the complete 31-type requirement vocabulary, and carries -the `SalvageCombine` material ranges/value modes into the live combine planner. -VTClassic's color rules use the original ordered ObjDesc subpalettes and the -original sample index `length*16 + offset*32 + 8`, resolved from portal DAT -palette colors rather than approximated from icon pixels. - -Profiles are implemented over manifest-scoped JSON with exact VTank files as -an interchange/export layer: `By char` hashes the canonical character name into a distinct -document, named profiles are explicit shared snapshots, and the index records -owner plus per-character active selection. Create/copy/clear/select all hot- -load the same mutable policy owners already borrowed by the controllers. The -generic retained markup contract gained editable fields and retail dropdown -menus for this editor; later Monsters, Loot, Route, and Meta editors reuse the -same controls. - -## 6. Acceptance boundary for “autocombat ported” - -The milestone is complete when an in-world MossTank panel can enable/disable -combat, periodically capture canonical hostile targets, preserve a valid -locked target, choose a target by configured range/angle/hybrid policy and -priority, enter the equipped default combat mode, drive retail's physical -press/charge/release state machine at configured height/power, or cast the -best usable learned direct offensive spell in magic mode. It must stop cleanly -on session loss, invalid/dead/out-of-range targets, and user disable; it must -not duplicate Runtime state or issue overlapping requests. - -Full VTank parity is the campaign target. This acceptance boundary is only the -first executable slice requested for this work session. - -## 7. Official binary combat-item findings (2026-08-27) - -The official `vt.tar.gz` update was decompiled for behavior research and the -live GameInfoDB v9 feed was read directly. The decisive implementations are -`dz.cs` (debuff source selection), `ga.cs` (item classification), `gs.cs` -(caster-item confirmation), `bo.cs` (physical/proc confirmation), and `hi.cs` -(attack-power policy). - -- `dz.b.CompareTo` ranks spell quality then source skill/spellcraft for - `SpellLevel`, reverses those two for `Skill`, and gives a learned spell the - final tie. Spell quality is normally spell difficulty. -- Caster items activate on the target. Melee/missile proc weapons are equipped - and repeatedly attack at power 0/1 respectively. Neither path counts as - applied until color-7 combat chat matches `^You cast (.*) on .*$`. -- Grenades are missile-class items with CombatUse 0 and `Phial` in the name; - the official database contains exactly 72 names across eight material tiers, - with Alchemy requirements 75..400 and spellcraft 100..520. -- Normal physical attack power is not a smooth heuristic. `hi.cs` emits the - exact 0, .2, .49, .5 or 1 values for slash/pierce hybrid arrangements, then - clamps to .11..90 when trained Recklessness is enabled. - -These findings require three host facts VTank formerly obtained through -Decal: retained per-item appraisal SpellBooks, ordered transcript capture, and -an explicit combat-mode command. They are additive BCL plugin contracts; -MossTank retains all source-choice and retry policy. diff --git a/docs/research/2026-08-26-retail-inventory-interaction-audit.md b/docs/research/2026-08-26-retail-inventory-interaction-audit.md deleted file mode 100644 index 028c7a3b..00000000 --- a/docs/research/2026-08-26-retail-inventory-interaction-audit.md +++ /dev/null @@ -1,745 +0,0 @@ -# Retail inventory interaction audit - -**Date:** 2026-08-26 - -**Scope:** Selection, status text, single/double/right click, drag/drop, -container movement, ground pickup/drop, equipping, stack splitting, vendor -staging, failure feedback, and SpewBox routing. - -**Change policy:** Audit followed by implementation in the same worktree. - -**Source snapshot:** `0c699240`, plus the already-present working-tree fixes for -#444, #445, #446, #447, and #449. Those fixes are assessed as found; this -report does not claim that they have been committed or user-accepted. - -## Implementation closeout — 2026-08-26 - -Slices 1–4 below are implemented and automated-test covered. Gameplay -refusals now use the `ClientLocal` SpewBox route; move/wield failure kinds are -complete; full move/drop/wield are request-first with pending projections; -owned-container and vendor-row mouse behavior follows the named retail -handlers; hover and release share one side-effect-free legality policy; and -local item-policy wording is composed from retail's exact literals. - -The last toolbar uncertainty is also resolved. Raw retail bytes at -`gmToolbarUI::HandleSelectionChanged @ 0x004BF4EF` push format literal -`0x007B4748`, which decodes to `%d %hs (of %d)`. The owned Pyreal-stack branch -now renders that exact stack/name/total shape. The final AutoWield fallback was -also corrected: retail does not print the invented “That slot is already in -use”; with automatic unblocking enabled it moves the preferred occupied-slot -item to the backpack, waits for the authoritative move, then retries the -wield. Slice 5 remains deliberately deferred as the single combined connected -owner gate. - -## Audited root causes (now fixed) - -The inventory implementation was not missing one isolated rule. Most individual -operations existed and used the correct wire messages, but three seams made the -whole experience feel intermittent: - -1. **Some retail-local refusal text was routed to a dead production callback.** - `ItemInteractionController` and `AutoWieldController` used a `toast` callback - for a substantial class of local rejections while `GameWindow` supplied - `null`. Retail sends these messages to the `ClientLocal` channel, which is - the SpewBox in acdream. The result was a real silent-failure class, not merely - different wording. -2. **Full moves, world drops, and wield operations mutated canonical inventory - state before the server accepted them.** Retail normally leaves the source - canonical object in place, adds a waiting/ghost projection at the intended - destination, and commits only after the authoritative object update. The - old optimistic mutation was reversible, but selection, capacity, - paperdoll, vendor, and other observers could see a transient state that never - existed on the server. This was the largest structural flakiness risk. -3. **Several list-specific mouse behaviors did not match retail.** In - particular, staged vendor rows could not be double-clicked or dragged to - remove them, staged rows lacked right-click examine, and owned side-pack - double-click/open ordering differed from retail. - -The wire builders, global selection/split model, merge-first rule, request gate, -most right-click examine paths, normal item double-click use/equip, external -container pickup, paperdoll placement validation, and the newly repaired -vendor-split/main-pack-capacity paths are broadly aligned with retail. - -The implementation was executed in this order: - -1. Route every local item refusal through `ClientLocal`/SpewBox. -2. Replace canonical optimistic movement with retail-style pending projections. -3. Close the vendor staged-row and owned-container input differences. -4. Deepen hover/drop legality and finish exact status/failure text parity. -5. Run one connected interaction matrix across inventory, paperdoll, ground, - external containers, and vendors. - -## Method and evidence standard - -This audit used four evidence layers: - -- The September 2013 named retail pseudo-C under - `docs/research/named-retail/acclient_2013_pseudo_c.txt`, searched by named - class and method before relying on older address-only material. -- Existing focused retail notes under `docs/research/`, especially the item, - drag, give, world-drop, use/autowear, and vendor investigations. -- The current production controllers, Runtime owners, UI input dispatch, wire - request builders, and communication routing. -- Existing focused tests, used to distinguish implemented intent from behavior - that is not currently protected. - -Verdicts in this report mean: - -- **Match:** the important retail behavior and ownership rule are present. -- **Partial:** the common path matches, but a retail branch, presentation rule, - or failure path is absent. -- **Mismatch:** direct retail evidence contradicts the current behavior. -- **Risk:** the mechanism differs in a way likely to produce transient or race - defects, but this audit does not assert a particular live symptom without a - connected reproduction. -- **Gate pending:** a code fix exists in the working tree and has automated - coverage, but the owner has not yet accepted the live behavior. - -## Retail reference model - -### One selected object and one split quantity - -Retail has a client-global selected object. Clicking an item selects it; -right-click first selects it and then examines it; beginning a drag selects it -if it was not already selected. The toolbar observes that global selection and -shows the name, stack quantity, and split controls. - -The split quantity is also global and applies only when the dragged/requested -object is the selected object. An unselected stack always means the full stack. -Changing selection resets/reseeds the split amount. Vendor-owned selected -stacks use a different initial amount from normal owned stacks. - -Primary anchors: - -- `UIElement_ItemList::ListenToElementMessage` at `0x004E4D50` -- `UIElement_ItemList::BeginDrag` at `0x004E32D0` -- `gmToolbarUI::HandleSelectionChanged` at `0x004BF380` -- `ItemHolder::GetObjectSplitSize` in the named retail pseudo-C - -### Mouse-down establishes intent; click completion performs list action - -For a retail item-list entry, left press first gives target mode a chance to -consume the object. Otherwise it selects the object. A container-list entry -also opens that child container and updates its open indicator in this same -item-list message path. - -Right press selects and examines. Double-click invokes generic `UseObject` for -ordinary list items, but the generic double-use path is suppressed for an -owned `containerList` entry. The ground/external root is explicitly allowed. - -This distinction matters: a side pack is opened as a container, not opened and -then generically used as an ordinary item on the second click. - -### Dragging is a request with pending presentation - -Beginning a physical-item drag produces a source waiting/ghost state. Vendor, -salvage, and shortcut lists are special list types and do not use the same -physical-source waiting ghost. - -Hover is advisory and silent. Release reruns legality with feedback enabled. -For a normal container move, retail retains the canonical source ownership and -adds a pending destination projection. The server's authoritative object update -commits the move. Rejection removes the pending projection and prints the local -failure. This same general principle appears in world placement and split-to- -world handling. - -Primary anchors: - -- `UIElement_ItemList::BeginDrag` at `0x004E32D0` -- `UIElement_ItemList::DragOver` at `0x004E3400` -- `UIElement_ItemList::AcceptDragObject` at `0x004E4250` -- `UIElement_ItemList::HandleDropRelease` at `0x004E4790` -- `ItemHolder::AttemptToPlaceInContainer_IsItemLegal` at `0x005870C0` -- `ItemHolder::AttemptToPlaceInContainer_IsContainerLegal` at `0x005879B0` -- `ItemHolder::WillItemFitInContainer` at `0x00587D60` -- `ItemHolder::IsDragIntoContainerAttemptLegal` at `0x00587E90` - -### Drop target dispatch is ordered - -Retail's three-dimensional drop/give dispatcher follows this practical order: - -1. Require an owned, movable source that is not currently in trade. -2. Dropping on self means the main backpack. -3. Target zero means ground placement or split-to-world. -4. Try stack merge before treating the target as a container. -5. A player target opens/routes through secure trade. -6. A creature target uses give-item behavior. -7. A container target must be open, unlocked, and legal. -8. Vendor lists use their own staging rules. -9. Otherwise resolve as a ground placement or refuse it. - -`AttemptMerge` uses the selected split amount, clamps to target capacity, sends -the merge request, and selects the target stack. Give-item is request-only; it -does not optimistically remove the source from canonical inventory. - -Primary anchors: - -- `ItemHolder::AttemptMerge` at `0x005878F0` -- `ItemHolder::AttemptPlaceIn3D` at `0x00588600` -- `docs/research/2026-07-13-retail-give-item-pseudocode.md` -- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` - -### Use and equip - -Generic double-click use passes through `ItemHolder::DetermineUseResult` and -`ItemHolder::UseObject`, with a short use throttle. The item is classified as -direct-use, targeted-use, pickup, equip/autowear, trade, salvage, or game use. -Retail locally refuses invalid states and prints a `ClientLocal` message. - -Paperdoll 3D clicks and discrete equipment-slot lists share the same global -selection/examine model. Dropping on a paperdoll location validates the exact -location, then chooses auto-wear or auto-wield behavior. Clothing overlap can -be rejected locally; weapon replacement has different rules. - -Primary anchors: - -- `ItemHolder::DetermineUseResult` at `0x00588460` -- `ItemHolder::UseObject` at `0x00588A80` -- `CPlayerSystem::UsingItem` at `0x00562F70` -- `gmPaperDollUI::ListenToElementMessage` at `0x004A5C30` -- `gmPaperDollUI::AcceptDragObject` at `0x004A3B10` -- `gmPaperDollUI::AcceptPaperDollDragObject` at `0x004A4A70` -- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` - -### Vendor rows are active item lists - -Direct named-retail evidence establishes these behaviors: - -- Double-clicking a vendor browse row buys one item. -- Double-clicking a staged buying row removes it and prints - “Removing %s from shopping list” through `ClientLocal`. -- Double-clicking a staged selling row removes it, clears its sell state, and - prints the same form of message. -- Dragging an already-staged selling row removes it from the staged list. -- If a partial split is selected while dragging a staged selling row, retail - refuses to split that row, prints “You cannot split items from this panel”, - and resets the split control to the stack maximum. -- A new partial-stack drag into the Selling list sends a split request, creates - a temporary staged row, and replaces that row when the new matching object - arrives. -- Hover rejection is silent; release rejection prints to `ClientLocal`. - -Primary anchors: - -- `gmVendorUI::HandleMousePresses` at `0x004C40D0` -- `gmVendorUI::RecvNotice_ItemListBeginDrag` at `0x004C4380` -- `VendorSellUI::DragItemAcceptable` at `0x004C20C0` -- `VendorSellUI::AcceptDragObject` at `0x004C4F00` -- `VendorSellUI::ItemAttributesChanged` at `0x004C3FD0` - -This corrects an older project research conclusion: browse-row double-click -buy is retail behavior. It is not an acdream modernization. - -### Feedback destination - -Retail item-policy and request-failure messages are sent on the local client -text channel. In acdream, `RuntimeCommunicationState.AddText` maps -`ClientLocal` (`0x1A`) to the SpewBox only: it does not add the line to the -chat transcript and does not apply a chat timestamp. - -Hover failures are normally silent. Release/action failures are not. Server -request failures are composed by `ACCWeenieObject::ServerSaysAttemptFailed` -at `0x0058EAE0`, including move and wield failures. - -## Current acdream ownership and routing - -The relevant production flow is: - -```text -UiRoot / UiItemSlot - -> InventoryController | ExternalContainerController | PaperdollController - | VendorUiController | SelectedObjectController - -> ItemInteractionController / AutoWieldController - -> RuntimeInventoryState + RuntimeActionState transactions - -> ClientObjectTable (canonical object ownership) - -> outbound request builder - -> authoritative object update / request failure - -> RuntimeCommunicationState.ClientLocal -> SpewBox -``` - -Important owners: - -- `SelectionState` is the sole selected-object owner shared by inventory, - paperdoll, vendor, world selection, and toolbar status. -- `RuntimeInventoryState` owns external-container state, item-use transaction - state, shared busy/request state, split/pending placement state, and borrows - the canonical `ClientObjectTable`. -- `SelectedObjectController` projects selection into the authored toolbar and - owns the split-slider presentation. -- `ItemInteractionController` classifies use/drop/give/move operations and - sends requests. -- `InventoryController`, `ExternalContainerController`, `PaperdollController`, - and `VendorUiController` own their list-specific input and projections. - -This ownership shape aligns with the architecture document. The central issue -is not duplicate state; it is which state is mutated before acknowledgement. - -## Behavior matrix - -| Surface/action | Retail | Current acdream | Verdict | -|---|---|---|---| -| Inventory left press | Target-mode consume, otherwise select | `PrimaryItemPressed` does the same | Match | -| Ordinary item single click | Select; no generic use | Mouse-down selects | Match | -| Ordinary item double-click | Generic use/equip | `DoubleClicked = ActivateItem` | Match | -| Owned side-pack single press | Select and open in the item-list handler | Selects and opens on mouse-down | Match, implemented | -| Owned side-pack double-click | Open behavior; generic item double-use suppressed | Opens once; generic activation is suppressed | Match, implemented | -| Inventory right-click | Select, then examine | Select and examine | Match | -| Drag lift | Select if needed; source ghost | Selects and ghosts | Match | -| Drag hover | Silent, legality-aware green/red | Silent and shares the release legality decision | Match, implemented | -| Full internal move | Request plus pending destination projection; canonical source waits for server | Request-first pending projection; authoritative update commits | Match, implemented | -| Merge stacks | Merge before container placement; selected split amount; select target | Same broad behavior | Match | -| Partial move to container | Split request; wait for authoritative object | Request-only | Match | -| Drop to ground | Request/pending presentation; source remains canonical until response | Request-first; canonical source waits for response | Match, implemented | -| Split to ground | Global pending split; select arriving matching object; timeout | Request/pending path exists | Broad match | -| Pick up from ground | Pending destination projection; authoritative commit | Pending destination path | Match | -| Open external container | Root/nested list-specific behavior | Root double-click, nested open behavior | Broad match | -| Move to external container | Request-only, open/unlocked legality, server commit | Request-only with shared hover/release legality | Match, implemented | -| Give to creature | Request-only; selected split amount | Request-only | Match | -| Give/drop to player | Secure-trade routing | Secure-trade routing exists | Broad match | -| Paperdoll click/right-click | Global select/examine | Global select/examine | Match | -| Paperdoll drag equip | Exact location validation; auto-wear/wield | Same broad split | Broad match | -| Full wield | Authoritative request model | Request-first; canonical ownership waits for response | Match, implemented | -| Invalid item use/equip | ClientLocal text in SpewBox | Shared `ReportClientLocal` route | Match, implemented | -| Selected status | Normal name or `{quantity} name`; owned coin is `%d %hs (of %d)` | Both branches implemented | Match, implemented | -| Split applicability | Only selected stack uses global quantity | Same | Match | -| Vendor browse single/right | Select; right-click examine | Select and right-click examine | Match | -| Vendor browse double | Buy one | Buy one | Match | -| Drag inventory to Selling | Stage full or selected partial quantity | Present; partial temp-row replacement present | Match, #445 gate pending | -| Vendor hover refusal | Silent | Silent | Match | -| Vendor release refusal | ClientLocal/SpewBox | System message/SpewBox path | Match | -| Staged Buying double-click | Remove one + SpewBox line | Same | Match, implemented | -| Staged Selling double-click | Remove row, clear state + SpewBox line | Same | Match, implemented | -| Staged Selling drag | Remove row; partial selection warns and resets split | Same, exact refusal + reset | Match, implemented | -| Staged row right-click | Generic select/examine item-list behavior | Select and examine on every vendor list role | Match, implemented | -| Main-pack capacity | Items and carried containers counted separately | Separate loose-item count now present | Match, #449 gate pending | -| Server move/wield failure text | Exact ClientLocal move/wield compositions | Both request kinds and compositions present | Match, implemented | - -## Findings - -### F1 — local inventory refusals can be completely silent - -**Resolution:** CLOSED IN CODE — one `ReportClientLocal` route now selects -interface text, system text, or the test fallback in that order. - -**Priority:** P0 - -**Confidence:** Confirmed by production composition - -`ItemInteractionController` uses two different presentation routes: - -- `_systemMessage` / `_interfaceText`, which are wired to - `RuntimeCommunicationState.AddText(..., ClientLocal)` and reach SpewBox. -- `_toast`, used by many local policy refusals. - -`InteractionRetainedUiComposition` forwards its `toast` dependency, but -`GameWindow` currently sets the production composition toast to `null` after -the developer-toast surface was removed. Consequently, the local rejection -still aborts the action, but the user receives no explanation. - -Affected classes include invalid item use, missing use target, trade/wield -requirements, locked or unsuitable targets, invalid move/give/drop states, -midair/drop refusal, and paperdoll slot-in-use refusal. Exact membership should -be frozen in a focused message-routing test before changing it. - -Retail evidence is unambiguous: these are local client text messages and belong -in SpewBox, not a transient developer toast. - -**Future fix:** remove the semantic split for gameplay failure text. Give item -controllers one `ClientLocal` sink and reserve any visual toast mechanism for -non-retail developer/launcher notifications. - -### F2 — optimistic canonical moves expose impossible intermediate state - -**Resolution:** CLOSED IN CODE — full move, world drop, and wield dispatch -requests without mutating canonical ownership; pending source/destination -presentation converges on confirmation, failure, and reset. - -**Priority:** P0 architectural correction - -**Confidence:** Confirmed mechanism divergence; symptom linkage requires gates - -The full-stack internal move and world-drop paths use optimistic operations -against the canonical object table. Full wield uses the same pattern. Failure -rollback exists, but all borrowers can observe the speculative state: - -- selection and toolbar status; -- loose-item and carried-container capacity; -- paperdoll slots; -- vendor sell eligibility/staging; -- external-container views; -- plugins and Runtime views. - -Retail instead keeps source canonical ownership stable and uses waiting/ghost -presentation at the intended destination until the server update arrives. - -This does not prove that every reported intermittent inventory symptom comes -from this seam. It does explain why otherwise-correct controllers can disagree -briefly and why a rejection/late response/re-entrant action can make the UI feel -flaky. - -**Future fix:** model full move/drop/wield like the existing request-only split, -give, ground-pickup, and external-container paths. Store a generation-scoped -pending placement intent and presentation ghost, send the request, and let the -authoritative update commit canonical ownership. On failure/timeout/reset, -remove only the pending presentation. - -### F3 — vendor staged-row removal behavior is missing - -**Resolution:** CLOSED IN CODE — staged rows implement the retail -double-click, right-click, drag-lift, message, and split-reset branches. - -**Priority:** P1 - -**Confidence:** Confirmed by direct named-retail functions - -Current staged Buying and Selling rows only bind selection. They have no -double-click removal. Selling rows also disable drag source behavior. - -Retail supports: - -- double-click staged Buying to remove; -- double-click staged Selling to remove and clear sell state; -- drag staged Selling to remove; -- a precise ClientLocal removal line; -- a partial-split refusal/reset when dragging from the staged Selling list. - -**Future fix:** add list-role-specific actions rather than routing these rows -through generic item activation. Protect each action with unit tests that also -assert selection, sell-state cleanup, totals, and exact SpewBox routing. - -### F4 — owned side-pack click/double-click sequencing differs - -**Resolution:** CLOSED IN CODE — carried containers open on press and the -generic double-use route is suppressed for that list role. - -**Priority:** P1 - -**Confidence:** Confirmed structural mismatch - -Retail opens a carried child container in the item-list press handler and -suppresses generic double-click use for a `containerList` item. acdream selects -on mouse-down, opens on completed click, and binds generic activation to double -click for every inventory cell. `UiRoot` emits the second click before the -double-click event, so a double-click can both open and activate the pack. - -This is a plausible source of redundant requests and awkward drag/open -interactions. It should be fixed by explicit item-list role, not by a global -double-click timing change, because ordinary items and the external-container -root intentionally retain double-click use/open behavior. - -### F5 — hover acceptance is less strict than release/server legality - -**Resolution:** CLOSED IN CODE — `InventoryContainerPlacementPolicy` is the -shared silent-hover/speaking-release decision for owned and external lists. - -**Priority:** P1/P2 - -**Confidence:** Confirmed code difference - -Inventory-grid hover mostly checks list role, basic object class, and capacity. -External-container hover is broader still. Retail's predicates incorporate -ownership, trade state, source/destination identity, real carrying-container -restrictions, open/locked state, destination capacity type, and other legal -conditions. - -The practical symptom is a green cursor followed by a refusal or apparent -no-op on release. Hover must remain silent, but its boolean should be produced -from the same pure legality decision used at release. - -**Future fix:** extract one side-effect-free placement decision that returns a -reason code. Hover consumes only allowed/denied; release converts the same -reason to exact ClientLocal text. - -### F6 — selected status lacks retail's owned-coin special case - -**Resolution:** CLOSED IN CODE — the PDB-matched retail executable resolves -the literal at `0x007B4748` to `%d %hs (of %d)`; the controller now reads the -player's `CoinValue` and uses that exact branch for owned WCID 273 stacks. - -**Priority:** P2 - -**Confidence:** Byte-resolved from the PDB-matched retail executable - -Normal current text—name for a singleton and `{stackSize} {name}` for a -stack—matches the main retail branch. Retail has an additional owned-coinstack -formatting branch that derives a total/value-aware display and name. The -current controller always uses the generic stack prefix. - -Binary inspection resolves the apparent vtable-symbol artifact: the raw call -site pushes `0x007B4748`, `%d %hs (of %d)`, with stack size, appropriate name, -and the player's integer `CoinValue` as its three arguments. - -### F7 — request failure coverage omits move and wield kinds - -**Resolution:** CLOSED IN CODE — both request kinds are represented and route -through the item-aware retail failure composer. - -**Priority:** P2 - -**Confidence:** Confirmed enum/composer gap - -Retail's `ServerSaysAttemptFailed` includes move and wield result families. -The current request-failure model and `InventoryFailureMessages` cover merge, -split, pickup, put, drop, and give, but do not represent the retail move/wield -families. A server-side failure in those operations therefore cannot produce -the exact item-aware retail sentence through the common composer. - -### F8 — current retail-divergence documentation is wrong about vendor double-click - -**Resolution:** CLOSED — the older research is corrected and AP-171 retired. - -**Priority:** Documentation correction before implementation - -**Confidence:** Confirmed by direct named-retail evidence - -Older vendor research and AP-171 characterize double-click browse-row purchase -as an acdream enhancement. `gmVendorUI::HandleMousePresses` directly calls -`BuySingleItem` on the retail Items-list double-click. Current browse behavior -is correct; the documentation is not. Leaving this claim in the register risks -a future parity cleanup deleting a retail feature. - -### F9 — #445 and #449 need connected acceptance, not more inference - -**Priority:** Gate now - -**Confidence:** Automated fixes present - -- #445 now uses the selected split quantity for vendor selling, creates a - temporary staged row, and replaces it when the authoritative split object - arrives. -- #449 now counts loose items separately from carried container objects when - deciding whether the main backpack is full. - -Both have focused tests in the current working tree. Neither should be marked -closed until a live server gate covers success, refusal, repeated action, and -selection changes. - -### F10 — paperdoll disappearance is a separate rendering/residency defect - -**Priority:** Keep separate from transaction fixes - -**Confidence:** Existing issue #443 - -The intermittent missing paperdoll that heals after a delay is tracked as -paperdoll first-open/residency behavior. It can make a correct equip transaction -look broken, so it belongs in the combined user gate, but it should not be -folded into inventory ownership or input logic without evidence. - -## SpewBox contract - -The following should appear in the SpewBox through `ClientLocal` when the user -commits the action and it is refused or changed: - -- invalid use/equip/wield state; -- “choose a target” or invalid target; -- cannot move/drop/give an item; -- locked, closed, full, or otherwise illegal destination; -- merge/split/pickup/put/drop/give/move/wield request failure; -- vendor item cannot be sold or split in that list; -- removal from a vendor shopping/selling list; -- automatic removal of conflicting wear items where retail reports it; -- midair or other locally cancelled placement when retail reports it. - -The following should be silent: - -- merely hovering a rejected drop target; -- moving the pointer away without releasing; -- ordinary selection changes; -- beginning a legal drag. - -These messages should not be duplicated into the normal chat log and should -not gain chat timestamps. That is already how `ClientLocal` behaves in the -communication owner. - -## Existing automated coverage - -The repository already has strong narrow coverage in: - -- `InventoryControllerTests`: population, selection, open/right-click, - drag/ghost, pending pickup, split, merge, capacity, rollback, and #449. -- `ExternalContainerControllerTests`: root/nested behavior, selection, - right-click, partial split, and pending gates. -- `PaperdollControllerTests`: selection, examine, drag, and wield placement. -- `SelectedObjectControllerTests`: name, stack status, slider, and vendor split - initialization. -- `VendorUiControllerTests`: browse, buy quantities, selection/examine, - staging, partial vendor split/failure, rejection feedback, and alternate - currency. -- `ItemInteractionControllerTests`: use/equip, world drop, give, partial-stack - behavior, failures, and transaction lifecycle. -- Runtime inventory tests: request ownership, reset, and lifecycle behavior. - -The pre-implementation test suite was strongest at proving controller-local -intent. The implementation program below adds the missing transaction and -cross-controller coverage. - -## Automated gates added by the implementation - -The implementation adds or updates coverage for the following: - -1. A production-composition test proving every local policy rejection reaches - `ClientLocal`/SpewBox and no gameplay failure depends on a toast callback. -2. Owned side-pack single/double-click tests proving one open action and no - generic use request, including the second-click event order. -3. Vendor staged Buying and Selling double-click removal tests with exact - selection, totals, state cleanup, and message assertions. -4. Vendor staged Selling drag-to-remove and selected-partial split-reset tests. -5. Staged vendor-row right-click select/examine tests. -6. A table-driven pure legality test shared by hover and release for inventory, - external container, ground, player, creature, vendor, self, locked container, - full item slots, and full container slots. -7. Owned coinstack toolbar-status parity using the byte-resolved exact format. -8. Move and wield authoritative failure-composition tests. -9. Transaction-observer tests proving canonical ownership does not change - before acknowledgement while selection, capacity, vendor, and paperdoll - borrow the same state. -10. Re-entrant sequences: drag while a request is pending, selection change - during split, rejection after container close, late response after session - reset, and repeated action after rollback. - -## Executed implementation program - -### Slice 1 — feedback integrity — COMPLETE - -- Replace gameplay `toast` refusal calls with the shared ClientLocal sink. -- Add the missing move/wield failure kinds and exact item-aware compositions. -- Freeze hover-silent versus release-speaks behavior. -- Correct the vendor double-click documentation claim. - -This is small, high-confidence, and immediately turns “nothing happened” into -an actionable player explanation. - -### Slice 2 — authoritative placement ownership — COMPLETE - -- Introduce one generation-scoped pending placement record for full move, - world drop, and wield. -- Preserve canonical source ownership until the authoritative object update. -- Project source waiting/ghost and destination pending visuals separately. -- Converge success, refusal, timeout, disconnect, and late-response cleanup. -- Prove all borrowed observers see either pre-commit or committed state, never - a speculative canonical move. - -This is the most important solidity work and should receive dual review because -it crosses Runtime ownership and retained presentation. - -### Slice 3 — item-list mouse parity — COMPLETE - -- Make carried-container press/open and double-click suppression explicit. -- Add staged vendor double-click removal. -- Add staged Selling drag-to-remove and split reset/refusal. -- Restore right-click select/examine consistently across vendor list roles. - -### Slice 4 — shared legality and exact presentation — COMPLETE - -- Unify hover/release placement decisions with reason codes. -- Add the owned-coinstack toolbar branch after capturing exact retail text. -- Reconcile hard-coded local item wording with DAT-backed retail strings. - -### Automated verification — COMPLETE - -- Focused inventory/external-container/paperdoll/vendor/selection/item-use - matrix: 328 passed, 0 failed. -- Cross-controller retained-UI interaction flow: 10 passed, 0 failed. -- Complete Release build: 0 warnings, 0 errors. -- Repository hermetic lane (the exact release filter, serial execution): - 15,755 passed, 0 skipped, 0 failed across 14 test assemblies. - -The repository wrapper's project-consistency preflight explicitly excludes the -tracked deployment-only ACE comparison mods under `tools/ace-mods/`. They -compile against a separately installed ACE server and intentionally remain -outside `AcDream.slnx`; the portable product graph still owns every other -project under `src/`, `tests/`, and `tools/`. - -### Slice 5 — connected closure — DEFERRED OWNER GATE - -Run the manual matrix below against ACE using an exact built binary and retain -logs/screenshots for failures. Close #445 and #449 only after their rows pass. -Keep #443 independent unless the evidence links paperdoll rendering to an -inventory acknowledgement. - -## Connected manual matrix - -Use one normal item, one wearable item, one wieldable item, two mergeable -stacks, one side pack, a full main backpack, a full side pack, an open chest, -a locked/closed container if available, a creature/player target, and a vendor -with normal and alternate currency. - -1. Single-click each item/list type; verify selection border and exact status. -2. Right-click inventory, side-pack, external-container, paperdoll, browse, - Buying, and Selling rows; verify selection and examine. -3. Double-click ordinary usable, wearable, wieldable, and unusable items; - verify one request and correct SpewBox refusal where applicable. -4. Single- and double-click a carried side pack; verify one open action, no - redundant generic use, and stable selection. -5. Drag a full item between main pack and side pack; observe source/destination - before response, after success, and after forced rejection. -6. Fill a side pack, reject a move, free one slot, and retry immediately. -7. Fill the main pack with loose items while carrying side packs; verify item - and container capacities independently (#449). -8. Merge full and partial stacks; verify selected split amount, target - selection, source remainder, and full-target refusal text. -9. Split to an inventory container, external container, creature, ground, and - vendor; change selection while the request is pending. -10. Drop full and partial stacks to ground; verify ghost/pending behavior, - selected arriving object, rejection cleanup, and no duplicate item. -11. Pick up from ground into a nearly full destination, then retry after - freeing capacity. -12. Equip by double-click and by paperdoll drag; test clothing conflict and - weapon replacement. Verify source/paperdoll state before acknowledgement. -13. Drag full and partial stacks to vendor Selling; verify exact quantities, - temp-row replacement, totals, and #445 behavior. -14. Double-click staged Buying and Selling rows to remove them; verify SpewBox - text and state cleanup. -15. Drag a staged Selling row to remove it; repeat with a partial split selected - and verify refusal plus slider reset. -16. Complete/cancel transactions in normal and alternate currency; verify - currency balance refresh (#444) and selection/status stability. -17. Repeat representative actions while another inventory request is pending, - immediately after rejection, and immediately after reopening a container. -18. Log out/portal/re-enter with a pending or recently completed interaction; - verify the request ledger and pending projections converge to zero. - -For every refused release/action, record whether the cursor was green/red, -whether a SpewBox line appeared, the exact line, and whether canonical item -ownership changed before the server response. - -## Evidence index - -Retail research already in the tree: - -- `docs/research/deepdives/r06-items-inventory.md` -- `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md` -- `docs/research/2026-07-13-retail-give-item-pseudocode.md` -- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` -- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` -- `docs/research/2026-08-08-slice6-vendor-transactions-research.md` -- `docs/research/named-retail/acclient_2013_pseudo_c.txt` - -Primary current implementation surfaces: - -- `src/AcDream.App/UI/UiRoot.cs` -- `src/AcDream.App/UI/UiItemSlot.cs` -- `src/AcDream.App/UI/ItemInteractionController.cs` -- `src/AcDream.App/UI/Layout/InventoryController.cs` -- `src/AcDream.App/UI/Layout/ExternalContainerController.cs` -- `src/AcDream.App/UI/Layout/PaperdollController.cs` -- `src/AcDream.App/UI/Layout/SelectedObjectController.cs` -- `src/AcDream.App/UI/Layout/VendorUiController.cs` -- `src/AcDream.App/UI/AutoWieldController.cs` -- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` -- `src/AcDream.App/Rendering/GameWindow.cs` -- `src/AcDream.Core/Items/ItemInteractionPolicy.cs` -- `src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs` -- `src/AcDream.Runtime/Gameplay/RuntimeActionState.cs` - -## Closure statement - -The retail-backed work order is implemented through Slice 4. The code now has -one ClientLocal feedback route, request-first authoritative placement, -list-role-specific retail mouse behavior, shared placement legality, complete -move/wield failure composition, exact local-policy literals, and the exact -owned-coinstack status format. Occupied-slot AutoWield now also follows retail's -move-confirm-retry transaction instead of emitting an invented refusal. The -complete hermetic automated lane is green. No connected acceptance is claimed -here; the combined owner gate remains the final closure step, and #443 remains -an independent private-viewport residency issue. diff --git a/docs/research/2026-08-26-retail-keyboard-routing-audit.md b/docs/research/2026-08-26-retail-keyboard-routing-audit.md deleted file mode 100644 index 75af11e7..00000000 --- a/docs/research/2026-08-26-retail-keyboard-routing-audit.md +++ /dev/null @@ -1,115 +0,0 @@ -# Retail keyboard defaults and routing audit — 2026-08-26 - -## Verdict - -The code gate for #446 now covers all 306 user-bindable rows in the installed -Sept-2013 EoR ActionMap. Each row has a distinct `InputAction`, appears enabled -in Configure Keyboard, persists through retail-compatible named `.keymap` -profiles, and reaches -a concrete subsystem consumer. The exact installed-DAT default chord set has -zero exceptions. The remaining gate is a connected visual/behavior pass and a -fresh-process persistence check. - -The approved acdream extension is deliberately retained: mouse-wheel chase -zoom may pull back to 40 m. It does not change the retail keyboard defaults or -the keypad camera actions. - -## Oracles - -- `docs/research/named-retail/retail-default.keymap.txt` and installed - `client_portal.dat` ActionMap DID `0x26000000`: the 306 rows, default chords, - activation types, input contexts, and `ConflictingMaps` relationships. -- Installed MasterInputMaps `0x14000000` and `0x14000002`: non-bindable system - and mouse commands. -- `ClientUISystem::OnAction @0x00564B90`: Escape priority. -- `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800` and - `ControlNameMapper::LoadSemantics`: displayed keyboard/mouse names. -- `ACCmdInterp::InitializeEmoteInputActionHash @0x0058B510`: all 87 emote - action-to-motion mappings. -- `CPlayerSystem::SelectNext @0x0055F9A0`: selection-cycle filtering and - opened-corpse behavior. - -## Exact ActionMap coverage - -| Retail input map | Rows | Consumer | -|---|---:|---| -| Movement | 14 | Runtime movement owner, including four postures | -| Camera + alternate camera | 22 | Held camera input, presets, alternate-scope modifier, instant mouse look | -| Combat + melee + missile + magic | 32 | Runtime combat attack owner and spellcasting controller, including spell slots 1–12 | -| Emotes | 87 | Exact retail raw-motion table and Runtime `ExecuteMotion` | -| Item selection | 26 | Selection controller/query and canonical inventory interaction state | -| UI | 42 | Retained panels, screenshot, help/plugin result, logout, and selection commands | -| Chat + chat-entry toggle | 7 | Retained chat entry/reply/command routes | -| Quickslots | 28 | Toolbar use/select/create routes, including slots 10–18 | -| Character settings | 48 | Exact `CharacterOptionId` bit toggle through Runtime | -| **Total** | **306** | **306 distinct live identities** | - -The low MasterInputMap entries such as bare Escape and raw mouse event -commands are intentionally not Configure Keyboard rows in retail and are not -counted among the 306. Unknown rows from a future DAT can still round-trip in -the compatibility sibling store, but the installed EoR DAT has no such row and -shows no dimmed/store-only keyboard entry. - -## Behavior completed - -- Defaults are an exact installed-DAT transcription, including bare - `LeftShift`; device, modifier, activation, and scope all match. -- Primary and alternate camera maps remain distinct rebind targets even where - retail reuses an action id. The alternate modifier changes the active camera - scope without aliasing saved bindings. -- Same physical chord may fire each distinct retail action allowed by the - ActionMap. In particular, the authored Alt+1..4 chat/UI and quickslot rows - multicast instead of one silently replacing the other. -- Rebind conflicts use the DAT `ConflictingMaps` table. The shared melee, - missile, and magic key cluster remains legal; true conflicts still prompt. -- Capture accepts keyboard keys, modifier-only bindings, and mouse buttons. - Physical modifier self-bits are normalized, so binding LeftShift does not - accidentally become Shift+LeftShift. Mouse button names use retail's - `DIMOFS_BUTTON0..7` semantics table. Unsupported joystick and left/right - mouse inputs keep the instruction dialog open and re-arm capture. -- Setting the chord already present on the same row is a no-op. New chords use - retail's dense two-slot insertion rule, and conflicts use priority dialogs - with the exact installed-DAT singular/plural and non-bindable text. -- Apply/OK, Revert, Defaults, Cancel, explicit unbinding, schema migration, - and startup persistence are covered. Revert is enabled only while dirty; - OK avoids rewriting an unchanged file. -- Load File and Save As use retail's type-7 menu/type-5 text-entry dialogs, - PFile bracket-text grammar, filename normalization, overwrite/read-only - handling, `Documents\Asheron's Call\*.keymap` directory, selected-profile - preference, startup load, and graceful-shutdown rewrite. The portable JSON - file remains only as an acdream-host-command compatibility mirror. -- Escape follows retail's priority: finish jump charge, release focused UI, - stop movement/repeat attack, cancel target mode, clear selection, then - toggle the authored Gameplay Options page. It never exits player mode or - exposes the orbit/developer camera. Shift+Escape reaches the normal logout - gate. -- Selection cycling applies retail's containment, cloaking, radar, attackable, - fellow, vendor, environment, combat-mode, and opened-corpse rules. - Opened-corpse history lives for the session and retires on object deletion. -- Screenshot, help, and plugin actions are consumed. Missing separately - shipped retail help/plugin surfaces report an honest chat/system result - rather than doing nothing. - -## Automated verification - -- App: 6,413/6,413 passed. -- Core: 4,713/4,713 passed. -- Runtime: 1,849/1,849 passed. -- UI.Abstractions: 879/879 passed. -- Installed-DAT identity/default conformance and the authored Configure - Keyboard mount pin all 306 rows. - -The `.keymap` codec parses the committed real retail file and round-trips all -306 user-bindable identities, including low-bit Shift/Ctrl/Alt/Win modifiers, -DirectInput controls, fixed Escape/system/edit/pointer maps, and the 48 -CharacterOption action names. AP-202 is retired. The connected gate could not -be run because no local ACE endpoint was listening on UDP port 9000. - -## Connected acceptance gate - -Use the installed EoR DATs and the normal owner-gate pak. In Configure -Keyboard, verify that all rows are enabled and that a key, modifier-only chord, -and mouse button can each be rebound. Exercise representative movement, -camera, melee/missile/magic, emote, selection, panel, chat, quickslot, and -character-option actions. Verify conflict prompt, Cancel, Revert, Defaults, -Apply, and OK, then restart the process and confirm the applied bindings remain. diff --git a/docs/superpowers/specs/2026-08-26-mosstank-autocombat-design.md b/docs/superpowers/specs/2026-08-26-mosstank-autocombat-design.md deleted file mode 100644 index 87c0f26e..00000000 --- a/docs/superpowers/specs/2026-08-26-mosstank-autocombat-design.md +++ /dev/null @@ -1,95 +0,0 @@ -# MossTank autocombat design - -Date: 2026-08-26 - -## Outcome - -Ship the first VTank-class MossTank milestone: a polished in-client controller -that performs safe automatic melee, missile or direct-spell combat while all -policy remains in the plugin and all authoritative state/actions remain in -Runtime. - -## Architecture - -```text -Runtime canonical owners - entity directory + object table + selection + combat + spellbook - | - v -AppAutomationSurface (borrowed projection, no ownership) - PluginCombatTarget[] + PluginCombatSnapshot + attempt commands - | - v -MossTank CombatController (policy/state machine) - scan -> score/lock -> mode -> charge/cast -> wait -> repeat - | - v -retained plugin panel (bindings only) -``` - -`AcDream.Plugin.Abstractions` stays BCL-only. New interfaces use records, -enums, arrays/lists and primitives only. Existing interfaces gain default -members where needed so API v1 plugins remain loadable. - -## API additions - -- `PluginCombatTarget`: id, name, weenie class, distance, signed relative - angle, health-known and health fraction. -- `PluginCombatSnapshot`: selected id, mode, charge/request state, power and - server-pending state. -- `ICombatAutomation`: immutable hostile snapshot plus explicit mode, - begin/release/abort attempts. -- `ISpellCatalog.KnownAttackSpells`: learned, direct offensive spells. -- `IAutomationSurface.Combat`: the combat group. - -Attempt results distinguish unavailable, invalid target, wrong mode, busy, -transition started and sent/started. This avoids `bool` APIs whose `false` -cannot tell a plugin whether to wait, retry, reselect or stop. - -## Target snapshots - -`RuntimeHostileTargetQuery` is extended with a snapshot capture method. It -borrows the same entity directory and `ClientObjectTable` used by gameplay, -filters with the same `CombatTargetPolicy`, and computes distance and relative -heading using retail's `MoveToMath` helpers. Hidden, no-draw, dead and -cell-less entities are excluded. The App surface refreshes at bounded cadence -and publishes one immutable list reference; retained UI reads do not scan the -world or allocate. - -## Combat controller - -States: - -1. `Off`: no automation command may be emitted. -2. `Acquire`: keep a valid lock or choose the lowest score. -3. `Mode`: request the equipped default combat mode and wait for confirmation. -4. `PhysicalCharge`: select, set power, press height, then wait until the - canonical meter reaches desired power before release. -5. `MagicCast`: select and cast the chosen known offensive spell. -6. `Wait`: wait while physical server response, repeat state or magic busy is - active, then reacquire/repeat. - -Target scoring first applies ordered rules (initial slice supplies a default -priority and an ignore-name list), then applies the configured selection -method. Target lock keeps the current target while it remains admissible. - -The controller never fabricates success. Health and disappearance retire a -target; timeouts return to `Acquire`; session loss transitions to `Off` and -aborts an in-progress physical build. - -## UI - -The main window becomes a dashboard rather than a single force-buff button: -macro toggle, current target/mode, state, vitals, combat settings, buff action -and settings navigation. Generic markup gains bound child visibility/enabled -and color/style attributes so active controls read as active without App types -leaking into the plugin. - -## Verification - -- pure controller tests for selection policies, lock, mode transition, - charge/release, busy suppression, magic choice, disable and session loss; -- Runtime query tests for filter, range, distance, relative angle and health; -- App projection tests for caching and command mapping where practical; -- markup parser tests for new generic bindings; -- MossTank, Runtime, App and complete Release solution gates. diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index 3ccc68c8..ec9fe281 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -112,17 +112,13 @@ AfterTargets="Build" Condition="'$(IsCrossTargetingBuild)' != 'true'"> + <_SmokePluginSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework) + <_SmokePluginSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginSourceDir)/$(RuntimeIdentifier) <_SmokePluginDestDir>$(OutputPath)plugins/AcDream.Plugins.Smoke - - - + <_SmokePluginPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework) + <_SmokePluginPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginPublishSourceDir)/$(RuntimeIdentifier) <_SmokePluginPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.Smoke - - - + <_MossTankSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework) + <_MossTankSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankSourceDir)/$(RuntimeIdentifier) <_MossTankDestDir>$(OutputPath)plugins/AcDream.Plugins.MossTank - - - + <_MossTankPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework) + <_MossTankPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankPublishSourceDir)/$(RuntimeIdentifier) <_MossTankPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.MossTank - - - -/// Routes combat refusal text to whichever surface is bound to show it — -/// in production, the SpewBox as RetailLogTextType.ClientLocal -/// (retail: ClientCombatSystem::ExecuteAttack 0x0056bb70 → -/// ClientSystem::AddTextToScroll(..., 0x1A, ...)). -/// -/// -/// #434/#436: the bound target used to be the developer DebugVM, -/// which Campaign V slice V11 left unreachable, so these messages were -/// silently dropped. The session composition now owns the binding via -/// (same lifetime shape as -/// ); before a session binds — -/// and after it tears down — is a deliberate no-op. -/// internal sealed class CombatFeedbackSlot : ICombatFeedbackSink { - private Action? _target; + private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _viewModel; - public void Bind(Action target) + public void Bind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel) { - ArgumentNullException.ThrowIfNull(target); - if (_target is not null && !ReferenceEquals(_target, target)) + ArgumentNullException.ThrowIfNull(viewModel); + if (_viewModel is not null && !ReferenceEquals(_viewModel, viewModel)) throw new InvalidOperationException( - "Combat feedback is already bound to a presentation target."); - _target = target; + "Combat feedback is already bound to a developer view model."); + _viewModel = viewModel; } - public IDisposable BindOwned(Action target) + public void Unbind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel) { - ArgumentNullException.ThrowIfNull(target); - if (_target is not null) - throw new InvalidOperationException( - "Combat feedback is already bound to a presentation target."); - _target = target; - return new Binding(this, target); + ArgumentNullException.ThrowIfNull(viewModel); + if (ReferenceEquals(_viewModel, viewModel)) + _viewModel = null; } - public void Unbind(Action target) - { - ArgumentNullException.ThrowIfNull(target); - if (ReferenceEquals(_target, target)) - _target = null; - } - - public void Show(string message) => _target?.Invoke(message); - - private sealed class Binding(CombatFeedbackSlot slot, Action target) - : IDisposable - { - public void Dispose() => slot.Unbind(target); - } + public void Show(string message) => _viewModel?.AddToast(message); } internal sealed class CombatAttackOperationsSlot @@ -239,10 +209,7 @@ internal sealed class LiveCombatAttackOperations if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)) { - // Retail is SILENT here: ClientCombatSystem::ExecuteAttack - // (0x0056bb70) is unreachable outside melee/missile modes, so - // no user-facing text exists for this case — only the no-target - // branch below speaks (#436). + _feedback.Show("Enter melee or missile combat first"); Console.WriteLine( "combat: attack ignored; not in melee/missile combat mode"); return false; @@ -250,9 +217,7 @@ internal sealed class LiveCombatAttackOperations if (_targets.GetSelectedOrClosestCombatTarget(_settings.AutoTarget) is null) { - // Retail: ExecuteAttack's edi==0 branch (0x0056bc05) → - // AddTextToScroll(0x1A) — the ClientLocal SpewBox channel. - _feedback.Show(AcDream.Core.Chat.ClientTextRefusals.MustSelectCombatTarget); + _feedback.Show("No monster target"); Console.WriteLine("combat: attack ignored; no creature target found"); return false; } diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs index ae4e4d58..5b6e72a3 100644 --- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs +++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs @@ -51,9 +51,6 @@ internal sealed record ContentEffectsAudioResult( internal sealed record ContentEffectsAudioDependencies( string DatDirectory, string PreparedAssetPath, - string? PreparedAssetOverlayPath, - uint? PreparedAssetBaseRecipeVersion, - uint? PreparedAssetEffectiveRecipeVersion, ResidencyBudgetOptions ResidencyBudgets, PhysicsDataCache PhysicsDataCache, bool DumpMotionEnabled, @@ -102,9 +99,6 @@ internal interface IContentEffectsAudioCompositionFactory IDatReaderWriter OpenDatCollection(string datDirectory); IPreparedAssetSource OpenPreparedAssetSource( string path, - string? overlayPath, - uint? baseRecipeVersion, - uint? effectiveRecipeVersion, IDatReaderWriter dats, Action diagnostic); MagicCatalog LoadMagicCatalog(IDatReaderWriter dats); @@ -175,54 +169,9 @@ internal sealed class RetailContentEffectsAudioCompositionFactory public IPreparedAssetSource OpenPreparedAssetSource( string path, - string? overlayPath, - uint? baseRecipeVersion, - uint? effectiveRecipeVersion, IDatReaderWriter dats, - Action diagnostic) - { - if (string.IsNullOrWhiteSpace(overlayPath)) - { - return new PakPreparedAssetSource(path, dats, diagnostic); - } - - if (baseRecipeVersion is not > 0 - || effectiveRecipeVersion is not > 0) - { - throw new InvalidDataException( - "Layered prepared content is missing its recipe identities."); - } - - if (effectiveRecipeVersion - != AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion) - { - throw new InvalidDataException( - $"Prepared content recipe {effectiveRecipeVersion} does not " - + $"match client recipe " - + $"{AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion}."); - } - - PakPreparedAssetSource? baseSource = null; - PakPreparedAssetSource? overlaySource = null; - try - { - baseSource = new PakPreparedAssetSource( - path, - PreparedAssetCatalogIdentity.From(dats, baseRecipeVersion.Value), - diagnostic); - overlaySource = new PakPreparedAssetSource( - overlayPath, - PreparedAssetCatalogIdentity.From(dats, effectiveRecipeVersion.Value), - diagnostic); - return new LayeredPreparedAssetSource(baseSource, overlaySource); - } - catch - { - overlaySource?.Dispose(); - baseSource?.Dispose(); - throw; - } - } + Action diagnostic) => + new PakPreparedAssetSource(path, dats, diagnostic); public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => MagicCatalog.Load(dats); @@ -421,9 +370,6 @@ internal sealed class ContentEffectsAudioCompositionPhase : "prepared asset source", () => _factory.OpenPreparedAssetSource( _dependencies.PreparedAssetPath, - _dependencies.PreparedAssetOverlayPath, - _dependencies.PreparedAssetBaseRecipeVersion, - _dependencies.PreparedAssetEffectiveRecipeVersion, dats, _dependencies.Error), static value => value.Dispose()).Publish( diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index cb0899e0..d74658b3 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -17,7 +17,6 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Player; -using AcDream.Core.Properties; using AcDream.Core.Selection; using AcDream.Core.Spells; using AcDream.Runtime; @@ -96,9 +95,7 @@ internal sealed record InteractionRetainedUiDependencies( Func CurrentCalendar, AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null, Func? - RenderPackDiagnostics = null, - string? ScreenshotsDirectory = null, - AppAutomationSurface? Automation = null) + RenderPackDiagnostics = null) { public RuntimeActionState Actions => Runtime.ActionOwner; @@ -390,15 +387,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory container, placement, amount), - sendStackableMerge: (source, target, amount) => - session.CurrentSession?.SendStackableMerge(source, target, amount), requestExternalContainer: guid => { - ClientObject? container = d.Inventory.Objects.Get(guid); - bool isCorpse = container is not null - && ((PublicWeenieFlags)(container.PublicWeenieBitfield ?? 0u) - & PublicWeenieFlags.Corpse) != 0; - d.Inventory.ExternalContainers.RequestOpen(guid, isCorpse); + d.Inventory.ExternalContainers.RequestOpen(guid); }, requestUse: selection.RequestUse, // Slice 6.3: ItemInteractionController.TryBuy owns the @@ -430,13 +421,6 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory return false; activeSession.SendSell(vendorGuid, items); return true; - }, - sendSalvage: (toolGuid, itemGuids) => - { - if (session.CurrentSession is not { } activeSession || !session.IsInWorld) - return false; - activeSession.SendSalvage(toolGuid, itemGuids); - return true; }); } @@ -543,19 +527,6 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory var cursorManager = new RetailCursorManager(d.Dats, d.DatLock); checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated); - // Campaign CT slice CT3 (2026-08-24): the Titles page's DAT - // id -> display-string chain (CT2). Constructed once — its own - // constructor does no DAT I/O (only .Resolve reads touch the - // dats), matching the characterCreationStrings precedent below. - // CT4 (2026-08-24) also feeds this resolver's display-title text - // into the character panel's own heritage line (CharacterSheet.Title). - var characterTitleResolver = new CharacterTitleResolver(d.Dats); - // CT4: the header identity block's PK-status line (StringTable - // 0x23000001, ID_StatManagement_Header_PKStatus_* keys — the same - // compute_str_hash mechanism ChatWindowController's chatStrings - // delegate already uses). One instance, same DatLock discipline - // as characterTitleResolver above. - var characterUiStrings = new DatStringResolver(d.Dats); var characterSheet = new CharacterSheetProvider( d.Inventory.Objects, d.Character.LocalPlayer, @@ -582,17 +553,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.GameRuntime.Advance( RuntimeAdvancementKind.TrainSkill, statId, - credits), - titles: d.Character.Titles, - resolveDisplayTitle: titleId => - { - lock (d.DatLock) return characterTitleResolver.Resolve(titleId); - }, - resolveUiString: key => - { - lock (d.DatLock) - return characterUiStrings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key)); - }); + credits)); checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated); uint MagicSkillLevel(MagicSchool school) @@ -678,20 +639,16 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory CharacterKey: () => d.Settings.ActiveToonKey, ScreenSize: () => (d.Window.Size.X, d.Window.Size.Y)); void ProbeLog(string message) => d.Log("[UI-PROBE] " + message); - string screenshotDirectory = - d.Options.UiProbeEnabled - && d.Options.AutomationArtifactDirectory is { } artifactDirectory - ? Path.Combine(artifactDirectory, "screenshots") - : !string.IsNullOrWhiteSpace(d.ScreenshotsDirectory) - ? d.ScreenshotsDirectory - : Path.Combine( - Path.GetDirectoryName(d.KeyBindingsFilePath)!, - "screenshots"); - var screenshots = new FrameScreenshotController( - d.BackbufferReader, - screenshotDirectory, - ProbeLog, - d.RenderPackDiagnostics); + FrameScreenshotController? screenshots = null; + if (d.Options.UiProbeEnabled + && d.Options.AutomationArtifactDirectory is { } artifactDirectory) + { + screenshots = new FrameScreenshotController( + d.BackbufferReader, + Path.Combine(artifactDirectory, "screenshots"), + ProbeLog, + d.RenderPackDiagnostics); + } checkpoint(InteractionRetainedUiCompositionPoint.UiProbeCreated); var assets = new RetailUiAssets( @@ -865,11 +822,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory && d.Inventory.Objects.Get(guid) is { } vendorCandidate && vendorCandidate.ContainerId == d.Inventory.Vendor.VendorId && VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)), - Character: new CharacterRuntimeBindings( - characterSheet, - d.Character.Titles, - characterTitleResolver, - SendSetTitle: titleId => late.GameRuntime.SetTitle(titleId)), + Character: new CharacterRuntimeBindings(characterSheet), Inventory: new InventoryRuntimeBindings( d.Inventory.Objects, () => d.PlayerIdentity.ServerGuid, @@ -948,14 +901,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory item, inscription), text => - d.Communication.AddText(text, RetailLogTextType.ClientLocal), - // AS4 (gap G6): the SAME LocalPlayerState instance - // characterSheet was built from above — no new state - // path, just another read of its PlayerDescription- - // backed property snapshot. - LocalFactionBits: () => - d.Character.LocalPlayer.Properties.GetInt( - (uint)PropertyInt.Faction1Bits)), + d.Communication.AddText(text, RetailLogTextType.ClientLocal)), Options: new OptionsRuntimeBindings( CommandBus: () => late.Session.Commands, // Tri-state per gmGamePlayUI::UseTime @0x004EA3A0's exact @@ -1176,10 +1122,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.GameRuntime.CharacterSelectionConfirmDelete, late.GameRuntime.CharacterSelectionRestore, late.GameRuntime.CharacterSelectionCancel, - // Campaign LA gate round 2 finding 1: the character - // selection screen's Exit button uses the ordinary host - // close path. Gameplay Escape is independent: retail - // clears selection or toggles the Gameplay Options page. + // Campaign LA gate round 2 finding 1: the SAME + // window-close path GameplayInputCommandController's + // Escape fallback uses (IGameplayWindowCommands.Close + // /GameplayWindowCommands wrap this same d.Window.Close + // delegate) — no separate exit path. d.Window.Close), // Campaign CC slice CC4: same late-bound generation-capturing // seam as CharacterSelection above. RequestExit here is a @@ -1221,27 +1168,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance, RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing, GetSkillScore: chargenSkillScoreResolver.Resolve, - OpenOnStart: d.Options.OpenCharacterCreationOnStart), - CaptureScreenshot: () => - { - if (screenshots.TryRequestRetailScreenshot( - out string path, - out string error)) - { - d.Communication.AddText( - $"Screenshot saved to {path}", - RetailLogTextType.ClientLocal); - } - else - { - d.Communication.AddText( - $"Screenshot failed: {error}", - RetailLogTextType.ClientLocal); - } - }, - ProjectileDebugSamples: d.Automation is null - ? null - : d.Automation.CaptureProjectileDebugSamples); + OpenOnStart: d.Options.OpenCharacterCreationOnStart)); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index afaaf7ae..87f8b1cd 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -150,15 +150,6 @@ internal sealed class DeferredGameRuntimeStateCommands generation, new RuntimeAdvancementCommand(kind, statId, cost))); - /// Campaign CT slice CT3 (2026-08-24): retail TitleSet - /// (0x002C) — same generation-capturing late seam as every other - /// method here. - /// is the only caller. - public RuntimeCommandResult SetTitle(uint titleId) => - Invoke((commands, generation) => commands.Character.SetTitle( - generation, - titleId)); - // Campaign LA slice LA8: the retained character-management screen uses // the same generation-capturing late seam as every gameplay panel. The // screen never receives GameRuntime or WorldSession and cannot retain a diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 3ba05a6a..aa9391f8 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -63,7 +63,6 @@ internal sealed record LivePresentationDependencies( CellVisibility CellVisibility, LiveWorldOriginState WorldOrigin, LocalPlayerIdentityState PlayerIdentity, - ChaseCameraInputState ChaseCameraInput, PointerPositionState PointerPosition, PlayerApproachCompletionState PlayerApproachCompletions, GameRenderResourceLifetime RenderResourceLifetime, @@ -809,12 +808,7 @@ internal sealed class LivePresentationCompositionPhase d.RetailAlphaQueue, alphaScratchBudgets.DispatcherBytes, foundation.TerrainAtlas?.BuildingDetailTexture ?? default, - () => d.Settings.DisplayPreview.BuildingDetailTextures, - serverGuid => serverGuid != 0u - && serverGuid == d.PlayerIdentity.ServerGuid - ? d.ChaseCameraInput.Retail?.PlayerTranslucency - ?? (d.ChaseCameraInput.Legacy?.IsInHead == true ? 1f : 0f) - : 0f), + () => d.Settings.DisplayPreview.BuildingDetailTextures), static value => value.Dispose()); var selectionQuery = new WorldSelectionQuery( liveEntities, @@ -851,11 +845,7 @@ internal sealed class LivePresentationCompositionPhase localEntityId => d.EffectPoses.TryGetRootPose(localEntityId, out Matrix4x4 childRoot) ? childRoot - : null, - hasOpenedCorpse: - d.Runtime.InventoryOwner.ExternalContainers.HasCorpseBeenOpened, - combatMode: () => d.Runtime.ActionOwner.Combat.CurrentMode, - isFellow: guid => d.Runtime.Fellowship.TryGetMember(guid, out _)); + : null); var radarSnapshotProvider = new RadarSnapshotProvider( d.EntityObjects.Objects, liveEntities, @@ -886,12 +876,7 @@ internal sealed class LivePresentationCompositionPhase () => d.PlayerController.Controller, d.PlayerApproachCompletions), d.Toast, - d.PlayerApproachCompletions, - splitStack: guid => - interaction.RetainedUi?.Runtime.SelectedObjectController? - .FocusSplitStackEntry(guid) ?? false, - fellowshipMembers: () => - d.Runtime.Fellowship.GetMembers().Select(static member => member.Guid)); + d.PlayerApproachCompletions); selectionInteractionSource.Bind(selectionInteractions); bindings.Adopt( "world selection", diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index da60f580..879b9920 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -83,7 +83,6 @@ internal sealed record SessionPlayerDependencies( CombatFeedbackSlot CombatFeedback, TransferableResourceSlot PortalTunnelFallback, Action Log, - Func? TryHandlePluginCommand, /// Campaign LA slice LA1: the shared per-session status-event /// writer, no-op when was /// not configured. @@ -113,7 +112,6 @@ internal sealed record SessionPlayerResult( DatSpawnClaimHydrationClassifier SpawnClaimHydration, LiveSessionController LiveSession, LiveEntityHydrationController Hydration, - LiveEntityDeletionController Deletion, LiveEntityNetworkUpdateController NetworkUpdates, LiveEntityLivenessController Liveness, LiveEntitySessionController SessionEvents, @@ -359,8 +357,7 @@ internal sealed class SessionPlayerCompositionPhase // LiveSessionCommandSurface has no dependencies of its own, so // hoisting its construction is inert; the later site now reuses // this instance instead of constructing a second one. - var liveSessionCommands = new LiveSessionCommandSurface( - d.TryHandlePluginCommand); + var liveSessionCommands = new LiveSessionCommandSurface(); var settingsTargets = new RuntimeSettingsTargets( new SilkRuntimeDisplayWindowTarget(d.Window), live.DrawDispatcher, @@ -418,23 +415,6 @@ internal sealed class SessionPlayerCompositionPhase ? revealMeshes.SetDestinationRevealUploadPriority : static _ => { }, foundation.TextureCache.SetDestinationRevealUploadPriority); - IRenderFrameResourceDiagnosticsSource? revealResourceDiagnostics = - StreamingDiagnostics.ProbeRevealTiming - ? new RuntimeRenderFrameResourceDiagnosticsSource( - particles: null, - particleBindings: null, - worldDispatcher: revealDispatcher, - environmentCells: null, - particleRenderer: null, - uiTextRenderer: null, - portalDepthMask: null, - clipFrame: null, - terrain: null, - lighting: null, - meshes: foundation.MeshAdapter, - textures: foundation.TextureCache, - preparedAssets: content.PreparedAssets) - : null; var worldReveal = new WorldRevealCoordinator( live.WorldTransit, // #280: read the radii LIVE from the streaming controller rather @@ -470,8 +450,7 @@ internal sealed class SessionPlayerCompositionPhase worldQuiescence, streaming, revealRenderResources, - () => live.WorldState.LoadedLandblockCount, - revealResourceDiagnostics); + () => live.WorldState.LoadedLandblockCount); Fault(SessionPlayerCompositionPoint.WorldRevealCreated); return CompleteSessionPlayer( @@ -583,7 +562,8 @@ internal sealed class SessionPlayerCompositionPhase d.EntityObjects, teardown, d.PlayerIdentity, - dormantLiveEntities); + dormantLiveEntities, + d.Options.DumpLiveSpawns ? d.Log : null); // 2026-08-08 vendor-approach root cause: the local player's // publication-chain host resolves moveto/sticky targets through // RuntimePhysicsState.ResolveObjectTableHost. Bind the graphical @@ -729,6 +709,7 @@ internal sealed class SessionPlayerCompositionPhase d.PlayerIdentity, deletion, dormantLiveEntities, + d.Options.DumpLiveSpawns ? d.Log : null, firstEntryDrive, acceptedPositionDrive); bindings.Adopt( @@ -830,16 +811,6 @@ internal sealed class SessionPlayerCompositionPhase liveSessionSource, liveSessionSource, d.CombatFeedback))); - // #436: combat refusal text goes to the SpewBox as ClientLocal — - // retail's ClientCombatSystem::ExecuteAttack (0x0056bb70) routes - // "You must select a valid combat target before attacking" through - // AddTextToScroll(0x1A). Session-owned so a torn-down session's - // slot goes back to its silent unbound state. - bindings.Adopt( - "combat feedback", - d.CombatFeedback.BindOwned( - text => d.Communication.AddText( - text, RetailLogTextType.ClientLocal))); Fault(SessionPlayerCompositionPoint.CombatOperationsBound); MouseLookController? mouseLook = @@ -1172,7 +1143,6 @@ internal sealed class SessionPlayerCompositionPhase live.SelectionInteractions), new LiveSessionWorldRuntime( content.Dats, - d.DatLock, content.Audio?.Engine is { } sessionAudioEngine ? new AcDream.App.Audio.WorldAudioSessionGate( sessionAudioEngine, @@ -1283,10 +1253,14 @@ internal sealed class SessionPlayerCompositionPhase new RetainedGameplayWindowCommands( interaction.RetainedUi?.Runtime), runtimeDiagnostics, - new PlayerModeGameplayCommands(playerMode), + new PlayerModeGameplayCommands( + d.PlayerMode, + playerMode), new ItemTargetModeCommands(interaction.ItemInteraction), + new GameplayCameraModeCommands(host.CameraController), gameRuntime, gameRuntime.Combat, + new GameplayWindowCommands(d.Window.Close), toggleAudioMute: content.Audio?.Engine is { } audioEngine ? () => { @@ -1305,7 +1279,6 @@ internal sealed class SessionPlayerCompositionPhase gameRuntime, gameRuntime.Selection, gameRuntime.MovementCommands, - gameRuntime.CharacterCommands, commands); GameplayInputActionRouter gameplayActions = GameplayInputActionRouter.Create( @@ -1335,7 +1308,6 @@ internal sealed class SessionPlayerCompositionPhase spawnClaimClassifier, liveSession, hydration, - deletion, networkUpdates, liveness, sessionEvents, diff --git a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs index 526df5f3..15e7df79 100644 --- a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs +++ b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs @@ -18,8 +18,8 @@ namespace AcDream.App.Composition; /// UiHost/UiRoot tree — D1) instead of a new /// IPanelRenderer implementation, and its OP9 closeout retired the /// unrendered ImGui-era SettingsPanel/SettingsVM outright. Keybind remapping -/// is Campaign OP slice OP8's Configure Keyboard screen, persisting retail -/// *.keymap profiles with keybinds.json as the host-command mirror. +/// is Campaign OP slice OP8's Configure Keyboard screen, persisting to +/// keybinds.json (not retail's .keymap format — register row AP-202). /// internal sealed record SettingsDevToolsResult( AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality) diff --git a/src/AcDream.App/Configuration/SessionConfiguration.cs b/src/AcDream.App/Configuration/SessionConfiguration.cs index 8e55c922..ec225785 100644 --- a/src/AcDream.App/Configuration/SessionConfiguration.cs +++ b/src/AcDream.App/Configuration/SessionConfiguration.cs @@ -71,12 +71,6 @@ internal sealed class SessionContentDescriptor [JsonRequired] public string PreparedAssetPath { get; init; } = string.Empty; - - public string? PreparedAssetOverlayPath { get; init; } - - public uint? PreparedAssetBaseRecipeVersion { get; init; } - - public uint? PreparedAssetEffectiveRecipeVersion { get; init; } } internal sealed record SessionDescriptor diff --git a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs index eaab04a3..086cb11e 100644 --- a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs +++ b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs @@ -83,18 +83,6 @@ internal static class SessionConfigurationLoader throw new SessionConfigurationException( "process.content requires non-empty datDirectory and preparedAssetPath."); } - - bool hasOverlay = !string.IsNullOrWhiteSpace( - content.PreparedAssetOverlayPath); - bool hasBaseRecipe = content.PreparedAssetBaseRecipeVersion is > 0; - bool hasEffectiveRecipe = - content.PreparedAssetEffectiveRecipeVersion is > 0; - if (hasOverlay != hasBaseRecipe || hasOverlay != hasEffectiveRecipe) - { - throw new SessionConfigurationException( - "process.content overlay path, base recipe, and effective recipe " - + "must be supplied together."); - } } private static void ValidateSession(SessionDescriptor session) diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs index 9d1d0396..a2588d5b 100644 --- a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs +++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs @@ -63,36 +63,6 @@ internal sealed class FrameScreenshotController return true; } - /// - /// Queues the first free retail-style screenshot name. Retail scans - /// ScreenShot00000.jpg through ScreenShot99999.jpg beside - /// its preferences file; acdream keeps the exact stem/numbering while - /// writing lossless PNGs in the portable screenshots directory. - /// - public bool TryRequestRetailScreenshot(out string path, out string error) - { - for (int index = 0; index < 100_000; index++) - { - string name = $"ScreenShot{index:D5}"; - string candidate = Path.Combine(_directory, name + ".png"); - if (File.Exists(candidate) || _status.ContainsKey(name)) - continue; - - if (TryRequest(name, out error)) - { - path = candidate; - return true; - } - - path = string.Empty; - return false; - } - - path = string.Empty; - error = "all retail screenshot names ScreenShot00000 through ScreenShot99999 are in use"; - return false; - } - public bool IsComplete(string name) => _status.TryGetValue(name, out CaptureStatus? status) && status.State == CaptureState.Complete; diff --git a/src/AcDream.App/Input/CameraPointerInputController.cs b/src/AcDream.App/Input/CameraPointerInputController.cs index 9104323f..aa2672da 100644 --- a/src/AcDream.App/Input/CameraPointerInputController.cs +++ b/src/AcDream.App/Input/CameraPointerInputController.cs @@ -316,88 +316,6 @@ internal sealed class CameraPointerInputController } } - public bool HandleCameraAction( - InputAction action, - ActivationType activation) - { - if (activation != ActivationType.Press - || !_playerMode.IsPlayerMode - || !_camera.IsChaseMode) - { - return false; - } - - bool handled = action is - InputAction.CameraViewDefault - or InputAction.CameraAlternateViewDefault - or InputAction.CameraViewFirstPerson - or InputAction.CameraAlternateViewFirstPerson - or InputAction.CameraViewLookDown - or InputAction.CameraAlternateViewLookDown - or InputAction.CameraViewMapMode - or InputAction.CameraAlternateViewMapMode; - if (!handled) - return false; - - ApplyCameraPreset(_chase.Retail, action); - ApplyCameraPreset(_chase.Legacy, action); - return true; - } - - private static void ApplyCameraPreset( - RetailChaseCamera? camera, - InputAction action) - { - if (camera is null) - return; - switch (action) - { - case InputAction.CameraViewDefault: - case InputAction.CameraAlternateViewDefault: - camera.SetRetailDefaultView(); - break; - case InputAction.CameraViewFirstPerson: - case InputAction.CameraAlternateViewFirstPerson: - camera.SetRetailFirstPersonView(); - break; - case InputAction.CameraViewLookDown: - case InputAction.CameraAlternateViewLookDown: - camera.ToggleRetailLookDownView(); - break; - case InputAction.CameraViewMapMode: - case InputAction.CameraAlternateViewMapMode: - camera.ToggleRetailMapModeView(); - break; - } - } - - private static void ApplyCameraPreset( - ChaseCamera? camera, - InputAction action) - { - if (camera is null) - return; - switch (action) - { - case InputAction.CameraViewDefault: - case InputAction.CameraAlternateViewDefault: - camera.SetRetailDefaultView(); - break; - case InputAction.CameraViewFirstPerson: - case InputAction.CameraAlternateViewFirstPerson: - camera.SetRetailFirstPersonView(); - break; - case InputAction.CameraViewLookDown: - case InputAction.CameraAlternateViewLookDown: - camera.ToggleRetailLookDownView(); - break; - case InputAction.CameraViewMapMode: - case InputAction.CameraAlternateViewMapMode: - camera.ToggleRetailMapModeView(); - break; - } - } - public string AdjustSensitivity(float factor) { string mode; diff --git a/src/AcDream.App/Input/DispatcherCameraInputSource.cs b/src/AcDream.App/Input/DispatcherCameraInputSource.cs index 94a6ea8f..dae50088 100644 --- a/src/AcDream.App/Input/DispatcherCameraInputSource.cs +++ b/src/AcDream.App/Input/DispatcherCameraInputSource.cs @@ -15,9 +15,7 @@ internal readonly record struct ChaseCameraAdjustmentInput( bool ZoomIn, bool ZoomOut, bool Raise, - bool Lower, - bool RotateLeft, - bool RotateRight); + bool Lower); internal interface ICameraFrameInputSource { @@ -73,21 +71,9 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource return default; return new ChaseCameraAdjustmentInput( - dispatcher.IsActionHeld(InputAction.CameraZoomIn) - || dispatcher.IsActionHeld(InputAction.CameraMoveToward) - || dispatcher.IsActionHeld(InputAction.CameraAlternateMoveToward), - dispatcher.IsActionHeld(InputAction.CameraZoomOut) - || dispatcher.IsActionHeld(InputAction.CameraMoveAway) - || dispatcher.IsActionHeld(InputAction.CameraAlternateMoveAway), - dispatcher.IsActionHeld(InputAction.CameraRaise) - || dispatcher.IsActionHeld(InputAction.CameraRotateUp) - || dispatcher.IsActionHeld(InputAction.CameraAlternateRotateUp), - dispatcher.IsActionHeld(InputAction.CameraLower) - || dispatcher.IsActionHeld(InputAction.CameraRotateDown) - || dispatcher.IsActionHeld(InputAction.CameraAlternateRotateDown), - dispatcher.IsActionHeld(InputAction.CameraRotateLeft) - || dispatcher.IsActionHeld(InputAction.CameraAlternateRotateLeft), - dispatcher.IsActionHeld(InputAction.CameraRotateRight) - || dispatcher.IsActionHeld(InputAction.CameraAlternateRotateRight)); + dispatcher.IsActionHeld(InputAction.CameraZoomIn), + dispatcher.IsActionHeld(InputAction.CameraZoomOut), + dispatcher.IsActionHeld(InputAction.CameraRaise), + dispatcher.IsActionHeld(InputAction.CameraLower)); } } diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs index 10bf5a8f..876ac7ad 100644 --- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs +++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs @@ -63,7 +63,7 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource return default; if (_movement.HasCommandInput) - return _movement.CommandInput with { IsPersistentCommand = true }; + return _movement.CommandInput; if (_dispatcher is not { } dispatcher) return default; diff --git a/src/AcDream.App/Input/GameplayInputActionRouter.cs b/src/AcDream.App/Input/GameplayInputActionRouter.cs index 303b91b0..9840e0b1 100644 --- a/src/AcDream.App/Input/GameplayInputActionRouter.cs +++ b/src/AcDream.App/Input/GameplayInputActionRouter.cs @@ -3,7 +3,6 @@ using AcDream.App.Rendering; using AcDream.App.UI; using AcDream.Core.Combat; using AcDream.Runtime; -using AcDream.Runtime.Gameplay; using AcDream.UI.Abstractions.Input; namespace AcDream.App.Input; @@ -15,8 +14,6 @@ internal interface IGameplayInputActionSurface void RemoveFired(Action callback); void SetCombatScope(InputScope? scope); - - void SetCameraAlternateScope(bool active); } internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher) @@ -33,9 +30,6 @@ internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispa public void SetCombatScope(InputScope? scope) => _dispatcher.SetCombatScope(scope); - - public void SetCameraAlternateScope(bool active) => - _dispatcher.SetCameraAlternateScope(active); } internal interface ICombatModeEventSurface @@ -72,8 +66,6 @@ internal interface IGameplayInputPriorityTargets bool HandleRetainedUiAction(InputAction action); - bool HandleCharacterOptionAction(InputAction action); - bool HandleSelectionAction(InputAction action); bool HandlePressedMovementAction(InputAction action); @@ -95,7 +87,6 @@ internal sealed class RuntimeGameplayInputPriorityTargets private readonly IGameRuntimeView _runtimeView; private readonly IRuntimeSelectionCommands _runtimeSelection; private readonly IRuntimeMovementCommands _runtimeMovement; - private readonly IRuntimeCharacterCommands _runtimeCharacter; private readonly IGameplayInputCommandTarget _commands; public RuntimeGameplayInputPriorityTargets( @@ -106,7 +97,6 @@ internal sealed class RuntimeGameplayInputPriorityTargets IGameRuntimeView runtimeView, IRuntimeSelectionCommands runtimeSelection, IRuntimeMovementCommands runtimeMovement, - IRuntimeCharacterCommands runtimeCharacter, IGameplayInputCommandTarget commands) { _frame = frame ?? throw new ArgumentNullException(nameof(frame)); @@ -119,14 +109,11 @@ internal sealed class RuntimeGameplayInputPriorityTargets ?? throw new ArgumentNullException(nameof(runtimeSelection)); _runtimeMovement = runtimeMovement ?? throw new ArgumentNullException(nameof(runtimeMovement)); - _runtimeCharacter = runtimeCharacter - ?? throw new ArgumentNullException(nameof(runtimeCharacter)); _commands = commands ?? throw new ArgumentNullException(nameof(commands)); } public bool HandlePointerAction(InputAction action, ActivationType activation) => - _frame.HandlePointerAction(action, activation) - || _pointer.HandleCameraAction(action, activation); + _frame.HandlePointerAction(action, activation); public void HandleScroll(InputAction action) => _pointer.HandleScroll(action); @@ -135,74 +122,10 @@ internal sealed class RuntimeGameplayInputPriorityTargets _frame.HandleCombatAction(action, activation); public bool HandleRetainedUiAction(InputAction action) => - FinishJumpBeforeUi(action) - || _retainedUi?.HandleInputAction(action) == true; - - public bool HandleCharacterOptionAction(InputAction action) - { - if (!RetailActionIdentityTable.TryGetCharacterOptionId( - action, - out uint optionId) - || !CharacterOptionTable.TryGet( - optionId, - out CharacterOptionTableEntry entry)) - { - return false; - } - - RuntimeCharacterOptionsSnapshot options = - _runtimeView.Character.Snapshot.Options; - uint word = entry.IsOptions1 ? options.Options1 : options.Options2; - bool current = (word & entry.Mask) != 0u; - _runtimeCharacter.SetSingleOption( - _runtimeView.Generation, - optionId, - !current); - return true; - } - - private bool FinishJumpBeforeUi(InputAction action) - { - RuntimeMovementCommand? command = ResolveEscapeMovementCommand( - action, - _runtimeView.Movement.IsStandingStill, - _runtimeView.Movement.JumpCharge, - _runtimeView.Actions.Snapshot.CombatAttack); - if (command != RuntimeMovementCommand.FinishJump) - { - return false; - } - - _runtimeMovement.Execute( - _runtimeView.Generation, - command.Value); - return true; - } + _retainedUi?.HandleInputAction(action) == true; public bool HandleSelectionAction(InputAction action) { - if (action == InputAction.EscapeKey) - { - IRuntimeMovementView movement = _runtimeView.Movement; - RuntimeCombatAttackSnapshot attack = _runtimeView.Actions.Snapshot - .CombatAttack; - RuntimeMovementCommand? escapeCommand = - ResolveEscapeMovementCommand( - action, - movement.IsStandingStill, - movement.JumpCharge, - attack); - if (escapeCommand == RuntimeMovementCommand.StopCompletely) - { - _runtimeMovement.Execute( - _runtimeView.Generation, - escapeCommand.Value); - if (attack.RepeatAttackInProgress) - _frame.AbortAutomaticAttack(); - return true; - } - } - RuntimeSelectionCommand? command = action switch { InputAction.SelectionClosestMonster => @@ -226,32 +149,15 @@ internal sealed class RuntimeGameplayInputPriorityTargets return _selection?.HandleInputAction(action) == true; } - internal static RuntimeMovementCommand? ResolveEscapeMovementCommand( - InputAction action, - bool isStandingStill, - in AcDream.Runtime.Gameplay.JumpChargeSnapshot jumpCharge, - in RuntimeCombatAttackSnapshot attack) - { - if (action != InputAction.EscapeKey) - return null; - if (jumpCharge.IsCharging) - return RuntimeMovementCommand.FinishJump; - if (!isStandingStill || attack.RepeatAttackInProgress) - return RuntimeMovementCommand.StopCompletely; - return null; - } - public bool HandlePressedMovementAction(InputAction action) { - if (RetailEmoteMotionTable.TryGetMotion(action, out uint motion)) + RuntimeMovementCommand? command = action switch { - _runtimeMovement.ExecuteMotion( - _runtimeView.Generation, - motion); - return true; - } - - RuntimeMovementCommand? command = ResolvePressedMovementCommand(action); + InputAction.MovementRunLock => + RuntimeMovementCommand.ToggleRunLock, + InputAction.MovementStop => RuntimeMovementCommand.Stop, + _ => null, + }; if (command is { } typed) { _runtimeMovement.Execute(_runtimeView.Generation, typed); @@ -261,18 +167,6 @@ internal sealed class RuntimeGameplayInputPriorityTargets return _frame.HandlePressedMovementAction(action); } - internal static RuntimeMovementCommand? ResolvePressedMovementCommand( - InputAction action) => action switch - { - InputAction.MovementRunLock => RuntimeMovementCommand.ToggleRunLock, - InputAction.MovementStop => RuntimeMovementCommand.Stop, - InputAction.Ready => RuntimeMovementCommand.Ready, - InputAction.Sitting => RuntimeMovementCommand.Sit, - InputAction.Crouch => RuntimeMovementCommand.Crouch, - InputAction.Sleeping => RuntimeMovementCommand.Sleep, - _ => null, - }; - public void HandleCommand(InputAction action) => _commands.Handle(action); } @@ -404,14 +298,6 @@ internal sealed class GameplayInputActionRouter : IDisposable { _log($"[input] {action} {activation}"); - if (action == InputAction.CameraActivateAlternateMode) - { - if (activation == ActivationType.Press) - _actions.SetCameraAlternateScope(true); - else if (activation == ActivationType.Release) - _actions.SetCameraAlternateScope(false); - } - if (_targets.HandlePointerAction(action, activation)) return; @@ -434,8 +320,6 @@ internal sealed class GameplayInputActionRouter : IDisposable if (_targets.HandleRetainedUiAction(action)) return; - if (_targets.HandleCharacterOptionAction(action)) - return; if (_targets.HandleSelectionAction(action)) return; if (_targets.HandlePressedMovementAction(action)) diff --git a/src/AcDream.App/Input/GameplayInputCommandController.cs b/src/AcDream.App/Input/GameplayInputCommandController.cs index 246c52b4..f3fbb3a1 100644 --- a/src/AcDream.App/Input/GameplayInputCommandController.cs +++ b/src/AcDream.App/Input/GameplayInputCommandController.cs @@ -1,5 +1,6 @@ using AcDream.App.Combat; using AcDream.App.Diagnostics; +using AcDream.App.Rendering; using AcDream.App.UI; using AcDream.Runtime; using AcDream.UI.Abstractions.Input; @@ -23,12 +24,6 @@ internal interface IRetainedGameplayWindowCommands /// RetailUiRuntime.BindToolbarPanelButtons. /// void ToggleOptionsPanel(); - - void ToggleGameplayOptionsPage(); - - void FocusChatEntry(); - - void LogOutCharacter(); } internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime) @@ -44,31 +39,35 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime) public void ToggleOptionsPanel() => _runtime?.ToggleWindow(WindowNames.Options); - - public void ToggleGameplayOptionsPage() => - _runtime?.ToggleGameplayOptionsPage(); - - public void FocusChatEntry() => _runtime?.FocusChatEntry(); - - public void LogOutCharacter() => _runtime?.LogOutCharacter(); } internal interface IPlayerModeGameplayCommands { + bool IsPlayerMode { get; } + void ToggleFlyOrChase(); void TogglePlayerMode(); + + void ExitPlayerMode(); } -internal sealed class PlayerModeGameplayCommands(PlayerModeController controller) - : IPlayerModeGameplayCommands +internal sealed class PlayerModeGameplayCommands( + ILocalPlayerModeSource mode, + PlayerModeController controller) : IPlayerModeGameplayCommands { + private readonly ILocalPlayerModeSource _mode = mode + ?? throw new ArgumentNullException(nameof(mode)); private readonly PlayerModeController _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + public bool IsPlayerMode => _mode.IsPlayerMode; + public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase(); public void TogglePlayerMode() => _controller.Toggle(); + + public void ExitPlayerMode() => _controller.Exit(); } internal interface IItemTargetModeCommands @@ -89,6 +88,37 @@ internal sealed class ItemTargetModeCommands(ItemInteractionController items) public void CancelTargetMode() => _items.CancelTargetMode(); } +internal interface IGameplayCameraModeCommands +{ + bool IsFlyMode { get; } + + void ExitFlyMode(); +} + +internal sealed class GameplayCameraModeCommands(CameraController camera) + : IGameplayCameraModeCommands +{ + private readonly CameraController _camera = camera + ?? throw new ArgumentNullException(nameof(camera)); + + public bool IsFlyMode => _camera.IsFlyMode; + + public void ExitFlyMode() => _camera.ToggleFly(); +} + +internal interface IGameplayWindowCommands +{ + void Close(); +} + +internal sealed class GameplayWindowCommands(Action close) : IGameplayWindowCommands +{ + private readonly Action _close = close + ?? throw new ArgumentNullException(nameof(close)); + + public void Close() => _close(); +} + internal interface IGameplayInputCommandTarget { bool Handle(InputAction action); @@ -105,8 +135,10 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg private readonly IRuntimeDiagnosticCommands _diagnostics; private readonly IPlayerModeGameplayCommands _playerMode; private readonly IItemTargetModeCommands _targetMode; + private readonly IGameplayCameraModeCommands _camera; private readonly IGameRuntimeView _runtimeView; private readonly IRuntimeCombatCommands _combat; + private readonly IGameplayWindowCommands _window; private readonly Action? _toggleAudioMute; public GameplayInputCommandController( @@ -114,17 +146,21 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg IRuntimeDiagnosticCommands diagnostics, IPlayerModeGameplayCommands playerMode, IItemTargetModeCommands targetMode, + IGameplayCameraModeCommands camera, IGameRuntimeView runtimeView, IRuntimeCombatCommands combat, + IGameplayWindowCommands window, Action? toggleAudioMute = null) { _retained = retained ?? throw new ArgumentNullException(nameof(retained)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); _targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); _runtimeView = runtimeView ?? throw new ArgumentNullException(nameof(runtimeView)); _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _window = window ?? throw new ArgumentNullException(nameof(window)); _toggleAudioMute = toggleAudioMute; } @@ -170,12 +206,10 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg _playerMode.TogglePlayerMode(); return true; case InputAction.ToggleChatEntry: - case InputAction.EnterChatMode: - // Physical Tab/Enter are normally consumed by UiRoot before - // the dispatcher. This semantic route is what makes a rebound - // key and headless/UI automation reach that same retained - // chat field. - _retained.FocusChatEntry(); + // OP9: IDevToolsGameplayCommands.FocusChatInput() retired — + // same shape as AcdreamToggleDebugPanel above (its ImGui + // ChatPanel target was already gone). Tab is still consumed + // here, matching the prior no-op's "handled" contract. return true; case InputAction.ToggleOptionsPanel: // Campaign OP slice OP3 (D1): F11 opens the RETAIL Options @@ -193,9 +227,6 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg _runtimeView.Generation, RuntimeCombatCommand.ToggleMode); return true; - case InputAction.LOGOUT: - _retained.LogOutCharacter(); - return true; case InputAction.EscapeKey: HandleEscape(); return true; @@ -208,7 +239,9 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg { if (_targetMode.IsAnyTargetModeActive) _targetMode.CancelTargetMode(); + else if (_playerMode.IsPlayerMode) + _playerMode.ExitPlayerMode(); else - _retained.ToggleGameplayOptionsPage(); + _window.Close(); } } diff --git a/src/AcDream.App/Input/GameplayInputFrameController.cs b/src/AcDream.App/Input/GameplayInputFrameController.cs index 4f87b484..f5b34fd0 100644 --- a/src/AcDream.App/Input/GameplayInputFrameController.cs +++ b/src/AcDream.App/Input/GameplayInputFrameController.cs @@ -9,7 +9,6 @@ internal interface ICombatInputFrameController { void Tick(); void HandleMovementInput(InputAction action, ActivationType activation); - void AbortAutomaticAttack(); bool HandleInputAction(InputAction action, ActivationType activation); } @@ -39,11 +38,6 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle RuntimeInputActivation.Press)); } - public void AbortAutomaticAttack() => - _owner.HandleCommand(new RuntimeCombatAttackInput( - RuntimeCombatAttackCommand.AbortForMovement, - RuntimeInputActivation.Press)); - public bool HandleInputAction(InputAction action, ActivationType activation) { RuntimeCombatAttackCommand? command = action switch @@ -58,37 +52,16 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle RuntimeCombatAttackCommand.DecreasePower, InputAction.CombatIncreaseAttackPower => RuntimeCombatAttackCommand.IncreasePower, - InputAction.CombatDecreaseMissileAccuracy => - RuntimeCombatAttackCommand.DecreasePower, - InputAction.CombatIncreaseMissileAccuracy => - RuntimeCombatAttackCommand.IncreasePower, - InputAction.CombatAimLow => - RuntimeCombatAttackCommand.LowAttack, - InputAction.CombatAimMedium => - RuntimeCombatAttackCommand.MediumAttack, - InputAction.CombatAimHigh => - RuntimeCombatAttackCommand.HighAttack, _ => null, }; if (command is null) return false; - // A retail Hold binding emits Press once, Hold every input frame, then - // Release on key-up. RuntimeCombatAttackState already measures the - // Press-to-Release interval; forwarding the repeated Hold pulse as a - // Release made Delete/End/PageDown attack on the first frame instead - // of charging until the player released the key. - if (activation == ActivationType.Hold) - return true; - return _owner.HandleCommand(new RuntimeCombatAttackInput( command.Value, - activation switch - { - ActivationType.Press => RuntimeInputActivation.Press, - ActivationType.Release => RuntimeInputActivation.Release, - _ => RuntimeInputActivation.Press, - })); + activation == ActivationType.Press + ? RuntimeInputActivation.Press + : RuntimeInputActivation.Release)); } } @@ -141,8 +114,6 @@ internal sealed class GameplayInputFrameController public bool HandlePressedMovementAction(InputAction action) => _movement.HandlePressedAction(action); - public void AbortAutomaticAttack() => _combat.AbortAutomaticAttack(); - public void QueueRawMouseDelta(float dx, float dy) => _mouseLook?.QueueRawDelta(dx, dy); diff --git a/src/AcDream.App/Input/MouseLookController.cs b/src/AcDream.App/Input/MouseLookController.cs index d3b28f79..998969ae 100644 --- a/src/AcDream.App/Input/MouseLookController.cs +++ b/src/AcDream.App/Input/MouseLookController.cs @@ -149,9 +149,7 @@ internal sealed class MouseLookController : IMouseLookInputFrameController return true; } - if (action is not ( - InputAction.CameraInstantMouseLook - or InputAction.CameraActivateAlternateMode)) + if (action != InputAction.CameraInstantMouseLook) return false; if (activation == ActivationType.Press) diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index 36665902..faa2e1d5 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -332,6 +332,8 @@ internal sealed class PlayerModeController : { moveTo.MoveToComplete = error => { + if (PhysicsDiagnostics.ProbeAutoWalkEnabled) + Console.WriteLine($"[autowalk-end] reason=complete err={error}"); if (error == WeenieError.None) approachLifetime.PublishNaturalCompletion(); else diff --git a/src/AcDream.App/Input/RetailEmoteMotionTable.cs b/src/AcDream.App/Input/RetailEmoteMotionTable.cs deleted file mode 100644 index 0e7979d4..00000000 --- a/src/AcDream.App/Input/RetailEmoteMotionTable.cs +++ /dev/null @@ -1,129 +0,0 @@ -using AcDream.UI.Abstractions.Input; - -namespace AcDream.App.Input; - -/// -/// Verbatim Sept-2013 ACCmdInterp::InitializeEmoteInputActionHash -/// (0x0058B510). ACCmdInterp::OnAction -/// (0x0058B370) resolves one of these input actions and submits the -/// corresponding raw motion through SetMotion with start=true. -/// -internal static class RetailEmoteMotionTable -{ - private const uint EmoteInputMap = 0x10000006u; - private const uint FirstEmoteAction = 0x10000098u; - - // Action ids 0x10000098..0x100000EE are contiguous in retail's ActionMap. - // Values come from the named Motion_* globals used by the initializer. - private static readonly uint[] Motions = - [ - 0x43000118u, // AFKState - 0x13000088u, // Akimbo - 0x420000F9u, // ATOYOT - 0x430000F2u, // AkimboState - 0x43000146u, // AtEaseState - 0x1300007Au, // Beckon - 0x1300007Bu, // BeSeeingYou - 0x1300007Cu, // BlowKiss - 0x1300007Du, // BowDeep - 0x430000ECu, // BowDeepState - 0x1300004Cu, // Cheer - 0x1300007Eu, // ClapHands - 0x430000EDu, // ClapHandsState - 0x13000091u, // Cringe - 0x430000EEu, // CrossArmsState - 0x1300007Fu, // Cry - 0x43000117u, // CurtseyState - 0x1300014Eu, // DrudgeDance - 0x43000141u, // DrudgeDanceState - 0x1300014Fu, // HaveASeat - 0x43000145u, // HaveASeatState - 0x13000089u, // HeartyLaugh - 0x13000132u, // Helper - 0x13000092u, // Kneel - 0x430000F7u, // KneelState - 0x1300014Cu, // Knock - 0x13000080u, // Laugh - 0x430000F6u, // LeanState - 0x43000119u, // MeditateState - 0x13000082u, // MimeDrink - 0x13000081u, // MimeEat - 0x130000CBu, // Mock - 0x13000083u, // Nod - 0x13000147u, // NudgeLeft - 0x13000148u, // NudgeRight - 0x13000093u, // Plead - 0x430000F8u, // PleadState - 0x13000084u, // Point - 0x430000F0u, // PointState - 0x1300014Bu, // PointDown - 0x43000140u, // PointDownState - 0x13000149u, // PointLeft - 0x4300013Du, // PointLeftState - 0x1300014Au, // PointRight - 0x4300013Eu, // PointRightState - 0x43000142u, // PossumState - 0x130000CAu, // Pray - 0x430000EBu, // PrayState - 0x43000143u, // ReadState - 0x1300008Au, // Salute - 0x430000F3u, // SaluteState - 0x1300014Du, // ScanHorizon - 0x1300008Bu, // ScratchHead - 0x430000F4u, // ScratchHeadState - 0x13000079u, // ShakeFist - 0x430000EAu, // ShakeFistState - 0x13000085u, // ShakeHead - 0x13000094u, // Shiver - 0x430000EFu, // ShiverState - 0x13000095u, // Shoo - 0x13000086u, // Shrug - 0x4300013Au, // SitState - 0x4300013Cu, // SitBackState - 0x4300013Bu, // SitCrossleggedState - 0x13000096u, // Slouch - 0x430000FAu, // SlouchState - 0x1300008Cu, // SmackHead - 0x43000115u, // SnowAngelState - 0x13000097u, // Spit - 0x13000098u, // Surrender - 0x430000FBu, // SurrenderState - 0x4300013Fu, // TalktotheHandState - 0x1300008Du, // TapFoot - 0x430000F5u, // TapFootState - 0x130000CCu, // Teapot - 0x43000144u, // ThinkerState - 0x13000116u, // WarmHands - 0x13000087u, // Wave - 0x430000F1u, // WaveState - 0x1300008Fu, // WaveLow - 0x1300008Eu, // WaveHigh - 0x1300009Au, // Winded - 0x430000FDu, // WindedState - 0x13000099u, // Woah - 0x430000FCu, // WoahState - 0x13000090u, // YawnStretch - 0x1200009Bu, // YMCA - ]; - - public static int Count => Motions.Length; - - public static bool TryGetMotion(InputAction action, out uint motion) - { - motion = 0u; - if (!RetailActionIdentityTable.TryGetRetailIdentity( - action, - out var identity) - || identity.InputMapId != EmoteInputMap) - { - return false; - } - - uint index = identity.ActionId - FirstEmoteAction; - if (index >= Motions.Length) - return false; - - motion = Motions[index]; - return true; - } -} diff --git a/src/AcDream.App/Input/RetailKeymapFile.cs b/src/AcDream.App/Input/RetailKeymapFile.cs deleted file mode 100644 index 66130110..00000000 --- a/src/AcDream.App/Input/RetailKeymapFile.cs +++ /dev/null @@ -1,601 +0,0 @@ -using System.Globalization; -using System.Text; -using System.Text.RegularExpressions; -using AcDream.UI.Abstractions.Input; - -namespace AcDream.App.Input; - -/// -/// Parser/writer for retail's editable Documents\Asheron's Call\*.keymap -/// PFile text. Only the fourteen user-bindable input maps are replaced when a -/// profile is loaded; acdream-only commands and retail's fixed system/edit/ -/// pointer maps remain owned by the host's base . -/// -public static class RetailKeymapFile -{ - private static readonly Regex BindingLine = new( - "^(?[A-Za-z0-9_]+)\\s*\\[\\s*\"\"\\s*\\[\\s*" - + "(?[0-9]+)\\s+(?[A-Za-z0-9_]+)" - + "(?:\\s+(?[A-Za-z]+))?\\s*\\]" - + "(?:\\s+(?0x[0-9A-Fa-f]+|[0-9]+))?" - + "(?:\\s+(?[A-Za-z]+))?\\s*\\]$", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private static readonly (uint Id, string Name)[] GroupOrder = - { - (0x00000004u, "MovementCommands"), - (0x10000007u, "ItemSelectionCommands"), - (0x10000009u, "UICommands"), - (0x1000000Cu, "QuickslotCommands"), - (0x1000000Du, "ToggleChatEntry"), - (0x1000000Au, "ChatCommands"), - (0x10000002u, "Combat"), - (0x10000003u, "MeleeCombat"), - (0x10000004u, "MissileCombat"), - (0x10000005u, "MagicCombat"), - (0x10000006u, "Emotes"), - (0x00000005u, "CameraControls"), - (0x00000006u, "CameraAlternateControls"), - (0x10000008u, "CharacterOptionCommands"), - }; - - private static readonly IReadOnlyDictionary GroupIds = - GroupOrder.ToDictionary(static group => group.Name, static group => group.Id, - StringComparer.OrdinalIgnoreCase); - - // Lazy because the explicit character-option semantic table is declared - // later in this type; field initializers otherwise observe it as null. - private static readonly Lazy> ActionNamesHolder = - new(BuildActionNames); - private static readonly Lazy> ActionsByFileNameHolder = - new(BuildActionsByFileName); - private static IReadOnlyDictionary ActionNames => ActionNamesHolder.Value; - private static IReadOnlyDictionary ActionsByFileName => - ActionsByFileNameHolder.Value; - - public static KeyBindings Parse(string text, KeyBindings baseBindings) - { - ArgumentNullException.ThrowIfNull(text); - ArgumentNullException.ThrowIfNull(baseBindings); - - var result = new KeyBindings(); - foreach (Binding binding in baseBindings.All) - { - if (!RetailActionIdentityTable.ReverseMap.ContainsKey(binding.Action)) - result.Add(binding); - } - - bool foundBindings = false; - bool inBindings = false; - uint? currentGroup = null; - int lineNumber = 0; - foreach (string rawLine in text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')) - { - lineNumber++; - string line = rawLine.Trim(); - if (line.Length == 0 || line.StartsWith('#')) - continue; - if (line.Equals("Bindings", StringComparison.OrdinalIgnoreCase)) - { - foundBindings = true; - inBindings = true; - currentGroup = null; - continue; - } - if (!inBindings) - continue; - - // In the PFile grammar every input-map name is a bare identifier on - // the line before its opening bracket. An unknown map clears the - // user-map context so fixed SystemKeys/EditControls rows are ignored. - if (Regex.IsMatch(line, "^[A-Za-z][A-Za-z0-9_]*$", - RegexOptions.CultureInvariant)) - { - currentGroup = GroupIds.TryGetValue(line, out uint groupId) - ? groupId - : null; - continue; - } - if (currentGroup is not uint inputMapId || line is "[" or "]") - continue; - - Match match = BindingLine.Match(line); - if (!match.Success) - throw new FormatException( - $"Malformed retail key binding at line {lineNumber}: {line}"); - - string actionName = match.Groups["action"].Value; - if (!ActionsByFileName.TryGetValue(FileIdentity(inputMapId, actionName), out InputAction action)) - { - // Several fixed/non-user-bindable controls live inside an - // otherwise editable map (UICommands.EscapeKey/LOGOUT in the - // shipped file). They remain in baseBindings just like the - // wholly fixed maps below the user maps. - continue; - } - - string control = match.Groups["control"].Value; - if (!RetailScanCodeMap.TryFromFileControl(control, out uint scan, out uint tokenDevice) - || !uint.TryParse(match.Groups["device"].Value, - NumberStyles.None, CultureInfo.InvariantCulture, out uint device) - || device != tokenDevice - || RetailScanCodeMap.ToSilkKey(scan, device) is not { } key) - { - throw new FormatException( - $"Unsupported retail control '{control}' at line {lineNumber}."); - } - - uint fileModifier = 0u; - if (match.Groups["modifier"].Success) - { - string value = match.Groups["modifier"].Value; - NumberStyles style = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase) - ? NumberStyles.AllowHexSpecifier - : NumberStyles.None; - string digits = style == NumberStyles.AllowHexSpecifier ? value[2..] : value; - if (!uint.TryParse(digits, style, CultureInfo.InvariantCulture, out fileModifier)) - throw new FormatException($"Invalid modifier at line {lineNumber}."); - } - - var chord = new KeyChord(key, (ModifierMask)(fileModifier & 0x0Fu), (byte)device); - result.Add(new Binding( - chord, - action, - RetailActionIdentityTable.ActivationFor(inputMapId, - RetailActionIdentityTable.ReverseMap[action].ActionId), - RetailActionIdentityTable.ScopeForInputMap(inputMapId))); - } - - if (!foundBindings) - throw new FormatException("The file does not contain a retail Bindings section."); - return result; - } - - public static string Write(KeyBindings bindings) - { - ArgumentNullException.ThrowIfNull(bindings); - var output = new StringBuilder(24_000); - output.AppendLine("#Asheron's Call: Throne of Destiny Keymap File") - .AppendLine("#") - .AppendLine("#Generated by acdream's retail Configure Keyboard screen.") - .AppendLine("#This file is compatible with the Sept-2013 retail PFile keymap grammar.") - .AppendLine("#") - .AppendLine("\"User Defined Keymap\" [ 00000000-0000-0000-0000-000000000000 ]") - .AppendLine() - .AppendLine("Devices") - .AppendLine("[") - .AppendLine(" Keyboard [ GUID_SysKeyboard ]") - .AppendLine(" Mouse [ GUID_SysMouse ]") - .AppendLine(" Virtual [ GUID_Virtual ]") - .AppendLine("]") - .AppendLine() - .AppendLine("MetaKeys") - .AppendLine("[") - .AppendLine(" 1 [ 0 DIK_LSHIFT ]") - .AppendLine(" 2 [ 0 DIK_LCONTROL ]") - .AppendLine(" 2 [ 0 DIK_RCONTROL ]") - .AppendLine(" 3 [ 0 DIK_LMENU ]") - .AppendLine(" 3 [ 0 DIK_RALT ]") - .AppendLine(" 4 [ 0 DIK_LWIN ]") - .AppendLine(" 4 [ 0 DIK_RWIN ]") - .AppendLine(" 5 [ 1 DIMOFS_BUTTON3 ]") - .AppendLine(" 6 [ 1 DIMOFS_BUTTON4 ]") - .AppendLine("]") - .AppendLine() - .AppendLine("Bindings") - .AppendLine("["); - - foreach ((uint inputMapId, string groupName) in GroupOrder) - { - output.Append(" ").AppendLine(groupName).AppendLine(" ["); - foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in - RetailActionIdentityTable.Map - .Where(pair => pair.Key.InputMapId == inputMapId) - .OrderBy(static pair => pair.Key.ActionId)) - { - foreach (Binding binding in bindings.ForAction(action)) - { - if (!RetailScanCodeMap.TryToFileControl(binding.Chord, out string control)) - { - throw new InvalidOperationException( - $"{binding.Chord} cannot be represented by the retail DirectInput keymap."); - } - - output.Append(" ").Append(ActionNames[action]) - .Append(" [ \"\" [ ").Append(binding.Chord.Device) - .Append(' ').Append(control).Append(" ]"); - uint modifier = (uint)binding.Chord.Modifiers & 0x0Fu; - if (modifier != 0u) - output.Append(" 0x").Append(modifier.ToString("X8", CultureInfo.InvariantCulture)); - output.AppendLine(" ]"); - } - } - // Bare Escape is a fixed MasterInputMap control rather than one of - // the user-bindable ActionMap rows (LOGOUT is a normal row and was - // emitted above). Keep it in exported files so the Sept-2013 client - // retains its priority Escape ladder when opening our profile. - if (inputMapId == 0x10000009u) - output.AppendLine(" EscapeKey [ \"\" [ 0 DIK_ESCAPE ] ]"); - output.AppendLine(" ]").AppendLine(); - } - - // Retail's fixed maps are included so a file can also be opened by the - // 2013 client. They are deliberately not imported into the 306-row GUI. - output.Append(FixedRetailMaps); - output.AppendLine("]"); - return output.ToString(); - } - - private static IReadOnlyDictionary BuildActionNames() - { - var names = new Dictionary(); - foreach (InputAction action in RetailActionIdentityTable.ReverseMap.Keys) - names[action] = FileActionName(action); - return names; - } - - private static IReadOnlyDictionary BuildActionsByFileName() - { - var actions = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in - RetailActionIdentityTable.Map) - { - actions.Add(FileIdentity(identity.InputMapId, ActionNames[action]), action); - } - return actions; - } - - private static string FileIdentity(uint inputMapId, string actionName) => - $"{inputMapId:X8}:{actionName}"; - - private static string GroupName(uint inputMapId) => - GroupOrder.First(group => group.Id == inputMapId).Name; - - private static string FileActionName(InputAction action) - { - if (CharacterOptionNames.TryGetValue(action, out string? characterOption)) - return characterOption; - if (action == InputAction.SelectionPlaceInInventory) return "SelectionPickUp"; - if (action == InputAction.UseSelected) return "USE"; - string name = action.ToString(); - if (name.StartsWith("CameraAlternate", StringComparison.Ordinal)) - return "Camera" + name["CameraAlternate".Length..]; - if (!name.StartsWith("Emote", StringComparison.Ordinal)) - return name; - string emote = name["Emote".Length..]; - return emote switch - { - "AfkState" => "AFKState", - "AToyotState" => "ATOYOT", - "MimeDrinking" => "MimeDrink", - "MimeEating" => "MimeEat", - "TalkToTheHandState" => "TalktotheHandState", - "YawnAndStretch" => "YawnStretch", - "Ymca" => "YMCA", - _ => emote, - }; - } - - private static readonly IReadOnlyDictionary CharacterOptionNames = - new Dictionary - { - [InputAction.ToggleCharacterOptionAutoRepeatAttack] = "AutoRepeatAttacks", - [InputAction.ToggleCharacterOptionIgnoreAllegianceRequests] = "IgnoreAllegianceRequests", - [InputAction.ToggleCharacterOptionIgnoreFellowshipRequests] = "IgnoreFellowshipRequests", - [InputAction.ToggleCharacterOptionIgnoreTradeRequests] = "IgnoreTradeRequests", - [InputAction.ToggleCharacterOptionPersistentAtDay] = "PersistentAtDay", - [InputAction.ToggleCharacterOptionAllowGive] = "LetPlayersGiveYouItems", - [InputAction.ToggleCharacterOptionViewCombatTarget] = "AutoTrackCombatTargets", - [InputAction.ToggleCharacterOptionShowTooltips] = "DisplayTooltips", - [InputAction.ToggleCharacterOptionUseDeception] = "AttemptToDeceivePlayers", - [InputAction.ToggleCharacterOptionToggleRun] = "RunAsDefaultMovement", - [InputAction.ToggleCharacterOptionStayInChatMode] = "StayInChatModeAfterSend", - [InputAction.ToggleCharacterOptionAdvancedCombatUi] = "AdvancedCombatInterface", - [InputAction.ToggleCharacterOptionAutoTarget] = "AutoTarget", - [InputAction.ToggleCharacterOptionVividTargetingIndicator] = "VividTargetIndicator", - [InputAction.ToggleCharacterOptionFellowshipShareXp] = "ShareFellowshipXP", - [InputAction.ToggleCharacterOptionAcceptLootPermits] = "AcceptCorpseLooting", - [InputAction.ToggleCharacterOptionFellowshipShareLoot] = "ShareFellowshipLoot", - [InputAction.ToggleCharacterOptionFellowshipAutoAcceptRequests] = "AutomaticallyAcceptFellowshipRequests", - [InputAction.ToggleCharacterOptionCoordinatesOnRadar] = "ShowRadarCoordinates", - [InputAction.ToggleCharacterOptionSpellDuration] = "ShowSpellDurations", - [InputAction.ToggleCharacterOptionDisableHouseRestrictionEffects] = "DisableHouseEffect", - [InputAction.ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade] = "DragItemOnPlayerOpensSecureTrade", - [InputAction.ToggleCharacterOptionDisplayAllegianceLogonNotifications] = "DisplayAllegianceLogonNotifications", - [InputAction.ToggleCharacterOptionUseChargeAttack] = "UseChargeAttack", - [InputAction.ToggleCharacterOptionUseCraftSuccessDialog] = "ToggleCraftingChanceOfSuccessDialog", - [InputAction.ToggleCharacterOptionListenToAllegianceChat] = "AllegianceChat", - [InputAction.ToggleCharacterOptionDisplayDateOfBirth] = "DisplayDateOfBirth", - [InputAction.ToggleCharacterOptionDisplayAge] = "DisplayAge", - [InputAction.ToggleCharacterOptionDisplayChessRank] = "DisplayChessRank", - [InputAction.ToggleCharacterOptionDisplayFishingSkill] = "Fishing", - [InputAction.ToggleCharacterOptionDisplayNumberDeaths] = "DisplayNumberDeaths", - [InputAction.ToggleCharacterOptionDisplayTimeStamps] = "DisplayTimeStamps", - [InputAction.ToggleCharacterOptionSalvageMultiple] = "SalvageMultiple", - [InputAction.ToggleCharacterOptionListenToGeneralChat] = "GeneralChat", - [InputAction.ToggleCharacterOptionListenToTradeChat] = "TradeChat", - [InputAction.ToggleCharacterOptionListenToLfgChat] = "LFGChat", - [InputAction.ToggleCharacterOptionListenToRoleplayChat] = "RoleplayChat", - [InputAction.ToggleCharacterOptionDisplayNumberCharacterTitles] = "DisplayNumberCharacterTitles", - [InputAction.ToggleCharacterOptionMainPackPreferred] = "MainPackPreferred", - [InputAction.ToggleCharacterOptionLeadMissileTargets] = "LeadMissileTargets", - [InputAction.ToggleCharacterOptionUseFastMissiles] = "UseFastMissiles", - [InputAction.ToggleCharacterOptionFilterLanguage] = "FilterLanguage", - [InputAction.ToggleCharacterOptionConfirmVolatileRareUse] = "ConfirmVolatileRareUse", - [InputAction.ToggleCharacterOptionListenToSocietyChat] = "SocietyChat", - [InputAction.ToggleCharacterOptionShowHelm] = "ShowHelm", - [InputAction.ToggleCharacterOptionDisableDistanceFog] = "DisableDistanceFog", - [InputAction.ToggleCharacterOptionShowCloak] = "ShowCloak", - [InputAction.ToggleCharacterOptionSideBySideVitals] = "SideBySideVitals", - }; - - private const string FixedRetailMaps = """ - TargetedUsage - [ - SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ] - SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ] - ] - - SystemKeys - [ - AltEnter [ "" [ 0 DIK_RETURN ] 0x00000004 ] - AltTab [ "" [ 0 DIK_TAB ] 0x00000004 ] - AltF4 [ "" [ 0 DIK_F4 ] 0x00000004 ] - CtrlShiftEsc [ "" [ 0 DIK_ESCAPE ] 0x00000003 ] - ] - - MouseCommands - [ - PointerX [ "" [ 1 DIMOFS_X ] 0x00000000 Analog ] - PointerY [ "" [ 1 DIMOFS_Y ] 0x00000000 Analog ] - SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ] - SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ] - SelectMid [ "" [ 1 DIMOFS_BUTTON2 ] ] - SelectDblLeft [ "" [ 1 DIMOFS_BUTTON0 ] 0x00000000 MouseDblClick ] - SelectDblRight [ "" [ 1 DIMOFS_BUTTON1 ] 0x00000000 MouseDblClick ] - SelectDblMid [ "" [ 1 DIMOFS_BUTTON2 ] 0x00000000 MouseDblClick ] - ] - - ScrollableControls - [ - ScrollUp [ "" [ 1 DIMOFS_Z AxisPositive ] ] - ScrollDown [ "" [ 1 DIMOFS_Z AxisNegative ] ] - ScrollUp [ "" [ 0 DIK_UPARROW ] 0x00000002 ] - ScrollDown [ "" [ 0 DIK_DOWNARROW ] 0x00000002 ] - ] - - EditControls - [ - CursorCharLeft [ "" [ 0 DIK_LEFT ] ] - CursorCharRight [ "" [ 0 DIK_RIGHTARROW ] ] - CursorPreviousLine [ "" [ 0 DIK_UPARROW ] ] - CursorNextLine [ "" [ 0 DIK_DOWNARROW ] ] - CursorPreviousPage [ "" [ 0 DIK_PGUP ] ] - CursorNextPage [ "" [ 0 DIK_PGDN ] ] - CursorWordLeft [ "" [ 0 DIK_LEFT ] 0x00000002 ] - CursorWordRight [ "" [ 0 DIK_RIGHTARROW ] 0x00000002 ] - CursorStartOfLine [ "" [ 0 DIK_HOME ] ] - CursorStartOfDocument [ "" [ 0 DIK_HOME ] 0x00000002 ] - CursorEndOfLine [ "" [ 0 DIK_END ] ] - CursorEndOfDocument [ "" [ 0 DIK_END ] 0x00000002 ] - EscapeKey [ "" [ 0 DIK_ESCAPE ] ] - AcceptInput [ "" [ 0 DIK_RETURN ] ] - DeleteKey [ "" [ 0 DIK_DELETE ] ] - BackspaceKey [ "" [ 0 DIK_BACK ] ] - ] - - CopyAndPasteControls - [ - CopyText [ "" [ 0 DIK_C ] 0x00000002 ] - CopyText [ "" [ 0 DIK_INSERT ] 0x00000002 ] - CutText [ "" [ 0 DIK_X ] 0x00000002 ] - CutText [ "" [ 0 DIK_DELETE ] 0x00000001 ] - PasteText [ "" [ 0 DIK_V ] 0x00000002 ] - PasteText [ "" [ 0 DIK_INSERT ] 0x00000001 ] - ] - - DialogBoxes - [ - EscapeKey [ "" [ 0 DIK_ESCAPE ] ] - AcceptInput [ "" [ 0 DIK_RETURN ] ] - ] - - """; -} - -public enum RetailKeymapSaveStatus -{ - Saved, - Exists, - ReadOnly, - InvalidName, - Failed, -} - -public readonly record struct RetailKeymapSaveResult( - RetailKeymapSaveStatus Status, - string FileName, - string? Error = null); - -/// -/// Owns retail's active-profile preference and *.keymap directory. -/// The profile selector lives beside acdream's portable JSON mirror; profile -/// files live in retail's Documents/Asheron's Call folder. -/// -public sealed class RetailKeymapProfileStore -{ - public const string DefaultFileName = "acdream.keymap"; - - private readonly string _jsonPath; - private readonly string _directory; - private readonly string _selectorPath; - - public RetailKeymapProfileStore(string jsonPath, string? keymapDirectory = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(jsonPath); - _jsonPath = Path.GetFullPath(jsonPath); - string configDirectory = Path.GetDirectoryName(_jsonPath) - ?? Directory.GetCurrentDirectory(); - _selectorPath = Path.Combine(configDirectory, "active-keymap.txt"); - _directory = keymapDirectory ?? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), - "Asheron's Call"); - } - - public string DirectoryPath => _directory; - - public string CurrentFileName - { - get - { - try - { - if (File.Exists(_selectorPath)) - { - string selected = NormalizeFileName(File.ReadAllText(_selectorPath)); - if (selected.Length != 0) return selected; - } - } - catch (Exception failure) - { - Console.WriteLine($"keymap: active-profile preference could not be read: {failure.Message}"); - } - return DefaultFileName; - } - } - - public IReadOnlyList ListFiles() - { - try - { - if (!Directory.Exists(_directory)) return Array.Empty(); - return Directory.EnumerateFiles(_directory, "*.keymap", SearchOption.TopDirectoryOnly) - .Select(Path.GetFileName) - .Where(static name => !string.IsNullOrEmpty(name)) - .Cast() - .OrderBy(static name => name, StringComparer.OrdinalIgnoreCase) - .ToArray(); - } - catch (Exception failure) - { - Console.WriteLine($"keymap: profile list failed: {failure.Message}"); - return Array.Empty(); - } - } - - public bool TryLoad( - string fileName, - KeyBindings baseBindings, - out KeyBindings bindings, - out string? error) - { - bindings = baseBindings; - error = null; - string normalized = NormalizeFileName(fileName); - if (normalized.Length == 0) - { - error = "The keymap filename is invalid."; - return false; - } - - try - { - string text = File.ReadAllText(Path.Combine(_directory, normalized)); - bindings = RetailKeymapFile.Parse(text, baseBindings); - WriteSelector(normalized); - return true; - } - catch (Exception failure) - { - error = failure.Message; - return false; - } - } - - public RetailKeymapSaveResult Save( - string fileName, - KeyBindings bindings, - bool overwrite) - { - string normalized = NormalizeFileName(fileName); - if (normalized.Length == 0) - return new(RetailKeymapSaveStatus.InvalidName, string.Empty); - - string path = Path.Combine(_directory, normalized); - try - { - if (File.Exists(path)) - { - if (!overwrite) - return new(RetailKeymapSaveStatus.Exists, normalized); - if ((File.GetAttributes(path) & FileAttributes.ReadOnly) != 0) - return new(RetailKeymapSaveStatus.ReadOnly, normalized); - } - - Directory.CreateDirectory(_directory); - AtomicWrite(path, RetailKeymapFile.Write(bindings)); - WriteSelector(normalized); - return new(RetailKeymapSaveStatus.Saved, normalized); - } - catch (UnauthorizedAccessException failure) - { - return new(RetailKeymapSaveStatus.ReadOnly, normalized, failure.Message); - } - catch (Exception failure) - { - return new(RetailKeymapSaveStatus.Failed, normalized, failure.Message); - } - } - - public RetailKeymapSaveResult SaveActive(KeyBindings bindings) => - Save(CurrentFileName, bindings, overwrite: true); - - public static KeyBindings LoadActiveOrJson( - string jsonPath, - out string profileName) - { - KeyBindings fallback = KeyBindings.LoadOrDefault(jsonPath); - var store = new RetailKeymapProfileStore(jsonPath); - profileName = store.CurrentFileName; - string profilePath = Path.Combine(store.DirectoryPath, profileName); - if (!File.Exists(profilePath)) return fallback; - if (store.TryLoad(profileName, fallback, out KeyBindings loaded, out string? error)) - return loaded; - Console.WriteLine($"keymap: '{profileName}' could not be loaded; using JSON/defaults: {error}"); - return fallback; - } - - public static string NormalizeFileName(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return string.Empty; - string trimmed = value.Trim(); - if (!string.Equals(trimmed, Path.GetFileName(trimmed), StringComparison.Ordinal)) - return string.Empty; - if (trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) - return string.Empty; - return trimmed.EndsWith(".keymap", StringComparison.OrdinalIgnoreCase) - ? trimmed - : trimmed + ".keymap"; - } - - private void WriteSelector(string fileName) - { - string? directory = Path.GetDirectoryName(_selectorPath); - if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); - AtomicWrite(_selectorPath, fileName + Environment.NewLine); - } - - private static void AtomicWrite(string path, string content) - { - string temp = path + ".tmp-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - try - { - File.WriteAllText(temp, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - File.Move(temp, path, overwrite: true); - } - finally - { - if (File.Exists(temp)) File.Delete(temp); - } - } -} diff --git a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs index 0b6dd15f..b2161cad 100644 --- a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs +++ b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs @@ -61,6 +61,14 @@ internal sealed class PlayerInteractionMovementSink( Height = approach.TargetHeight, }; + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] BeginApproach guid=0x{approach.Target.ServerGuid:X8} " + + $"movementType={movement.Type} distanceToObject={parameters.DistanceToObject} " + + $"canCharge={parameters.CanCharge} target=0x{movement.ObjectId:X8}"); + } + // PerformMovement cancels at its head. Do it explicitly before the // intent is armed so cancellation of the preceding move cannot clear // the new request; the internal second call is then a retail no-op. diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index d70ace45..27ef7851 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -24,8 +24,6 @@ internal sealed class SelectionInteractionController private readonly IPlayerInteractionMovementSink _movement; private readonly PlayerApproachCompletionState _approachCompletions; private readonly Action? _toast; - private readonly Func? _splitStack; - private readonly Func> _fellowshipMembers; public SelectionInteractionController( SelectionState selection, @@ -34,9 +32,7 @@ internal sealed class SelectionInteractionController IRuntimeInteractionTransport transport, IPlayerInteractionMovementSink movement, Action? toast = null, - PlayerApproachCompletionState? approachCompletions = null, - Func? splitStack = null, - Func>? fellowshipMembers = null) + PlayerApproachCompletionState? approachCompletions = null) { _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _query = query ?? throw new ArgumentNullException(nameof(query)); @@ -47,96 +43,14 @@ internal sealed class SelectionInteractionController _toast = toast; _approachCompletions = approachCompletions ?? new PlayerApproachCompletionState(); - _splitStack = splitStack; - _fellowshipMembers = fellowshipMembers ?? (() => Array.Empty()); } public bool HandleInputAction(InputAction action) { switch (action) { - case InputAction.SelectionSelf: - SelectSelf(); - return true; - case InputAction.SelectionPlaceInInventory: - PlaceSelectionInBackpack(mainPack: false); - return true; - case InputAction.SelectionPlaceInMainPack: - PlaceSelectionInBackpack(mainPack: true); - return true; - case InputAction.SelectionSplitStack: - if (_selection.SelectedObjectId is { } stack) - _splitStack?.Invoke(stack); - return true; - case InputAction.SelectionClosestCompassItem: - SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Closest); - return true; - case InputAction.SelectionPreviousCompassItem: - SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Previous); - return true; - case InputAction.SelectionNextCompassItem: - SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Next); - return true; - case InputAction.SelectionClosestItem: - SelectRetailTarget( - RetailSelectionKind.Item, - RetailSelectionDirection.Closest, - excludeOwnedByPlayer: true); - return true; - case InputAction.SelectionPreviousItem: - SelectRetailTarget(RetailSelectionKind.Item, RetailSelectionDirection.Previous); - return true; - case InputAction.SelectionNextItem: - SelectRetailTarget(RetailSelectionKind.Item, RetailSelectionDirection.Next); - return true; case InputAction.SelectionClosestMonster: - SelectRetailTarget( - RetailSelectionKind.Monster, - RetailSelectionDirection.Closest, - showToast: true); - return true; - case InputAction.SelectionPreviousMonster: - SelectRetailTarget(RetailSelectionKind.Monster, RetailSelectionDirection.Previous); - return true; - case InputAction.SelectionNextMonster: - SelectRetailTarget(RetailSelectionKind.Monster, RetailSelectionDirection.Next); - return true; - case InputAction.SelectionLastAttacker: - if (_query.FindLastAttacker() is { } attacker) - _selection.Select(attacker, SelectionChangeSource.Keyboard); - return true; - case InputAction.SelectionClosestPlayer: - SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Closest); - return true; - case InputAction.SelectionPreviousPlayer: - SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Previous); - return true; - case InputAction.SelectionNextPlayer: - SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Next); - return true; - case InputAction.SelectionPreviousFellow: - SelectFellow(previous: true); - return true; - case InputAction.SelectionNextFellow: - SelectFellow(previous: false); - return true; - case InputAction.SelectionClosestUnopenedCorpse: - SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, RetailSelectionDirection.Closest); - return true; - case InputAction.SelectionNextUnopenedCorpse: - SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, RetailSelectionDirection.Next); - return true; - case InputAction.SelectionUseClosestUnopenedCorpse: - SelectAndUseCorpse(RetailSelectionDirection.Closest); - return true; - case InputAction.SelectionUseNextUnopenedCorpse: - SelectAndUseCorpse(RetailSelectionDirection.Next); - return true; - case InputAction.SelectionGiveToTarget: - GiveSelectionToPreviousTarget(); - return true; - case InputAction.SelectionDrop: - DropSelection(); + SelectClosestCombatTarget(showToast: true); return true; case InputAction.SelectionPreviousSelection: _selection.SelectPrevious(); @@ -173,109 +87,11 @@ internal sealed class SelectionInteractionController case InputAction.EscapeKey when _items.IsAnyTargetModeActive: _items.CancelTargetMode(); return true; - case InputAction.EscapeKey when _selection.SelectedObjectId is not null: - // ClientUISystem::OnAction @0x00564C8E: Escape willingly - // loses the current target before it reaches the Gameplay - // Options fallback at 0x00564CBF. - _selection.Clear(SelectionChangeSource.Keyboard); - return true; default: return false; } } - private void SelectSelf() - { - uint playerGuid = _query.PlayerGuid; - if (playerGuid == 0u) - return; - if (_items.OfferPrimaryClick(playerGuid) is not ItemPrimaryClickResult.NotActive) - return; - _selection.Select(playerGuid, SelectionChangeSource.Keyboard); - } - - private void PlaceSelectionInBackpack(bool mainPack) - { - if (_selection.SelectedObjectId is { } selected) - _items.PlaceWorldItemInBackpack(selected, mainPack); - } - - private void SelectRetailTarget( - RetailSelectionKind kind, - RetailSelectionDirection direction, - bool excludeOwnedByPlayer = false, - bool showToast = false) - { - uint? anchor = _selection.SelectedObjectId ?? _selection.PreviousObjectId; - uint? target = _query.FindSelectionTarget( - kind, - direction, - anchor, - excludeOwnedByPlayer); - if (target is { } guid) - { - _selection.Select(guid, SelectionChangeSource.Keyboard); - if (showToast) - _toast?.Invoke(_query.Describe(guid)); - } - } - - private void SelectAndUseCorpse(RetailSelectionDirection direction) - { - SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, direction); - if (_selection.SelectedObjectId is { } corpse) - EnqueueIdentityBound( - RuntimeQueuedInteractionKind.Use, - corpse, - requireLiveEntity: false); - } - - private void SelectFellow(bool previous) - { - uint[] fellows = _fellowshipMembers() - .Where(static guid => guid != 0u) - .Distinct() - .ToArray(); - if (fellows.Length == 0) - return; - - int current = _selection.SelectedObjectId is { } selected - ? Array.IndexOf(fellows, selected) - : -1; - int next = previous - ? (current > 0 ? current - 1 : fellows.Length - 1) - : (current >= 0 && current + 1 < fellows.Length ? current + 1 : 0); - _selection.Select(fellows[next], SelectionChangeSource.Keyboard); - } - - private void GiveSelectionToPreviousTarget() - { - if (_selection.SelectedObjectId is not { } selected - || _selection.PreviousObjectId is not { } target - || selected == target - || !_query.IsCreature(target)) - { - _toast?.Invoke( - "You must select a creature or a character to give that to.\n"); - return; - } - - if (_items.PlaceSelectedIn3D(selected, target)) - _selection.Select(target, SelectionChangeSource.Keyboard); - } - - private void DropSelection() - { - if (_selection.SelectedObjectId is not { } selected) - return; - if (!_items.IsOwnedByPlayer(selected)) - { - _toast?.Invoke("You must pick that up first"); - return; - } - _items.PlaceSelectedIn3D(selected, targetGuid: 0u); - } - public uint? PickAtCursor(bool includeSelf) => _query.PickAtCursor(includeSelf); @@ -365,6 +181,11 @@ internal sealed class SelectionInteractionController // ships with it. if (useImmediately && !_query.IsWieldedByPlayer(guid)) { + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] route=dblclick-world PickAndStoreSelection guid=0x{guid:X8} enqueue=Activate"); + } EnqueueIdentityBound( RuntimeQueuedInteractionKind.Activate, guid, @@ -400,6 +221,11 @@ internal sealed class SelectionInteractionController _toast?.Invoke("Nothing selected"); return; } + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] route=keyboard-use UseCurrentSelection guid=0x{selected:X8} enqueue=Use"); + } EnqueueIdentityBound( RuntimeQueuedInteractionKind.Use, selected, @@ -466,10 +292,22 @@ internal sealed class SelectionInteractionController bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid); bool useable = ownedByPlayer || _query.IsUseable(serverGuid); + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] RequestUse entry guid=0x{serverGuid:X8} ownedByPlayer={ownedByPlayer} useable={useable}"); + } + if (useable && _query.TryGetApproach(serverGuid, out InteractionApproach approach) && !approach.IsCloseRange) { + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=approach-armed useRadius={approach.UseRadius} isCloseRange={approach.IsCloseRange}"); + } + // Genuinely out of range (a real walk, not just a turn) — // mirror SendPickup's arrival-gated shape: arm the transaction // on the approach token BEFORE the movement starts (so a @@ -489,7 +327,17 @@ internal sealed class SelectionInteractionController token.ControllerLifetime, token.ApproachGeneration), out _); + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] TryArmPostArrivalUse guid=0x{serverGuid:X8} armed={armed} approachToken=({token.ControllerLifetime},{token.ApproachGeneration})"); + } }); + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] BeginApproach-result guid=0x{serverGuid:X8} started={started} armed={armed} stopDistance={approach.UseRadius} target=0x{approach.Target.ServerGuid:X8}"); + } if (!started || !armed) { // Release whatever got captured (or the caller's own @@ -512,6 +360,11 @@ internal sealed class SelectionInteractionController // — keep retail's immediate send; ACE's own "already within use // distance" branch (Player_Move.cs:65-87) calls back synchronously, // so there is no arrival gap to race here. + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=immediate-dispatch ownedByPlayer={ownedByPlayer} useable={useable}"); + } RuntimeInteractionDispatchResult result = _transactions.TryDispatchUse( serverGuid, @@ -520,6 +373,11 @@ internal sealed class SelectionInteractionController reservation, _transport, out uint sequence); + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] RequestUse guid=0x{serverGuid:X8} TryDispatchUse verdict={result} seq={sequence}"); + } if (result == RuntimeInteractionDispatchResult.NotInWorld) _toast?.Invoke("Not in world"); if (result == RuntimeInteractionDispatchResult.Dispatched) @@ -751,6 +609,22 @@ internal sealed class SelectionInteractionController RuntimePendingUse pending, bool accepted) { + if (VendorDiagnostics.DumpVendorEnabled) + { + // Diagnostic-only re-query — TryGetApproach is a pure read with + // no side effects, so an extra call here (gated off in + // production) cannot change RequestUse's own dispatch outcome. + string distanceText = "n/a"; + if (_query.TryGetApproach(pending.ServerGuid, out InteractionApproach diagApproach)) + { + float dx = diagApproach.Target.Entity.Position.X - diagApproach.Player.Position.X; + float dy = diagApproach.Target.Entity.Position.Y - diagApproach.Player.Position.Y; + distanceText = MathF.Sqrt(dx * dx + dy * dy).ToString("F2"); + } + Console.WriteLine( + $"[vendor-diag] HandleUseApproachCompletion guid=0x{pending.ServerGuid:X8} accepted={accepted} playerToTargetDist={distanceText}"); + } + if (!accepted) { pending.Reservation?.CancelBeforeDispatch(); diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index 485ff89f..9acbd662 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -6,9 +6,7 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Physics; -using AcDream.Core.Properties; using AcDream.Core.Selection; -using AcDream.Core.Ui; using AcDream.Core.World; namespace AcDream.App.Interaction; @@ -27,22 +25,6 @@ internal readonly record struct WorldInteractionTarget( internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared); -internal enum RetailSelectionKind -{ - Item, - CompassItem, - Monster, - Player, - UnopenedCorpse, -} - -internal enum RetailSelectionDirection -{ - Closest, - Previous, - Next, -} - internal readonly record struct InteractionApproach( WorldInteractionTarget Target, PlayerInteractionPose Player, @@ -54,7 +36,6 @@ internal readonly record struct InteractionApproach( internal interface IWorldSelectionQuery { - uint PlayerGuid => 0u; uint? PickAtCursor(bool includeSelf); uint? PickAt(float mouseX, float mouseY, bool includeSelf); void BeginLightingPulse(uint serverGuid); @@ -65,16 +46,6 @@ internal interface IWorldSelectionQuery bool IsHostileMonster(uint serverGuid); bool IsAttackableTarget(uint serverGuid); ClosestCombatTarget? FindClosestHostileMonster(); - uint? FindSelectionTarget( - RetailSelectionKind kind, - RetailSelectionDirection direction, - uint? anchor, - bool excludeOwnedByPlayer = false) => - kind == RetailSelectionKind.Monster - && direction == RetailSelectionDirection.Closest - ? FindClosestHostileMonster()?.ServerGuid - : null; - uint? FindLastAttacker() => null; bool IsUseable(uint serverGuid); bool IsPickupable(uint serverGuid); bool IsWieldedByPlayer(uint serverGuid); @@ -140,9 +111,6 @@ internal sealed class WorldSelectionQuery private readonly Func _setupCylinder; private readonly Func _selectionSphere; private readonly Func _childRootPose; - private readonly Func _hasOpenedCorpse; - private readonly Func _combatMode; - private readonly Func _isFellow; public WorldSelectionQuery( LiveEntityRuntime liveEntities, @@ -154,10 +122,7 @@ internal sealed class WorldSelectionQuery Func playerPose, Func setupCylinder, Func selectionSphere, - Func childRootPose, - Func? hasOpenedCorpse = null, - Func? combatMode = null, - Func? isFellow = null) + Func childRootPose) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _objects = objects ?? throw new ArgumentNullException(nameof(objects)); @@ -169,13 +134,8 @@ internal sealed class WorldSelectionQuery _setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder)); _selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere)); _childRootPose = childRootPose ?? throw new ArgumentNullException(nameof(childRootPose)); - _hasOpenedCorpse = hasOpenedCorpse ?? (_ => false); - _combatMode = combatMode ?? (() => CombatMode.NonCombat); - _isFellow = isFellow ?? (_ => false); } - public uint PlayerGuid => _playerGuid(); - public uint? PickAtCursor(bool includeSelf) { Vector2 cursor = _cursor(); @@ -333,183 +293,6 @@ internal sealed class WorldSelectionQuery return best; } - /// - /// Port of retail CPlayerSystem::SelectNext @ 0x0055F9A0. The - /// ordering scalar is the retail player-space horizontal distance plus - /// 1.2 * abs(z); the object id breaks exact-distance ties through - /// CPlayerSystem::Farther @ 0x0055D830. Previous/next wrap exactly - /// as the paired calls in CPlayerSystem::OnAction @ 0x00561890. - /// - public uint? FindSelectionTarget( - RetailSelectionKind kind, - RetailSelectionDirection direction, - uint? anchor, - bool excludeOwnedByPlayer = false) - { - uint playerGuid = _playerGuid(); - if (!_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player)) - return null; - - float radarRadius = IsOutdoorCell(player.VisibilityCellId) - ? RetailRadar.OutdoorRangeMeters - : RetailRadar.IndoorRangeMeters; - var candidates = new List<(uint Guid, float Order)>(); - foreach (LiveEntityRecord record in _liveEntities.VisibleRecords) - { - uint guid = record.ServerGuid; - if (guid == 0u - || guid == playerGuid - || record.WorldEntity is not { } entity - || _objects.Get(guid) is not { } obj - || (excludeOwnedByPlayer - && _objects.IsOwnedByObject(guid, playerGuid))) - { - continue; - } - - float order = SelectionOrder(player, entity); - if (order > radarRadius - || !MatchesSelectionKind(kind, guid, obj, record.FinalPhysicsState)) - continue; - candidates.Add((guid, order)); - } - - if (candidates.Count == 0) - return null; - candidates.Sort(static (left, right) => - { - int distance = left.Order.CompareTo(right.Order); - return distance != 0 ? distance : left.Guid.CompareTo(right.Guid); - }); - - if (direction == RetailSelectionDirection.Closest) - return candidates[0].Guid; - - (float Order, uint Guid)? anchorKey = null; - if (anchor is { } anchorGuid - && _liveEntities.TryGetWorldEntity(anchorGuid, out WorldEntity anchorEntity)) - { - anchorKey = (SelectionOrder(player, anchorEntity), anchorGuid); - } - - if (anchorKey is null) - { - return direction == RetailSelectionDirection.Previous - ? candidates[^1].Guid - : candidates[0].Guid; - } - - if (direction == RetailSelectionDirection.Next) - { - foreach ((uint guid, float order) in candidates) - { - if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) > 0) - return guid; - } - return candidates[0].Guid; - } - - for (int i = candidates.Count - 1; i >= 0; i--) - { - (uint guid, float order) = candidates[i]; - if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) < 0) - return guid; - } - return candidates[^1].Guid; - } - - public uint? FindLastAttacker() - { - uint playerGuid = _playerGuid(); - uint attacker = 0u; - if (_objects.Get(playerGuid) is not { } playerObject - || !playerObject.Properties.InstanceIds.TryGetValue( - (uint)PropertyInstanceId.CurrentAttacker, - out attacker) - || attacker == 0u - || !_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player) - || !_liveEntities.TryGetWorldEntity(attacker, out WorldEntity target)) - { - return null; - } - - float radarRadius = IsOutdoorCell(player.VisibilityCellId) - ? RetailRadar.OutdoorRangeMeters - : RetailRadar.IndoorRangeMeters; - return SelectionOrder(player, target) <= radarRadius ? attacker : null; - } - - private bool MatchesSelectionKind( - RetailSelectionKind kind, - uint guid, - ClientObject obj, - PhysicsStateFlags physicsState) - { - bool showableOnRadar = obj.RadarBehavior is { } behavior - && RetailRadar.IsShowable((RadarBehavior)behavior, hasPhysicsObject: true); - PublicWeenieFlags flags = (PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u); - bool isFellow = _isFellow(guid); - bool isCombatCompass = _combatMode() is CombatMode.Melee or CombatMode.Missile; - bool isSpecialCompassObject = (flags - & (PublicWeenieFlags.Lifestone - | PublicWeenieFlags.Portal - | PublicWeenieFlags.Bindstone)) != 0; - - // The common tail of CPlayerSystem::SelectNext rejects every object - // currently inside a container, every cloaked physics object, and a - // PWD carrying the reserved sign bit, independent of selection kind. - if (obj.ContainerId != 0u - || (physicsState & PhysicsStateFlags.Cloaked) != 0 - || (((uint)flags & 0x8000_0000u) != 0)) - return false; - - return kind switch - { - RetailSelectionKind.Item => - obj.RadarBehavior is null or 0 - || isSpecialCompassObject, - RetailSelectionKind.CompassItem => - (isSpecialCompassObject || showableOnRadar) - && (!isCombatCompass - || (IsAttackableTarget(guid) - && !isFellow - && (flags & PublicWeenieFlags.Vendor) == 0 - && (physicsState & PhysicsStateFlags.ReportAsEnvironment) == 0)), - RetailSelectionKind.Monster => - showableOnRadar - && IsAttackableTarget(guid) - && !isFellow - && (flags & PublicWeenieFlags.Vendor) == 0, - RetailSelectionKind.Player => - showableOnRadar && (flags & PublicWeenieFlags.Player) != 0, - RetailSelectionKind.UnopenedCorpse => - (flags & PublicWeenieFlags.Corpse) != 0 - && !_hasOpenedCorpse(guid), - _ => false, - }; - } - - private static float SelectionOrder(WorldEntity player, WorldEntity target) - { - Vector3 delta = target.Position - player.Position; - Vector3 local = Vector3.Transform(delta, Quaternion.Inverse(player.Rotation)); - return MathF.Sqrt(local.X * local.X + local.Y * local.Y) - + MathF.Abs(local.Z) * 1.2f; - } - - private static int CompareSelectionKey( - float leftOrder, - uint leftGuid, - float rightOrder, - uint rightGuid) - { - int order = leftOrder.CompareTo(rightOrder); - return order != 0 ? order : leftGuid.CompareTo(rightGuid); - } - - private static bool IsOutdoorCell(uint? cellId) - => cellId is null || (cellId.Value & 0xFFFFu) < 0x100u; - /// /// #298 follow-up: retail ClientCombatSystem::UpdateTargetTracking /// @ 0x0056A950 (pc:375691-375696) gates CameraSet::TrackTarget @@ -788,6 +571,12 @@ internal sealed class WorldSelectionQuery bool haveSpawn = _liveEntities.TryGetSnapshot(serverGuid, out var spawn); bool fromWire = haveSpawn && spawn.UseRadius is > 0f; float radius = fromWire ? spawn.UseRadius!.Value : DefaultUseRadius; + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] GetUseRadius guid=0x{serverGuid:X8} radius={radius} " + + $"source={(fromWire ? "wire" : "fallback-0.6")}"); + } return radius; } } diff --git a/src/AcDream.App/Net/DatChatPoseCatalog.cs b/src/AcDream.App/Net/DatChatPoseCatalog.cs deleted file mode 100644 index 39649058..00000000 --- a/src/AcDream.App/Net/DatChatPoseCatalog.cs +++ /dev/null @@ -1,81 +0,0 @@ -using AcDream.Runtime.Chat; -using AcDream.Content; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatMotionCommand = DatReaderWriter.Enums.MotionCommand; - -namespace AcDream.App.Net; - -/// -/// Immutable projection of retail's portal-DAT ChatPoseTable (0x0E000007). -/// Command lookup is case-insensitive, matching -/// ChatPoseTable::InqChatPoseCommand @ 0x00570AD0. -/// -internal sealed class DatChatPoseCatalog -{ - private const uint ChatPoseTableId = 0x0E000007u; - private readonly IReadOnlyDictionary _poses; - - private DatChatPoseCatalog( - IReadOnlyDictionary poses) => - _poses = poses; - - public static DatChatPoseCatalog Load(IDatReaderWriter dats, object datLock) - { - ArgumentNullException.ThrowIfNull(dats); - ArgumentNullException.ThrowIfNull(datLock); - lock (datLock) - { - ChatPoseTable? table = dats.Get(ChatPoseTableId); - if (table is null) - return new DatChatPoseCatalog( - new Dictionary( - StringComparer.OrdinalIgnoreCase)); - - var emotes = new Dictionary( - StringComparer.OrdinalIgnoreCase); - foreach (var pair in table.ChatEmotes) - { - emotes[pair.Key.Value] = ( - pair.Value.MyEmote.Value, - pair.Value.OtherEmote.Value); - } - - var poses = new Dictionary( - StringComparer.OrdinalIgnoreCase); - foreach (var pair in table.ChatPoses) - { - string command = pair.Key.Value; - string motionName = pair.Value.Value; - if (string.IsNullOrEmpty(command) - || !Enum.TryParse( - motionName, - ignoreCase: true, - out DatMotionCommand motion)) - { - continue; - } - emotes.TryGetValue(motionName, out var text); - poses[command] = new RetailChatPose( - (uint)motion, - text.Self ?? string.Empty, - text.Others ?? string.Empty); - } - return new DatChatPoseCatalog(poses); - } - } - - public RetailChatPose? Resolve(string command, bool male) - { - if (!_poses.TryGetValue(command, out RetailChatPose pose)) - return null; - string possessive = male ? "his" : "her"; - return pose with - { - OthersText = pose.OthersText.Replace( - "%p", - possessive, - StringComparison.Ordinal), - }; - } -} diff --git a/src/AcDream.App/Net/LiveSessionAppSource.cs b/src/AcDream.App/Net/LiveSessionAppSource.cs index d8e27646..f10e4e9e 100644 --- a/src/AcDream.App/Net/LiveSessionAppSource.cs +++ b/src/AcDream.App/Net/LiveSessionAppSource.cs @@ -36,17 +36,11 @@ internal sealed class LiveSessionAppSource /// retained UI may keep this surface, while the displaced route itself becomes /// inert before inbound subscriptions detach. /// -internal sealed class LiveSessionCommandSurface : IPluginCommandBus +internal sealed class LiveSessionCommandSurface : ICommandBus { private readonly object _gate = new(); - private readonly Func? _tryHandlePluginCommand; private LiveSessionCommandRouter? _active; - public LiveSessionCommandSurface(Func? tryHandlePluginCommand = null) - { - _tryHandlePluginCommand = tryHandlePluginCommand; - } - public ILiveSessionCommandRouting Attach(LiveSessionCommandRouter route) { ArgumentNullException.ThrowIfNull(route); @@ -71,9 +65,6 @@ internal sealed class LiveSessionCommandSurface : IPluginCommandBus route?.Publish(command); } - public bool TryHandlePluginCommand(string commandLine) => - _tryHandlePluginCommand?.Invoke(commandLine) == true; - private void Release(LiveSessionCommandRouter expected) { expected.Dispose(); diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 2a72e03a..9b7aa83a 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -55,10 +55,6 @@ internal sealed record LiveSessionCommandBindings( // research §2.3-§2.7. No-ops when the batched module is clean, matching // retail's CPlayerModule::SaveToServer(force: 0). Action SaveCharacterOptions, - // Campaign CT slice CT2 (2026-08-24): TitleSet (0x002C) — sends only, no - // local mutation (RuntimeCharacterState.Titles updates from the - // server's own echo). - Action SendSetTitle, // Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send // wrappers, the App-bus twin of DirectGameRuntimeCommandAdapter's // direct session.SendXxx calls. @@ -74,10 +70,7 @@ internal sealed record LiveSessionCommandBindings( Action SendAllegianceKick, Action SendAllegianceInfoRequest, Action SendAllegianceUpdateRequest, - Action? Log = null, - Func? ResolvePose = null, - Action? ExecuteMotion = null, - Action? SendSoulEmote = null); + Action? Log = null); internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry); internal readonly record struct RemoveShortcutRuntimeCmd(uint Index); @@ -102,7 +95,6 @@ internal readonly record struct SetSingleCharacterOptionRuntimeCmd( uint OptionId, bool Value); internal readonly record struct SaveCharacterOptionsRuntimeCmd; -internal readonly record struct SetTitleRuntimeCmd(uint TitleId); internal readonly record struct AddFriendRuntimeCmd(string Name); internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId); @@ -188,10 +180,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting bindings.SendTell, bindings.SendChannel, bindings.SendTurbineChat, - bindings.Log, - bindings.ResolvePose, - bindings.ExecuteMotion, - bindings.SendSoulEmote)); + bindings.Log)); // Campaign CH slice CH4 (2026-08-09): the 22 unregistered // ChannelSystem::GetChannelID fallback tags — bypasses // ChatChannelKind/ChannelResolver entirely and sends the raw @@ -239,8 +228,6 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting command.Value))); commands.Register( _ => SendIfActive(bindings.SaveCharacterOptions)); - commands.Register( - command => SendIfActive(() => bindings.SendSetTitle(command.TitleId))); commands.Register( command => SendIfActive(() => bindings.AddFriend(command.Name))); commands.Register( diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index ec26a68b..fe3a19c0 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -67,7 +67,6 @@ internal sealed record LiveSessionInteractionRuntime( internal sealed record LiveSessionWorldRuntime( IDatReaderWriter Dats, - object DatLock, // Logout-audio round (2026-08-17): null only when audio is disabled // (ACDREAM_NO_AUDIO / init failure) — the reset step and entered-world // resume both no-op then. @@ -115,7 +114,6 @@ internal sealed class LiveSessionRuntimeFactory private readonly IReadOnlyList _loginCommands; private readonly TimeSpan _loginCommandDelay; private readonly TimeProvider _timeProvider; - private readonly DatChatPoseCatalog _chatPoses; /// /// Where a bare @log filename lands. See @@ -164,7 +162,6 @@ internal sealed class LiveSessionRuntimeFactory _loginCommands = loginCommands is null ? [] : [.. loginCommands]; _loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs); _timeProvider = timeProvider ?? TimeProvider.System; - _chatPoses = DatChatPoseCatalog.Load(_world.Dats, _world.DatLock); // C3c-F1: stat recomputes route through the Runtime movement owner's // typed application seam; App keeps zero direct controller mutations. _movementStats = new LiveMovementStatsApplier( @@ -220,15 +217,6 @@ internal sealed class LiveSessionRuntimeFactory RestoreLayout: () => { _ui.RetailUi?.RestoreLayout(); - // The retained inventory controller exists before the - // character object graph is complete. Rebuild its open - // container once EnteredWorld makes that graph - // authoritative, otherwise the already-open main pack can - // keep the empty construction-time cells until the user - // switches packs. Redress the private doll at the same - // character-complete edge. - _ui.RetailUi?.InventoryPanelController?.Populate(); - _ui.Paperdoll?.MarkDirty(); // MUST-FIX 3 re-fix (FA4 re-review REOPEN): re-declare a // still-open Fellowship page's 0x00A6 now we are in world — // RestoreLayout is the post-world UI-restore moment, and @@ -455,7 +443,6 @@ internal sealed class LiveSessionRuntimeFactory OnUseDone: error => { _domain.Inventory.ExternalContainers.ApplyUseDone(error); - _domain.Actions.SpellCast.CompleteUse(error); _domain.Actions.Transactions.CompleteUse(error); }, _domain.Inventory.ItemMana, @@ -783,8 +770,6 @@ internal sealed class LiveSessionRuntimeFactory CharacterState: _domain.Character, SendSingleCharacterOption: SendSingleCharacterOption, SaveCharacterOptions: SaveCharacterOptionsIfDirty, - // Campaign CT slice CT2 (2026-08-24). - SendSetTitle: session.SendSetTitle, // Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send // wrappers, matching the WorldSession.SendXxx methods FA2 added. SendFellowshipCreate: session.SendFellowshipCreate, @@ -799,14 +784,7 @@ internal sealed class LiveSessionRuntimeFactory SendAllegianceKick: session.SendAllegianceKick, SendAllegianceInfoRequest: session.SendAllegianceInfoRequest, SendAllegianceUpdateRequest: session.SendAllegianceUpdateRequest, - Log: _log, - ResolvePose: command => _chatPoses.Resolve( - command, - male: _domain.EntityObjects.Objects - .Get(_player.Identity.ServerGuid)? - .Properties.GetInt(0x71u) == 1), - ExecuteMotion: motion => _player.Controller.ExecuteMotion(motion), - SendSoulEmote: session.SendSoulEmote); + Log: _log); } private static double ClientTimerNow() => diff --git a/src/AcDream.App/Net/RetailSkillFormula.cs b/src/AcDream.App/Net/RetailSkillFormula.cs index 18aab4d5..70dc47ef 100644 --- a/src/AcDream.App/Net/RetailSkillFormula.cs +++ b/src/AcDream.App/Net/RetailSkillFormula.cs @@ -186,19 +186,12 @@ internal static class RetailSkillFormula /// /// Retail SkillInfoRegion::GetTooltip @ 0x004f1fe0, called once /// from SkillInfoRegion::SkillInfoRegion @ 0x004f2140's - /// UIElement::SetTooltip at 0x004f222f. Composition is - /// formula + "\n" + description — CORRECTED 2026-08-24 at the - /// CA5 gate: the original Batch-B reading ("\n" + formula, no - /// separator) had the operator+ operand order backwards - /// (PVar3 = operator+(&local_c, ...)local_c, the - /// InqSkillFormula output, is the LEFT operand; the "\n" literal is the - /// right), and the owner's retail-client render confirms: formula on - /// its own first line, description below (wrapping to further lines - /// when the DAT text is long). A formula-less skill (Salvaging) shows - /// the bare description with no leading break, matching retail's - /// failed-InqSkillFormula branch, which appends the description - /// to the still-empty output. - /// SkillSystem::InqSkillDescription @ 0x005c8770 reads + /// UIElement::SetTooltip at 0x004f222f. Composition is exactly + /// "\n" + formula + description — retail concatenates the + /// description directly onto the formula line with NO separator between + /// them (ported verbatim, not "fixed": append_n_chars runs + /// immediately after the formula assignment with no intervening + /// literal). SkillSystem::InqSkillDescription @ 0x005c8770 reads /// SkillBase._description — the same DAT field /// already /// exposes, so no hand-transcription was needed for the ~30+ skill @@ -211,7 +204,7 @@ internal static class RetailSkillFormula string? formula = FormatFormula(skillBase.Formula); string description = skillBase.Description.Value ?? string.Empty; - string tooltip = (formula is null ? string.Empty : formula + "\n") + description; + string tooltip = (formula is null ? string.Empty : "\n" + formula) + description; return tooltip.Length == 0 ? null : tooltip; } } diff --git a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs index 34506cde..5a7f463d 100644 --- a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs +++ b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs @@ -229,6 +229,14 @@ internal sealed class LiveEntityMotionRuntimeController // CObjectMaint's object table and must still resolve here. if (liveEntities.IsHidden(id)) { + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + Console.WriteLine( + $"[autowalk-host-miss] object=0x{id:X8} " + + $"materialized={_liveEntities.ContainsWorldEntity(id)} " + + $"registered={liveEntities.TryGetPhysicsHost(id, out _)} " + + $"hidden={liveEntities.IsHidden(id)}"); + } return null; } if (liveEntities.TryGetPhysicsHost(id, out var existing)) @@ -486,6 +494,26 @@ internal sealed class LiveEntityMotionRuntimeController } } movement.PerformMovement(ms); + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + string target = turnPath.TargetGuid is { } targetGuid + ? $"0x{targetGuid:X8}" : "null"; + bool targetVisible = turnPath.TargetGuid is { } visibleGuid + && _liveEntities.TryGetInteractionEligibleEntity( + visibleGuid, + out _); + bool targetHost = turnPath.TargetGuid is { } hostGuid + && _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true; + var moveTo = movement.MoveTo; + Console.WriteLine( + $"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} " + + $"routed={ms.Type} target={target} visible={targetVisible} " + + $"host={targetHost} stop={mp.StopCompletelyFlag} " + + $"initialized={moveTo?.Initialized ?? false} " + + $"nodes={moveTo?.PendingActions.Count() ?? 0} " + + $"command=0x{moveTo?.CurrentCommand ?? 0u:X8} " + + $"pendingMotions={movement.Minterp.MotionsPending()}"); + } return true; } diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index e7722f2c..040b11f1 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -619,6 +619,28 @@ internal sealed class LiveEntityNetworkUpdateController // the exact swing and carries it in Commands[]. if (update.Guid == _playerServerGuid) { + // B.6 slice 1 (2026-05-14): trace inbound motion for the + // local player. One line per inbound UM, gated on + // ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5). + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null"; + string pathStr = update.MotionState.MoveToPath is { } p + ? $"path=cell=0x{p.OriginCellId:X8},xyz=({p.OriginX:F2},{p.OriginY:F2},{p.OriginZ:F2}),minDist={p.MinDistance:F2},objDist={p.DistanceToObject:F2}" + : "path=null"; + string spd = update.MotionState.ForwardSpeed is { } fs + ? $"fwdSpd={fs:F2}" + : "fwdSpd=null"; + string mtsSpd = update.MotionState.MoveToSpeed is { } ms + ? $"mtSpd={ms:F2}" + : "mtSpd=null"; + string mtsRun = update.MotionState.MoveToRunRate is { } mr + ? $"mtRun={mr:F2}" + : "mtRun=null"; + Console.WriteLine(System.FormattableString.Invariant( + $"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}")); + } + // R4-V5: retail unpack_movement dispatch for the local // player — the SAME shape the remote branch uses below. // Head (@300566): interrupt + unstick fire for EVERY @@ -678,6 +700,11 @@ internal sealed class LiveEntityNetworkUpdateController } if (localDispatch.RoutedMoveTo) { + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo?.MovementTypeState}")); + } return; } if (!localDispatch.AppliedInterpretedState) @@ -2101,6 +2128,21 @@ internal sealed class LiveEntityNetworkUpdateController if (update.Guid == _playerServerGuid) _authorityGate.ObserveAcceptedLocalPosition(update.Position.LandblockId); + // B.6 slice 1 (2026-05-14): trace inbound UpdatePosition cadence for + // the local player. Combined with [autowalk-mt] this answers + // whether ACE's broadcast frequency during a server-initiated + // auto-walk is dense enough to drive smooth visible motion (the + // Option C viability check from the design spec). Gated on + // ACDREAM_PROBE_AUTOWALK=1; skips remote entities. + if (update.Guid == _playerServerGuid + && AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + string velStr = update.Velocity is { } v + ? $"vel=({v.X:F2},{v.Y:F2},{v.Z:F2})" + : "vel=null"; + Console.WriteLine(System.FormattableString.Invariant( + $"[autowalk-up] cell=0x{p.LandblockId:X8} pos=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2}) world=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2}) {velStr} grounded={update.IsGrounded}")); + } var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition ? entity.Rotation : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); @@ -2643,6 +2685,14 @@ internal sealed class LiveEntityNetworkUpdateController // first UP after unstick (bounded by the 1 s sticky lease). bool snapSuppressedByStick = !IsPlayerGuid(update.Guid) && (rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u; + if (snapSuppressedByStick + && AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled) + { + float snapDist = System.Numerics.Vector3.Distance( + worldPos, rmState.Body.Position); + Console.WriteLine(FormattableString.Invariant( + $"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})")); + } // C4 routes 4a + 4b-2 + 4b-3 collapse: the complete near/far/ // teleport/leftover decision — including the dissolved LANDING @@ -2746,6 +2796,43 @@ internal sealed class LiveEntityNetworkUpdateController _motionRuntime.EnsureRemoteMotionBindings( rmState, aeForLand, update.Guid); } + + // Bug A investigation (2026-08-04, docs/ISSUES.md #32): + // the packet-side half of the landing capture, now fired + // for both guids (diagnostic-only — TEMPORARY, strip with + // the probe family; not behaviour). + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled) + { + bool gravitySetForProbe = rmState.Body.HasGravity; + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLanding( + site: "controller", + guid: update.Guid, + airborneBefore: true, + gravitySet: gravitySetForProbe, + contact: rmState.Body.InContact, + onWalkable: rmState.Body.OnWalkable, + hasDefaultSink: rmState.Motion.DefaultSink is not null, + resolveIsOnGround: null, + sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0, + sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0); + if (!gravitySetForProbe) + { + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingGateNoOp( + "controller", update.Guid); + } + // Zero the sink-dispatch latches before reading them + // back — nothing at THIS site dispatches (the arming + // call lives only next to the per-tick HitGround). + AcDream.Core.Physics.PhysicsDiagnostics + .BeginRemoteLandingDispatchCapture(); + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingAfter( + site: "controller", + guid: update.Guid, + hitGroundInvoked: false, + sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0, + sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0, + forwardCommand: rmState.Motion.InterpretedState.ForwardCommand); + } } } diff --git a/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs b/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs index 0b195c56..15810643 100644 --- a/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs +++ b/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs @@ -151,13 +151,6 @@ internal static class GraphicalWindowBackendConfigurator _ => throw new ArgumentOutOfRangeException( nameof(requested)), }); - // #451: InitHint proves the packaged glfw3.dll is loaded but runs - // before glfwInit creates any window or begins polling. This is - // the one safe point to narrow GLFW's GetActiveWindow import so a - // temporarily joined Win32 input queue cannot hand it another - // acdream process's private GLFWwindow pointer. - if (platform.OperatingSystem == GraphicalHostOperatingSystem.Windows) - Win32GlfwActiveWindowGuard.Install(); _glfw = glfw; _configuredProtocol = requested; } diff --git a/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs b/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs deleted file mode 100644 index 1caf7415..00000000 --- a/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -namespace AcDream.App.Platform; - -/// -/// Prevents GLFW's Win32 modifier-key repair pass from accepting a window -/// owned by another process. -/// -/// -/// -/// GLFW 3.4's _glfwPollEventsWin32 calls GetActiveWindow, then -/// reads that HWND's process-global GLFW property and dereferences the -/// result as a local _GLFWwindow*. Normally GetActiveWindow can -/// only return a window from this thread's input queue. Windows automation, -/// accessibility software, and some multi-box window managers temporarily -/// join input queues, however, allowing it to return another acdream process's -/// window. Every GLFW process uses the same property name, so GetPropW -/// then succeeds but returns a pointer meaningful only in the other process. -/// The next modifier-key read is an access violation (#452). -/// -/// -/// Patch only GLFW's import-address-table entry for GetActiveWindow. -/// The replacement returns the real active HWND when it belongs to this -/// process and zero otherwise. Zero is GLFW's existing, intentional -/// "nothing to repair" path. No process-global Win32 hook is installed and -/// no other module's User32 calls are changed. -/// -/// -internal static unsafe class Win32GlfwActiveWindowGuard -{ - private const string GlfwModuleName = "glfw3.dll"; - private const string User32ModuleName = "USER32.dll"; - private const string GetActiveWindowImport = "GetActiveWindow"; - private const uint PageReadWrite = 0x04; - private const ushort DosSignature = 0x5A4D; - private const uint PeSignature = 0x00004550; - private const ushort Pe32Magic = 0x010B; - private const ushort Pe32PlusMagic = 0x020B; - private const int ImportDescriptorSize = 20; - - private static readonly uint CurrentProcessId = - checked((uint)Environment.ProcessId); - private static int _installState; - - internal static bool IsInstalled => Volatile.Read(ref _installState) == 1; - - internal static void Install() - { - if (!OperatingSystem.IsWindows() - || Interlocked.CompareExchange(ref _installState, 2, 0) != 0) - { - return; - } - - try - { - nint module = GetModuleHandleW(GlfwModuleName); - if (module == 0 - || !TryFindImportSlot( - module, - User32ModuleName, - GetActiveWindowImport, - out nint slot)) - { - Volatile.Write(ref _installState, -1); - Console.Error.WriteLine( - "windowing: could not install the GLFW foreign-active-window guard"); - return; - } - - nint replacement = (nint)(delegate* unmanaged[Stdcall]) - &GetCurrentProcessActiveWindow; - if (!VirtualProtect( - slot, - checked((nuint)IntPtr.Size), - PageReadWrite, - out uint oldProtection)) - { - Volatile.Write(ref _installState, -1); - Console.Error.WriteLine( - "windowing: GLFW active-window import was not writable"); - return; - } - - try - { - *(nint*)slot = replacement; - } - finally - { - _ = VirtualProtect( - slot, - checked((nuint)IntPtr.Size), - oldProtection, - out _); - } - - Volatile.Write(ref _installState, 1); - Console.WriteLine( - "windowing: GLFW foreign-active-window guard installed (#452)."); - } - catch (Exception failure) - { - Volatile.Write(ref _installState, -1); - Console.Error.WriteLine( - $"windowing: GLFW active-window guard failed: {failure.Message}"); - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] - private static nint GetCurrentProcessActiveWindow() - { - nint window = GetActiveWindow(); - if (window == 0) - return 0; - - _ = GetWindowThreadProcessId(window, out uint ownerProcessId); - return AcceptWindow(window, ownerProcessId, CurrentProcessId); - } - - internal static nint AcceptWindow( - nint window, - uint ownerProcessId, - uint currentProcessId) => - window != 0 - && ownerProcessId != 0 - && ownerProcessId == currentProcessId - ? window - : 0; - - private static bool TryFindImportSlot( - nint module, - string importedModule, - string importedFunction, - out nint slot) - { - slot = 0; - byte* image = (byte*)module; - if (*(ushort*)image != DosSignature) - return false; - - int peOffset = *(int*)(image + 0x3C); - if (peOffset <= 0 || *(uint*)(image + peOffset) != PeSignature) - return false; - - byte* optionalHeader = image + peOffset + 24; - ushort magic = *(ushort*)optionalHeader; - int dataDirectoryOffset; - int thunkSize; - ulong ordinalFlag; - if (magic == Pe32PlusMagic) - { - dataDirectoryOffset = 112; - thunkSize = 8; - ordinalFlag = 0x8000000000000000UL; - } - else if (magic == Pe32Magic) - { - dataDirectoryOffset = 96; - thunkSize = 4; - ordinalFlag = 0x80000000UL; - } - else - { - return false; - } - - uint sizeOfImage = *(uint*)(optionalHeader + 56); - uint importRva = *(uint*)(optionalHeader + dataDirectoryOffset + 8); - uint importSize = *(uint*)(optionalHeader + dataDirectoryOffset + 12); - if (!Contains(sizeOfImage, importRva, ImportDescriptorSize)) - return false; - - int descriptorLimit = importSize >= ImportDescriptorSize - ? checked((int)(importSize / ImportDescriptorSize)) - : checked((int)((sizeOfImage - importRva) / ImportDescriptorSize)); - for (int descriptorIndex = 0; - descriptorIndex < descriptorLimit; - descriptorIndex++) - { - byte* descriptor = image - + importRva - + descriptorIndex * ImportDescriptorSize; - uint originalFirstThunk = *(uint*)descriptor; - uint nameRva = *(uint*)(descriptor + 12); - uint firstThunk = *(uint*)(descriptor + 16); - if (originalFirstThunk == 0 && nameRva == 0 && firstThunk == 0) - break; - if (!MatchesAsciiZ(image, sizeOfImage, nameRva, importedModule, true)) - continue; - if (originalFirstThunk == 0 - || !Contains(sizeOfImage, originalFirstThunk, thunkSize) - || !Contains(sizeOfImage, firstThunk, thunkSize)) - { - return false; - } - - int thunkLimit = checked((int)Math.Min( - (sizeOfImage - originalFirstThunk) / (uint)thunkSize, - (sizeOfImage - firstThunk) / (uint)thunkSize)); - for (int thunkIndex = 0; thunkIndex < thunkLimit; thunkIndex++) - { - ulong nameThunk = thunkSize == 8 - ? *(ulong*)(image + originalFirstThunk + thunkIndex * thunkSize) - : *(uint*)(image + originalFirstThunk + thunkIndex * thunkSize); - if (nameThunk == 0) - break; - if ((nameThunk & ordinalFlag) != 0) - continue; - - uint importByNameRva = checked((uint)nameThunk); - if (!Contains(sizeOfImage, importByNameRva, 3) - || !MatchesAsciiZ( - image, - sizeOfImage, - importByNameRva + 2, - importedFunction, - false)) - { - continue; - } - - slot = (nint)(image + firstThunk + thunkIndex * thunkSize); - return true; - } - - return false; - } - - return false; - } - - private static bool Contains(uint imageSize, uint offset, int length) => - length >= 0 - && offset < imageSize - && (ulong)offset + (uint)length <= imageSize; - - private static bool MatchesAsciiZ( - byte* image, - uint imageSize, - uint offset, - string expected, - bool ignoreCase) - { - if (!Contains(imageSize, offset, expected.Length + 1)) - return false; - - for (int i = 0; i < expected.Length; i++) - { - char actual = (char)image[offset + (uint)i]; - char wanted = expected[i]; - if (ignoreCase) - { - actual = char.ToUpperInvariant(actual); - wanted = char.ToUpperInvariant(wanted); - } - if (actual != wanted) - return false; - } - return image[offset + (uint)expected.Length] == 0; - } - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern nint GetModuleHandleW(string moduleName); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool VirtualProtect( - nint address, - nuint size, - uint newProtection, - out uint oldProtection); - - [DllImport("user32.dll")] - private static extern nint GetActiveWindow(); - - [DllImport("user32.dll")] - private static extern uint GetWindowThreadProcessId( - nint window, - out uint processId); -} diff --git a/src/AcDream.App/Plugins/AppAutomationSurface.cs b/src/AcDream.App/Plugins/AppAutomationSurface.cs index 8667bbbe..7ad88d74 100644 --- a/src/AcDream.App/Plugins/AppAutomationSurface.cs +++ b/src/AcDream.App/Plugins/AppAutomationSurface.cs @@ -1,22 +1,9 @@ using AcDream.Core.Chat; -using AcDream.Core.Combat; -using AcDream.Core.Items; using AcDream.Core.Player; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using AcDream.Core.Plugins; -using AcDream.Core.Properties; -using AcDream.Core.Selection; using AcDream.Core.Spells; -using AcDream.Core.World; -using AcDream.Core.CharGen; -using AcDream.Content; using AcDream.Plugin.Abstractions; -using AcDream.App.Runtime; using AcDream.Runtime; -using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; -using AcDream.Runtime.Session; namespace AcDream.App.Plugins; @@ -40,66 +27,20 @@ namespace AcDream.App.Plugins; /// internal sealed class AppAutomationSurface : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat, - ICombatAutomation, IEquipmentAutomation, IItemAutomation, - ILootAutomation, IFellowshipAutomation, IEnchantmentAutomation, - IRuntimeCommunicationObserver, - INavigationAutomation, IWorldObjectAutomation, IWorldTimeAutomation, - ILoginAutomation, INetworkAutomation, IRecoveryAutomation, - IProjectileAutomation, ISelectionAutomation, IDisposable + IDisposable { - private readonly PluginCommandRegistry _pluginCommands; - private const int MaximumPluginChatMessages = 512; - private const double PeerHeartbeatSeconds = 5d; private readonly object _gate = new(); - private readonly IEvents? _events; - private readonly LocalPluginPeerRegistry _peers; - private readonly string[] _peerTags; - private double _peerHeartbeatRemaining; private GameRuntime? _runtime; private RuntimeCommunicationState? _communication; private RuntimeCharacterState? _character; private RuntimeSpellCastState? _cast; private Spellbook? _spellbook; - private MagicCatalog _magicCatalog = MagicCatalog.Empty; private IReadOnlyDictionary _skillNames = new Dictionary(); - private Func _speciesName = static _ => string.Empty; - private IChargenPaletteColorSource? _paletteColors; - private Func? _equip; - private Func? _equipmentBusy; - private Func? _useItem; - private Func? _applyItem; - private Func? _moveItem; - private Func? _mergeItems; - private Func? _dropItem; - private Func? _giveItem; - private Func? _pickupItem; - private Func? _identifyItem; - private Func, bool>? _salvageItems; - private Func? _sellItem; - private Func? _dismissGhost; - private Func? _selectionAction; - private PhysicsEngine? _projectilePhysics; - private IReadOnlyList _projectileDebugSamples = - Array.Empty(); - private long _projectileDebugSamplesExpireAt; - private CurrentGameRuntimeAdapter? _sessionCommands; - private IDisposable? _communicationSubscription; - private readonly List _chatMessages = []; - private ulong _pluginChatSequence; - private long _inventoryCompletionRevision; - private PluginInventoryCompletion _lastInventoryCompletion; - private readonly Dictionary<(uint Target, uint Spell), TrackedEnchantment> - _trackedEnchantments = []; - private long _trackedCastCompletionRevision; private bool _disposed; private IReadOnlyList _knownSelfBuffs = Array.Empty(); - private IReadOnlyList _knownAttackSpells = - Array.Empty(); - private IReadOnlyList _knownCombatSpells = - Array.Empty(); private IReadOnlyList _enchantments = Array.Empty(); @@ -111,38 +52,6 @@ internal sealed class AppAutomationSurface private static readonly string[] AttributeNames = ["Strength", "Endurance", "Quickness", "Coordination", "Focus", "Self"]; - public AppAutomationSurface() - : this(events: null) - { - } - - internal AppAutomationSurface( - IEvents? events, - LocalPluginPeerRegistry? peers = null, - IReadOnlyList? peerTags = null) - { - _pluginCommands = new PluginCommandRegistry((verb, error) => - Console.WriteLine( - $"[PluginCommand:{verb}] {error.GetBaseException().Message}")); - _events = events; - _peers = peers ?? new LocalPluginPeerRegistry(Path.Combine( - AcDream.Platform.ApplicationPathSet.Resolve().DataDirectory, - "plugin-peers")); - _peerTags = (peerTags ?? Array.Empty()) - .Where(static tag => !string.IsNullOrWhiteSpace(tag)) - .Select(static tag => tag.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(128) - .ToArray(); - if (_events is not null) - _events.Tick += OnPeerTick; - } - - internal IPluginCommandRegistry PluginCommands => _pluginCommands; - - internal bool TryHandlePluginCommand(string commandLine) => - _pluginCommands.TryHandle(commandLine); - /// /// True only while a character is actually in world. The runtime's own /// lifecycle state is the signal: at character select the gameplay owners @@ -169,172 +78,6 @@ internal sealed class AppAutomationSurface public ISpellCatalog Spells => this; public IMagicCommands Magic => this; public IPluginChat Chat => this; - public ICombatAutomation Combat => this; - public IEquipmentAutomation Equipment => this; - public IItemAutomation Items => this; - public ILootAutomation Loot => this; - public IFellowshipAutomation Fellowship => this; - public IEnchantmentAutomation Enchantments => this; - public INavigationAutomation Navigation => this; - public IWorldObjectAutomation Objects => this; - public IWorldTimeAutomation WorldTime => this; - public ILoginAutomation Login => this; - public INetworkAutomation Network => this; - public IRecoveryAutomation Recovery => this; - public IProjectileAutomation Projectiles => this; - public ISelectionAutomation Selection => this; - - PluginRecoveryResult IRecoveryAutomation.ClearOneBusyReference() - { - GameRuntime? runtime; - lock (_gate) - { - if (_disposed) - return new(false, Message: "The plugin host is disposed."); - runtime = _runtime; - } - if (runtime is null) - return new(false, Message: "No game session is bound."); - - InventoryTransactionState transactions = - runtime.InventoryOwner.Transactions; - int before = transactions.BusyCount; - // VTank's /vt clearbusy calls ga.e(): decrement one reference and - // clamp at zero. CompleteUse has that exact counter transition without - // resetting an unrelated pending inventory request. - transactions.CompleteUse(0u); - return new( - Accepted: true, - PreviousCount: before, - CurrentCount: transactions.BusyCount, - Message: before == 0 - ? "The action busy count was already zero." - : "Cleared one action busy reference."); - } - - bool INetworkAutomation.IsAvailable - { - get - { - lock (_gate) - return !_disposed; - } - } - - IReadOnlyList INetworkAutomation.CaptureClients() - { - // Production publishes from the host update thread at the bounded - // heartbeat cadence. The no-event path exists only for isolated/test - // hosts and publishes on demand. - if (_events is null) - PublishPeerSnapshot(); - return _peers.CaptureRemoteClients(); - } - - bool ILoginAutomation.IsAvailable - { - get - { - lock (_gate) - return !_disposed && _runtime is not null; - } - } - - uint ILoginAutomation.NextLoginObjectId - { - get - { - lock (_gate) - return _runtime?.Session.NextLoginCharacterId ?? 0u; - } - } - - IReadOnlyList ILoginAutomation.CaptureRoster() - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || _disposed) - return Array.Empty(); - - IRuntimeCharacterSelectionView view = runtime.Session.CharacterSelection; - RuntimeCharacterSelectionSnapshot snapshot = view.Snapshot; - var result = new PluginLoginCharacter[snapshot.RosterCount]; - for (int index = 0; index < result.Length; index++) - { - if (!view.TryGetAt(index, out RuntimeCharacterSelectionEntry entry)) - return Array.Empty(); - result[index] = new PluginLoginCharacter( - entry.CharacterId, - entry.Name, - entry.ActiveIndex, - entry.IsPendingDelete); - } - return result; - } - - bool ILoginAutomation.SetNextLogin(uint characterObjectId) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime?.Session.TrySetNextLogin(characterObjectId) == true; - } - - bool ILoginAutomation.ClearNextLogin() - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime?.Session.ClearNextLogin() == true; - } - - PluginWorldTimeSnapshot IWorldTimeAutomation.Snapshot - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return default; - - double rawTicks = runtime.EnvironmentOwner.WorldTime.NowTicks; - DerethCalendar calendar = runtime.EnvironmentOwner.WorldTime.Calendar; - DerethDateTime.Calendar value = calendar.ToCalendar(rawTicks); - int hour = (int)value.Hour; - bool isDay = hour is >= 4 and < 12; - double shiftedTicks = Math.Max(0d, rawTicks) - + calendar.OriginOffsetTicks; - double gameTicks = shiftedTicks - + DerethDateTime.ZeroYear * DerethDateTime.YearTicks; - double withinHour = shiftedTicks - - Math.Floor(shiftedTicks / DerethDateTime.HourTicks) - * DerethDateTime.HourTicks; - double untilNight = isDay - ? ((12 - hour) * DerethDateTime.HourTicks - withinHour) / 60d - : 0d; - int dayHour = hour <= 4 ? hour + 16 : hour; - double untilDay = !isDay - ? ((20 - dayHour) * DerethDateTime.HourTicks - withinHour) / 60d - : 0d; - return new PluginWorldTimeSnapshot( - true, - gameTicks, - value.Year, - (int)value.Month, - value.Day, - hour, - FormatCalendarName(value.Month.ToString()), - FormatCalendarName(value.Hour.ToString()), - isDay, - Math.Max(0d, untilDay), - Math.Max(0d, untilNight)); - } - } - - private static string FormatCalendarName(string value) => value - .Replace("AndHalf", "-and-Half", StringComparison.Ordinal); /// Bind the surface to the runtime's gameplay owners. public void Bind( @@ -352,17 +95,11 @@ internal sealed class AppAutomationSurface DetachLocked(); _runtime = runtime; _communication = runtime.CommunicationOwner; - _communicationSubscription = - runtime.CommunicationOwner.Events.Subscribe(this); _character = character; _cast = cast; _spellbook = spellbook; spellbook.SpellbookChanged += OnSpellbookChanged; spellbook.EnchantmentsChanged += OnEnchantmentsChanged; - runtime.InventoryOwner.Transactions.RequestCompleted += - OnInventoryRequestCompleted; - runtime.InventoryOwner.Transactions.RequestFailed += - OnInventoryRequestFailed; } RebuildSpellbook(); @@ -381,143 +118,16 @@ internal sealed class AppAutomationSurface _skillNames = skillNames; } - /// Supply the immutable retail spell/component DAT catalog. - public void BindMagicCatalog(MagicCatalog catalog) - { - ArgumentNullException.ThrowIfNull(catalog); - lock (_gate) - _magicCatalog = catalog; - } - - /// - /// Bind the current graphical session's typed command adapter. Runtime - /// gameplay owners are process-stable, but the live command bus is - /// session-scoped and is therefore replaced on every new session. - /// - public void BindSessionCommands(CurrentGameRuntimeAdapter commands) - { - ArgumentNullException.ThrowIfNull(commands); - lock (_gate) - _sessionCommands = commands; - } - - /// Supply retail creature-enum display names from portal.dat. - public void BindSpeciesNameResolver(Func resolver) - { - ArgumentNullException.ThrowIfNull(resolver); - lock (_gate) - _speciesName = resolver; - } - - public void BindPaletteColorResolver(IChargenPaletteColorSource resolver) - { - ArgumentNullException.ThrowIfNull(resolver); - lock (_gate) - _paletteColors = resolver; - } - - /// - /// Bind the graphical host's one ItemInteraction/AutoWield owner. Kept - /// separate from Runtime binding because retained interaction composition - /// finishes later during window load. - /// - public void BindEquipment( - Func equip, - Func isBusy) - { - ArgumentNullException.ThrowIfNull(equip); - ArgumentNullException.ThrowIfNull(isBusy); - lock (_gate) - { - _equip = equip; - _equipmentBusy = isBusy; - } - } - - public void BindItems( - Func useItem, - Func applyItem, - Func moveItem, - Func mergeItems, - Func dropItem, - Func giveItem, - Func pickupItem, - Func identifyItem, - Func, bool>? salvageItems = null, - Func? sellItem = null) - { - ArgumentNullException.ThrowIfNull(useItem); - ArgumentNullException.ThrowIfNull(applyItem); - ArgumentNullException.ThrowIfNull(moveItem); - ArgumentNullException.ThrowIfNull(mergeItems); - ArgumentNullException.ThrowIfNull(dropItem); - ArgumentNullException.ThrowIfNull(giveItem); - ArgumentNullException.ThrowIfNull(pickupItem); - ArgumentNullException.ThrowIfNull(identifyItem); - lock (_gate) - { - _useItem = useItem; - _applyItem = applyItem; - _moveItem = moveItem; - _mergeItems = mergeItems; - _dropItem = dropItem; - _giveItem = giveItem; - _pickupItem = pickupItem; - _identifyItem = identifyItem; - _salvageItems = salvageItems; - _sellItem = sellItem; - } - } - - public void BindGhostDeletion(Func dismissGhost) - { - ArgumentNullException.ThrowIfNull(dismissGhost); - lock (_gate) - _dismissGhost = dismissGhost; - } - - /// - /// Bind the process-stable Runtime collision world used by ordinary client - /// physics. The plugin receives only bounded detached query results. - /// - public void BindProjectileCollision(PhysicsEngine physics) - { - ArgumentNullException.ThrowIfNull(physics); - lock (_gate) - _projectilePhysics = physics; - } - - public void BindSelectionActions( - Func execute) - { - ArgumentNullException.ThrowIfNull(execute); - lock (_gate) - _selectionAction = execute; - } - public void Unbind() { lock (_gate) DetachLocked(); - _peers.Withdraw(); _knownSelfBuffs = Array.Empty(); - _knownAttackSpells = Array.Empty(); - _knownCombatSpells = Array.Empty(); _enchantments = Array.Empty(); } private void DetachLocked() { - if (_runtime is { } runtime) - { - runtime.InventoryOwner.Transactions.RequestFailed -= - OnInventoryRequestFailed; - runtime.InventoryOwner.Transactions.RequestCompleted -= - OnInventoryRequestCompleted; - } - _communicationSubscription?.Dispose(); - _communicationSubscription = null; - _chatMessages.Clear(); if (_spellbook is not null) { _spellbook.SpellbookChanged -= OnSpellbookChanged; @@ -528,113 +138,8 @@ internal sealed class AppAutomationSurface _cast = null; _runtime = null; _communication = null; - _dismissGhost = null; - _trackedEnchantments.Clear(); - _trackedCastCompletionRevision = 0; - _projectileDebugSamples = Array.Empty(); - _projectileDebugSamplesExpireAt = 0; } - private void OnPeerTick(double elapsedSeconds) - { - _peerHeartbeatRemaining -= Math.Max(0d, elapsedSeconds); - if (_peerHeartbeatRemaining > 0d) - return; - _peerHeartbeatRemaining = PeerHeartbeatSeconds; - PublishPeerSnapshot(); - } - - private void PublishPeerSnapshot() - { - if (!IsAvailable) - { - _peers.Withdraw(); - return; - } - - ICharacterInfo character = this; - PluginNavigationSnapshot navigation = - ((INavigationAutomation)this).Snapshot; - if (!navigation.IsAvailable || character.ObjectId == 0u) - { - _peers.Withdraw(); - return; - } - - try - { - _peers.Publish(new PluginNetworkClient( - _peers.ClientId, - character.ObjectId, - character.Name, - character.WorldName, - navigation.Position, - _peerTags, - character.CurrentHealth, - character.CurrentMana, - character.CurrentStamina, - character.MaxHealth, - character.MaxMana, - character.MaxStamina, - navigation.Position.HeadingDegrees)); - } - catch (IOException) - { - } - catch (UnauthorizedAccessException) - { - } - } - - private void OnInventoryRequestCompleted(PendingInventoryRequest request) - { - lock (_gate) - { - if (_disposed) - return; - _lastInventoryCompletion = new PluginInventoryCompletion( - ++_inventoryCompletionRevision, - Project(request.Kind), - request.ItemId, - 0u); - } - } - - private void OnInventoryRequestFailed( - PendingInventoryRequest request, - uint weenieError) - { - lock (_gate) - { - if (_disposed) - return; - _lastInventoryCompletion = new PluginInventoryCompletion( - ++_inventoryCompletionRevision, - Project(request.Kind), - request.ItemId, - weenieError); - } - } - - private static PluginInventoryCommandKind Project(InventoryRequestKind kind) => - kind switch - { - InventoryRequestKind.Pickup => PluginInventoryCommandKind.Pickup, - InventoryRequestKind.PutInContainer => - PluginInventoryCommandKind.PutInContainer, - InventoryRequestKind.SplitToContainer => - PluginInventoryCommandKind.SplitToContainer, - InventoryRequestKind.Merge => PluginInventoryCommandKind.Merge, - InventoryRequestKind.Move => PluginInventoryCommandKind.Move, - InventoryRequestKind.DropToWorld => - PluginInventoryCommandKind.DropToWorld, - InventoryRequestKind.SplitToWorld => - PluginInventoryCommandKind.SplitToWorld, - InventoryRequestKind.Wield => PluginInventoryCommandKind.Wield, - InventoryRequestKind.Give => PluginInventoryCommandKind.Give, - _ => PluginInventoryCommandKind.Unknown, - }; - private void OnSpellbookChanged() => RebuildSpellbook(); private void OnEnchantmentsChanged() => RebuildEnchantments(); @@ -647,64 +152,28 @@ internal sealed class AppAutomationSurface if (spellbook is null) { _knownSelfBuffs = Array.Empty(); - _knownAttackSpells = Array.Empty(); - _knownCombatSpells = Array.Empty(); return; } - var buffs = new List(); - var attacks = new List(); - var combat = new List(); + var built = new List(); foreach (uint spellId in spellbook.LearnedSpells) { if (!spellbook.TryGetMetadata(spellId, out SpellMetadata meta)) continue; - if (meta.IsOffensive || meta.IsDebuff) - combat.Add(Project(meta)); // Beneficial and not a debuff is the whole filter. Requiring the // self-targeted flag here is what hid every bane: they are cast by // selecting yourself, and the flag only says "needs no selection". // Whether a given target accepts the spell is EvaluateGate's job. if (!meta.IsBeneficial || meta.IsDebuff || meta.IsUntargeted) - { - // MT1 intentionally projects direct attacks only. Rings, - // debuffs, streaks and harm/martyr policy are MT2, but they - // remain present in TryGet so later policy can inspect them. - if (meta.IsOffensive - && !meta.IsDebuff - && !meta.IsBeneficial - && !meta.IsSelfTargeted - && !meta.IsUntargeted - && meta.TargetMask != 0u) - { - attacks.Add(Project(meta)); - } continue; - } - buffs.Add(Project(meta)); + built.Add(Project(meta)); } - buffs.Sort(static (a, b) => + built.Sort(static (a, b) => a.Family != b.Family ? a.Family.CompareTo(b.Family) : b.Tier.CompareTo(a.Tier)); - attacks.Sort(static (a, b) => - { - int tier = b.Tier.CompareTo(a.Tier); - return tier != 0 - ? tier - : b.Difficulty.CompareTo(a.Difficulty); - }); - _knownSelfBuffs = buffs; - _knownAttackSpells = attacks; - combat.Sort(static (a, b) => - { - int tier = b.Tier.CompareTo(a.Tier); - return tier != 0 - ? tier - : string.CompareOrdinal(a.Name, b.Name); - }); - _knownCombatSpells = combat; + _knownSelfBuffs = built; } private void RebuildEnchantments() @@ -750,23 +219,7 @@ internal sealed class AppAutomationSurface SchoolSkillId(meta.SchoolId), meta.Description, meta.IsSelfTargeted, - meta.IsBeneficial) - { - IsDebuff = meta.IsDebuff, - IsOffensive = meta.IsOffensive, - IsFellowship = meta.IsFellowship, - IsUntargeted = meta.IsUntargeted, - RequiresTurnTo = meta.Family is not (>= 222u and <= 235u) - && !meta.IsUntargeted, - IsProjectile = meta.IsProjectile, - IsDamageOverTime = (meta.Flags & (uint)SpellFlags.DamageOverTime) != 0, - RawFlags = meta.Flags, - SpellType = meta.SpellType, - TargetMask = meta.TargetMask, - BaseRangeConstant = meta.BaseRangeConstant, - BaseRangeModifier = meta.BaseRangeModifier, - FormulaComponentIds = meta.FormulaComponents, - }; + meta.IsBeneficial); /// /// Magic school to the SKILL id that governs it. MagicSchool is @@ -787,95 +240,6 @@ internal sealed class AppAutomationSurface // ── ICharacterInfo ──────────────────────────────────────────────────── public bool IsInWorld => IsAvailable; - public string Name - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null) - return string.Empty; - uint playerId = runtime.PlayerIdentity.ServerGuid; - return runtime.InventoryOwner.Objects.Get(playerId)?.Name - ?? string.Empty; - } - } - - public string WorldName - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime?.CharacterSelection.Snapshot.WorldName ?? string.Empty; - } - } - - public string AccountName - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime?.CharacterSelection.Snapshot.AccountName ?? string.Empty; - } - } - - public int CharacterIndex - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null - || !runtime.CharacterSelection.TryGet( - runtime.PlayerIdentity.ServerGuid, - out RuntimeCharacterSelectionEntry character)) - { - return -1; - } - return character.ActiveIndex; - } - } - - public int Level - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null) - return 0; - return runtime.InventoryOwner.Objects - .Get(runtime.PlayerIdentity.ServerGuid)? - .Properties.GetInt((uint)PropertyInt.Level) ?? 0; - } - } - - public int MainPackFreeSlots - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null) - return 0; - uint playerId = runtime.PlayerIdentity.ServerGuid; - int occupied = runtime.InventoryOwner.Objects.Objects.Count(item => - item.ContainerId == playerId - && ClassifyObject(item) is not ( - PluginObjectClass.Container or PluginObjectClass.Foci) - && item.CurrentlyEquippedLocation == 0); - return Math.Max(0, 102 - occupied); - } - } - public uint ObjectId { get @@ -893,20 +257,6 @@ internal sealed class AppAutomationSurface public uint MaxStamina => Vital(LocalPlayerState.VitalKind.Stamina).Maximum; public uint CurrentMana => Vital(LocalPlayerState.VitalKind.Mana).Current; public uint MaxMana => Vital(LocalPlayerState.VitalKind.Mana).Maximum; - public int SummoningMastery - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null) - return 0; - uint playerId = runtime.PlayerIdentity.ServerGuid; - return runtime.InventoryOwner.Objects.Get(playerId)?.Properties.GetInt( - (uint)PropertyInt.SummoningMastery) ?? 0; - } - } private (uint Current, uint Maximum) Vital(LocalPlayerState.VitalKind kind) { @@ -975,16 +325,8 @@ internal sealed class AppAutomationSurface skill = default; return false; } - uint baseLevel = snapshot.CurrentLevel; - uint currentLevel = checked((uint)Math.Max( - 0, - character.LocalPlayer.GetEffectiveSkill(skillId) - ?? checked((int)baseLevel))); skill = new PluginSkillInfo( - skillId, name, Training(snapshot.Status), currentLevel) - { - Base = baseLevel, - }; + skillId, name, Training(snapshot.Status), snapshot.CurrentLevel); return true; } @@ -1015,18 +357,8 @@ internal sealed class AppAutomationSurface for (int kind = 0; kind < AttributeNames.Length; kind++) { if (character.View.TryGetAttribute(kind, out var attribute)) - { - uint effective = checked((uint)Math.Max( - 0, - character.LocalPlayer.GetEffectiveAttribute( - (LocalPlayerState.AttributeKind)kind) - ?? checked((int)attribute.Current))); built.Add(new PluginAttributeInfo( - kind, AttributeNames[kind], effective) - { - Base = attribute.Current, - }); - } + kind, AttributeNames[kind], attribute.Current)); } return built; } @@ -1034,16 +366,6 @@ internal sealed class AppAutomationSurface // ── ISpellCatalog ───────────────────────────────────────────────────── public IReadOnlyList KnownSelfBuffs => _knownSelfBuffs; - public IReadOnlyList KnownAttackSpells => _knownAttackSpells; - public IReadOnlyList KnownCombatSpells => _knownCombatSpells; - - public bool IsKnown(uint spellId) - { - Spellbook? spellbook; - lock (_gate) - spellbook = _spellbook; - return spellbook?.LearnedSpells.Contains(spellId) == true; - } public bool TryGet(uint spellId, out PluginSpellInfo info) { @@ -1060,93 +382,7 @@ internal sealed class AppAutomationSurface return false; } - public bool TryGetComponent(uint componentId, out PluginSpellComponentInfo info) - { - MagicCatalog catalog; - lock (_gate) - catalog = _magicCatalog; - if (catalog.TryGetComponentBySpellComponentId( - componentId, - out SpellComponentDescriptor descriptor)) - { - info = new PluginSpellComponentInfo( - descriptor.SpellComponentId, - descriptor.WeenieClassId, - descriptor.Name, - descriptor.BurnRate, - descriptor.GestureId, - descriptor.GestureSpeed, - descriptor.IconId, - descriptor.Category, - descriptor.Type, - descriptor.Word); - return true; - } - info = default; - return false; - } - // ── IPluginChat ─────────────────────────────────────────────────────── - public IReadOnlyList CaptureMessages(ulong afterSequence) - { - lock (_gate) - { - if (_chatMessages.Count == 0) - return Array.Empty(); - var result = new List(); - foreach (PluginChatMessage message in _chatMessages) - { - if (message.Sequence > afterSequence) - result.Add(message); - } - return result.Count == 0 - ? Array.Empty() - : result.ToArray(); - } - } - - public double GetCooldownRemaining(uint cooldownId) - { - Spellbook? spellbook; - GameRuntime? runtime; - lock (_gate) - { - spellbook = _spellbook; - runtime = _runtime; - } - if (spellbook is null || runtime is null || cooldownId == 0u) - return 0d; - return spellbook.OnCooldown( - cooldownId, - runtime.Clock.SimulationTimeSeconds, - out double remaining) - ? Math.Max(0d, remaining) - : 0d; - } - - public void OnChat(in RuntimeCommunicationEvent delta) - { - lock (_gate) - { - if (_disposed || _communication is null) - return; - RuntimeChatEntry entry = delta.Entry; - _chatMessages.Add(new PluginChatMessage( - ++_pluginChatSequence, - entry.SenderGuid, - entry.Kind, - entry.Sender, - entry.Text, - entry.ChannelName)); - if (_chatMessages.Count > MaximumPluginChatMessages) - { - _chatMessages.RemoveRange( - 0, - _chatMessages.Count - MaximumPluginChatMessages); - } - } - } - /// /// Routed to retail's ClientLocal log type (0x1A) — the channel the client /// uses for its own notices. Nothing reaches the server, so a plugin cannot @@ -1162,842 +398,6 @@ internal sealed class AppAutomationSurface communication?.AddText(text, RetailLogTextType.ClientLocal); } - public bool Submit(string text) - { - CurrentGameRuntimeAdapter? commands; - lock (_gate) - commands = _sessionCommands; - return commands?.SubmitChatText(text) == true; - } - - bool ISelectionAutomation.Execute(PluginSelectionAction action) - { - Func? execute; - lock (_gate) - execute = _disposed ? null : _selectionAction; - return execute?.Invoke(action) == true; - } - - // ── IProjectileAutomation ─────────────────────────────────────────── - bool IProjectileAutomation.IsAvailable - { - get - { - lock (_gate) - return !_disposed && _projectilePhysics is not null && IsAvailable; - } - } - - PluginProjectilePathResult IProjectileAutomation.EvaluatePath( - uint targetObjectId, - PluginProjectilePathKind kind, - PluginAttackHeight targetHeight, - float projectileRadius, - float stepDistance, - int maximumCollisionChecks) => EvaluateProjectilePathRequest( - targetObjectId, - kind, - targetHeight, - projectileRadius, - stepDistance, - maximumCollisionChecks, - captureDiagnostics: false); - - PluginProjectilePathResult IProjectileAutomation.EvaluatePathWithDiagnostics( - uint targetObjectId, - PluginProjectilePathKind kind, - PluginAttackHeight targetHeight, - float projectileRadius, - float stepDistance, - int maximumCollisionChecks) => EvaluateProjectilePathRequest( - targetObjectId, - kind, - targetHeight, - projectileRadius, - stepDistance, - maximumCollisionChecks, - captureDiagnostics: true); - - void IProjectileAutomation.ShowDebugSamples( - IReadOnlyList samples) - { - ArgumentNullException.ThrowIfNull(samples); - const int maximumMarkers = 4096; - var detached = new List( - Math.Min(samples.Count, maximumMarkers)); - for (int index = 0; index < samples.Count && index < maximumMarkers; index++) - { - PluginProjectileDebugSample sample = samples[index]; - if (!float.IsFinite(sample.WorldPosition.X) - || !float.IsFinite(sample.WorldPosition.Y) - || !float.IsFinite(sample.WorldPosition.Z) - || !float.IsFinite(sample.Radius) - || sample.Radius <= 0f) - { - continue; - } - detached.Add(sample); - } - lock (_gate) - { - if (_disposed) - return; - _projectileDebugSamples = detached.Count == 0 - ? Array.Empty() - : detached.ToArray(); - // VTank rebuilds these shapes on every collision pass. A short - // grace interval keeps them visible between automation ticks - // without turning one query into persistent world state. - _projectileDebugSamplesExpireAt = Environment.TickCount64 + 350; - } - } - - internal IReadOnlyList - CaptureProjectileDebugSamples() - { - lock (_gate) - { - if (_disposed - || Environment.TickCount64 > _projectileDebugSamplesExpireAt) - { - return Array.Empty(); - } - return _projectileDebugSamples; - } - } - - private PluginProjectilePathResult EvaluateProjectilePathRequest( - uint targetObjectId, - PluginProjectilePathKind kind, - PluginAttackHeight targetHeight, - float projectileRadius, - float stepDistance, - int maximumCollisionChecks, - bool captureDiagnostics) - { - GameRuntime? runtime; - PhysicsEngine? physics; - lock (_gate) - { - runtime = _runtime; - physics = _projectilePhysics; - } - if (runtime is null || physics is null || !IsAvailable) - return new(PluginProjectilePathStatus.Unavailable); - if (targetObjectId == 0u - || !float.IsFinite(projectileRadius) - || projectileRadius <= 0f - || !float.IsFinite(stepDistance) - || stepDistance <= 0f - || maximumCollisionChecks <= 0) - { - return new(PluginProjectilePathStatus.InvalidTarget); - } - - uint localId = runtime.PlayerIdentity.ServerGuid; - if (!runtime.EntityObjects.Entities.TryGetActive( - localId, - out RuntimeEntityRecord local) - || !runtime.EntityObjects.Entities.TryGetActive( - targetObjectId, - out RuntimeEntityRecord target) - || local.PhysicsBody is not { } localBody - || target.PhysicsBody is not { } targetBody - || localBody.CellPosition.ObjCellId == 0u) - { - return new(PluginProjectilePathStatus.InvalidTarget); - } - - try - { - return EvaluateProjectilePath( - physics, - localId, - localBody, - targetObjectId, - targetBody, - kind, - targetHeight, - projectileRadius, - stepDistance, - maximumCollisionChecks, - captureDiagnostics); - } - catch (Exception error) - { - return new( - PluginProjectilePathStatus.Error, - Notice: error.GetBaseException().Message); - } - } - - private static PluginProjectilePathResult EvaluateProjectilePath( - PhysicsEngine physics, - uint localObjectId, - PhysicsBody local, - uint targetObjectId, - PhysicsBody target, - PluginProjectilePathKind kind, - PluginAttackHeight targetHeight, - float radius, - float stepDistance, - int maximumChecks, - bool captureDiagnostics) - { - System.Numerics.Vector3 baseDelta = target.Position - local.Position; - var horizontal = new System.Numerics.Vector2(baseDelta.X, baseDelta.Y); - float horizontalDistance = horizontal.Length(); - if (!float.IsFinite(horizontalDistance) - || horizontalDistance <= PhysicsGlobals.EPSILON) - { - return new(PluginProjectilePathStatus.InvalidTarget); - } - - System.Numerics.Vector2 direction = horizontal / horizontalDistance; - float sourceForward = kind switch - { - PluginProjectilePathKind.Arc => 0.44f, - PluginProjectilePathKind.Missile => 0.61f, - _ => 0.66f, - }; - float sourceHeight = kind == PluginProjectilePathKind.Arc ? 1.8f : 1.2f; - float targetHeightMeters = targetHeight switch - { - PluginAttackHeight.Low => 0.3f, - PluginAttackHeight.High => 1.5f, - _ => 0.9f, - }; - var current = local.Position + new System.Numerics.Vector3( - direction.X * sourceForward, - direction.Y * sourceForward, - sourceHeight); - var destination = target.Position + new System.Numerics.Vector3( - 0f, - 0f, - targetHeightMeters); - System.Numerics.Vector3 delta = destination - current; - horizontal = new System.Numerics.Vector2(delta.X, delta.Y); - horizontalDistance = horizontal.Length(); - if (horizontalDistance <= PhysicsGlobals.EPSILON) - return new(PluginProjectilePathStatus.Clear); - direction = horizontal / horizontalDistance; - - float speed = kind switch - { - PluginProjectilePathKind.Arc => 37.5185f, - PluginProjectilePathKind.Missile => 46f, - _ => 100f, - }; - float totalTime = horizontalDistance / speed; - float verticalSpeed = kind == PluginProjectilePathKind.Straight - ? delta.Z / totalTime - : (delta.Z + 4.9f * totalTime * totalTime) / totalTime; - var velocity = new System.Numerics.Vector3( - direction.X * speed, - direction.Y * speed, - verticalSpeed); - float elapsed = 0f; - uint cellId = local.CellPosition.ObjCellId; - var probeBody = new PhysicsBody - { - State = PhysicsStateFlags.Missile - | PhysicsStateFlags.Inelastic - | PhysicsStateFlags.ReportCollisions, - }; - List? debugSamples = captureDiagnostics - ? new List( - Math.Min(maximumChecks, 512)) - : null; - - for (int check = 1; check <= maximumChecks; check++) - { - float remaining = MathF.Max(0f, totalTime - elapsed); - if (remaining <= PhysicsGlobals.EPSILON) - { - return WithProjectileDebugSamples( - new(PluginProjectilePathStatus.Clear, check - 1), - debugSamples); - } - float velocityMagnitude = velocity.Length(); - if (!float.IsFinite(velocityMagnitude) - || velocityMagnitude <= PhysicsGlobals.EPSILON) - { - return WithProjectileDebugSamples( - new( - PluginProjectilePathStatus.Error, - check - 1, - Notice: "The projectile trajectory became invalid."), - debugSamples); - } - float quantum = MathF.Min(remaining, stepDistance / velocityMagnitude); - System.Numerics.Vector3 next = current + velocity * quantum; - if (quantum >= remaining - PhysicsGlobals.EPSILON) - next = destination; - - ResolveResult resolved = physics.ResolveWithTransition( - current, - next, - cellId, - radius, - sphereHeight: 0f, - stepUpHeight: 0f, - stepDownHeight: 0f, - isOnGround: false, - body: probeBody, - moverFlags: ObjectInfoState.PathClipped, - movingEntityId: localObjectId, - localSphereOrigin: System.Numerics.Vector3.Zero, - designatedTargetId: targetObjectId); - float requestedDistance = System.Numerics.Vector3.Distance(current, next); - float deliveredDistance = System.Numerics.Vector3.Distance( - current, - resolved.Position); - bool stopped = !resolved.Ok - || resolved.CollidedWithEnvironment - || resolved.LastCollidedObjectId != 0u - || resolved.CollisionNormalValid - || deliveredDistance + 0.01f < requestedDistance; - bool targetHit = resolved.LastCollidedObjectId == targetObjectId; - debugSamples?.Add(new PluginProjectileDebugSample( - resolved.Position, - targetHit || !stopped, - radius)); - if (targetHit) - { - return WithProjectileDebugSamples( - new( - PluginProjectilePathStatus.Clear, - check, - targetObjectId), - debugSamples); - } - if (stopped) - { - return WithProjectileDebugSamples( - new( - PluginProjectilePathStatus.Blocked, - check, - resolved.LastCollidedObjectId), - debugSamples); - } - - current = resolved.Position; - cellId = resolved.CellId; - elapsed += quantum; - if (kind != PluginProjectilePathKind.Straight) - velocity.Z -= 9.8f * quantum; - } - - return WithProjectileDebugSamples( - new( - PluginProjectilePathStatus.BudgetExceeded, - maximumChecks, - Notice: "The projectile collision-check budget was exhausted."), - debugSamples); - } - - private static PluginProjectilePathResult WithProjectileDebugSamples( - PluginProjectilePathResult result, - List? samples) => samples is null - ? result - : result with { DebugSamples = samples.ToArray() }; - - // ── INavigationAutomation ───────────────────────────────────────────── - PluginNavigationSnapshot INavigationAutomation.Snapshot - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return default; - - RuntimeMovementSnapshot movement = runtime.Movement.Snapshot; - if (!movement.HasController) - return default; - RuntimePortalSnapshot portal = runtime.Portal.Snapshot; - PluginNavigationPosition livePosition = - ProjectNavigationPosition(movement.Position); - PluginNavigationPosition confirmedPosition = livePosition; - ulong confirmedRevision = 0UL; - if (runtime.EntityObjects.Entities.TryGetActive( - runtime.PlayerIdentity.ServerGuid, - out RuntimeEntityRecord localRecord) - && ConvertPosition(localRecord.Snapshot.Position) is { } accepted) - { - confirmedPosition = ProjectNavigationPosition(accepted); - confirmedRevision = localRecord.PositionAuthorityVersion; - } - return new PluginNavigationSnapshot( - IsAvailable: true, - IsPortalSpace: portal.Kind != RuntimePortalKind.None - && !portal.Completed - && !portal.Cancelled, - LocalObjectId: runtime.PlayerIdentity.ServerGuid, - Position: livePosition, - IsMoving: movement.Velocity.LengthSquared() > 0.0001f - || movement.HasCommandInput, - IsAirborne: movement.IsAirborne) - { - ConfirmedPosition = confirmedPosition, - ConfirmedPositionRevision = confirmedRevision, - }; - } - } - - public bool TryGetObject(uint objectId, out PluginNavigationObject value) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable || objectId == 0u) - { - value = default; - return false; - } - - RuntimeMovementSnapshot movement = runtime.Movement.Snapshot; - if (objectId == runtime.PlayerIdentity.ServerGuid) - { - value = new PluginNavigationObject( - objectId, - runtime.InventoryOwner.Objects.Get(objectId)?.Name - ?? string.Empty, - ProjectNavigationPosition(movement.Position)); - return movement.HasController; - } - - if (!runtime.EntityObjects.Entities.TryGetActive( - objectId, - out RuntimeEntityRecord record)) - { - value = default; - return false; - } - - Position? position = record.PhysicsBody?.CellPosition - ?? ConvertPosition(record.Snapshot.Position); - if (position is not { } current) - { - value = default; - return false; - } - value = new PluginNavigationObject( - objectId, - runtime.InventoryOwner.Objects.Get(objectId)?.Name - ?? record.Snapshot.Name - ?? $"0x{objectId:X8}", - ProjectNavigationPosition(current)); - value = EnrichNavigationObject( - value, - runtime.InventoryOwner.Objects.Get(objectId)); - return true; - } - - public bool TryFindObject( - string name, - in PluginNavigationPosition near, - double maximumDistanceMeters, - out PluginNavigationObject value) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null - || !IsAvailable - || string.IsNullOrWhiteSpace(name) - || !double.IsFinite(maximumDistanceMeters) - || maximumDistanceMeters < 0d) - { - value = default; - return false; - } - - double nearestDistance = maximumDistanceMeters; - PluginNavigationObject nearest = default; - bool found = false; - foreach (RuntimeEntityRecord record in runtime.EntityObjects.Entities.ActiveRecords) - { - uint objectId = record.ServerGuid; - string candidateName = runtime.InventoryOwner.Objects.Get(objectId)?.Name - ?? record.Snapshot.Name - ?? string.Empty; - if (!candidateName.Equals(name, StringComparison.OrdinalIgnoreCase)) - continue; - - Position? source = record.PhysicsBody?.CellPosition - ?? ConvertPosition(record.Snapshot.Position); - if (source is not { } position) - continue; - PluginNavigationPosition candidate = ProjectNavigationPosition(position); - double distance = near.HorizontalDistanceMeters(candidate); - if (distance > nearestDistance) - continue; - - nearestDistance = distance; - nearest = EnrichNavigationObject( - new PluginNavigationObject(objectId, candidateName, candidate), - runtime.InventoryOwner.Objects.Get(objectId)); - found = true; - } - - value = nearest; - return found; - } - - public IReadOnlyList CaptureObjects() - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return Array.Empty(); - - var result = new List(); - foreach (RuntimeEntityRecord record in runtime.EntityObjects.Entities.ActiveRecords) - { - Position? source = record.PhysicsBody?.CellPosition - ?? ConvertPosition(record.Snapshot.Position); - if (source is not { } position) - continue; - ClientObject? item = runtime.InventoryOwner.Objects.Get(record.ServerGuid); - string name = item?.Name - ?? record.Snapshot.Name - ?? $"0x{record.ServerGuid:X8}"; - result.Add(EnrichNavigationObject( - new PluginNavigationObject( - record.ServerGuid, - name, - ProjectNavigationPosition(position)), - item)); - } - result.Sort(static (left, right) => left.ObjectId.CompareTo(right.ObjectId)); - return result; - } - - public PluginNavigationCommandStatus SetMovementIntent( - in PluginMovementIntent intent) - { - CurrentGameRuntimeAdapter? commands; - lock (_gate) - commands = _sessionCommands; - if (commands is null || !IsAvailable) - return PluginNavigationCommandStatus.Unavailable; - RuntimeCommandResult result = commands.MovementCommands.SetIntent( - commands.Generation, - new MovementInput( - intent.Forward, - intent.Backward, - intent.StrafeLeft, - intent.StrafeRight, - intent.TurnLeft, - intent.TurnRight, - intent.Run, - MouseDeltaX: 0f, - intent.Jump)); - return result.Status == RuntimeCommandStatus.Accepted - ? PluginNavigationCommandStatus.Accepted - : PluginNavigationCommandStatus.Rejected; - } - - public PluginNavigationCommandStatus ClearMovementIntent() - { - CurrentGameRuntimeAdapter? commands; - lock (_gate) - commands = _sessionCommands; - if (commands is null || !IsAvailable) - return PluginNavigationCommandStatus.Unavailable; - RuntimeCommandResult result = commands.MovementCommands.ClearIntent( - commands.Generation); - return result.Status == RuntimeCommandStatus.Accepted - ? PluginNavigationCommandStatus.Accepted - : PluginNavigationCommandStatus.Rejected; - } - - internal static PluginNavigationPosition ProjectNavigationPosition( - Position position) - { - uint cellId = position.ObjCellId; - uint blockX = (cellId >> 24) & 0xFFu; - uint blockY = (cellId >> 16) & 0xFFu; - System.Numerics.Vector3 local = position.Frame.Origin; - return new PluginNavigationPosition( - cellId, - (((double)blockX - 127d) * 192d + local.X - 84d) / 240d, - (((double)blockY - 127d) * 192d + local.Y - 84d) / 240d, - local.Z / 240d, - MoveToMath.GetHeading(position.Frame.Orientation), - (cellId & 0xFFFFu) is >= 1u and <= 0x40u); - } - - // ── IWorldObjectAutomation ──────────────────────────────────────────── - bool IWorldObjectAutomation.IsAvailable => IsAvailable; - - uint IWorldObjectAutomation.OpenContainerObjectId - { - get - { - lock (_gate) - return _runtime?.InventoryOwner.ExternalContainers - .CurrentContainerId ?? 0u; - } - } - - IReadOnlyList IWorldObjectAutomation.CaptureObjects() - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return Array.Empty(); - - ClientObjectTable objects = runtime.InventoryOwner.Objects; - uint playerId = runtime.PlayerIdentity.ServerGuid; - var result = new List(); - var captured = new HashSet(); - foreach (RuntimeEntityRecord record in - runtime.EntityObjects.Entities.ActiveRecords.ToArray()) - { - ClientObject? item = objects.Get(record.ServerGuid); - result.Add(ProjectWorldObject(runtime, record, item, playerId)); - captured.Add(record.ServerGuid); - } - foreach (ClientObject item in objects.Objects) - { - if (!captured.Add(item.ObjectId)) - continue; - result.Add(ProjectWorldObject(runtime, null, item, playerId)); - } - result.Sort(static (left, right) => left.ObjectId.CompareTo(right.ObjectId)); - return result; - } - - bool IWorldObjectAutomation.TryGet( - uint objectId, - out PluginWorldObject value) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable || objectId == 0u) - { - value = default; - return false; - } - - runtime.EntityObjects.Entities.TryGetActive( - objectId, - out RuntimeEntityRecord? record); - ClientObject? item = runtime.InventoryOwner.Objects.Get(objectId); - if (record is null && item is null) - { - value = default; - return false; - } - value = ProjectWorldObject( - runtime, - record, - item, - runtime.PlayerIdentity.ServerGuid); - return true; - } - - bool IWorldObjectAutomation.TryCaptureProperties( - uint objectId, - out PluginItemProperties properties) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - ClientObject? item = runtime?.InventoryOwner.Objects.Get(objectId); - if (runtime is null || !IsAvailable || item is null) - { - properties = default; - return false; - } - properties = CaptureProperties(item.Properties); - return true; - } - - PluginItemCommandResult IWorldObjectAutomation.Identify(uint objectId) => - ((ILootAutomation)this).Identify(objectId); - - private PluginWorldObject ProjectWorldObject( - GameRuntime runtime, - RuntimeEntityRecord? record, - ClientObject? item, - uint playerId) - { - uint objectId = record?.ServerGuid ?? item!.ObjectId; - Position? source = record?.PhysicsBody?.CellPosition - ?? (record is null ? null : ConvertPosition(record.Snapshot.Position)); - bool owned = item is not null - && IsPlayerOwned(item, playerId, runtime.InventoryOwner.Objects); - IReadOnlyList activeSpells = objectId == playerId - ? _enchantments.Select(static enchantment => enchantment.SpellId).ToArray() - : Array.Empty(); - uint publicFlags = item?.PublicWeenieBitfield ?? 0u; - return new PluginWorldObject( - objectId, - item?.WeenieClassId ?? 0u, - item?.Name ?? record?.Snapshot.Name ?? $"0x{objectId:X8}", - ClassifyObject(item), - (uint)(item?.Type ?? ItemType.None), - item?.ContainerId ?? 0u, - item?.WielderId ?? 0u) - { - IsOwned = owned, - IsLandscape = source is not null - && !owned - && (item?.ContainerId ?? 0u) == 0u - && (item?.WielderId ?? 0u) == 0u, - HasPosition = source is not null, - Position = source is { } position - ? ProjectNavigationPosition(position) - : default, - HasAppraisalData = item is not null && HasPropertyData(item.Properties), - LastIdTime = item?.LastAppraisalTimeMs ?? 0, - IsDoorOpen = (publicFlags & (uint)PublicWeenieFlags.Door) != 0u - && (item?.Properties.GetBool((uint)PropertyBool.Open) ?? false), - StackSize = Math.Max(1, item?.StackSize ?? 1), - ItemsCapacity = item?.ItemsCapacity ?? 0, - ContainersCapacity = item?.ContainersCapacity ?? 0, - SpellIds = item?.AppraisedSpellIds.Count > 0 - ? item.AppraisedSpellIds.ToArray() - : Array.Empty(), - ActiveSpellIds = activeSpells, - }; - } - - private static bool HasPropertyData(PropertyBundle properties) => - properties.Ints.Count != 0 - || properties.Int64s.Count != 0 - || properties.Bools.Count != 0 - || properties.Floats.Count != 0 - || properties.Strings.Count != 0 - || properties.DataIds.Count != 0 - || properties.InstanceIds.Count != 0; - - /// - /// Exact Virindi/Decal ObjectClass priority from VTank's fu.a(). - /// PublicWeenieDesc flags override ItemType, then writable and creature - /// refinements distinguish books/scrolls/NPCs/combat pets. - /// - internal static PluginObjectClass ClassifyObject(ClientObject? item) - { - if (item is null) - return PluginObjectClass.Unknown; - uint type = (uint)item.Type; - uint flags = item.PublicWeenieBitfield ?? 0u; - PluginObjectClass result = type switch - { - _ when (type & 0x00000001u) != 0u => PluginObjectClass.MeleeWeapon, - _ when (type & 0x00000002u) != 0u => PluginObjectClass.Armor, - _ when (type & 0x00000004u) != 0u => PluginObjectClass.Clothing, - _ when (type & 0x00000008u) != 0u => PluginObjectClass.Jewelry, - _ when (type & 0x00000010u) != 0u => PluginObjectClass.Monster, - _ when (type & 0x00000020u) != 0u => PluginObjectClass.Food, - _ when (type & 0x00000040u) != 0u => PluginObjectClass.Money, - _ when (type & 0x00000080u) != 0u => PluginObjectClass.Misc, - _ when (type & 0x00000100u) != 0u => PluginObjectClass.MissileWeapon, - _ when (type & 0x00000200u) != 0u => PluginObjectClass.Container, - _ when (type & 0x00000400u) != 0u => PluginObjectClass.Bundle, - _ when (type & 0x00000800u) != 0u => PluginObjectClass.Gem, - _ when (type & 0x00001000u) != 0u => PluginObjectClass.SpellComponent, - _ when (type & 0x00004000u) != 0u => PluginObjectClass.Key, - _ when (type & 0x00008000u) != 0u => PluginObjectClass.WandStaffOrb, - _ when (type & 0x00010000u) != 0u => PluginObjectClass.Portal, - _ when (type & 0x00040000u) != 0u => PluginObjectClass.TradeNote, - _ when (type & 0x00080000u) != 0u => PluginObjectClass.ManaStone, - _ when (type & 0x00100000u) != 0u => PluginObjectClass.Services, - _ when (type & 0x00200000u) != 0u => PluginObjectClass.Plant, - _ when (type & 0x00400000u) != 0u => PluginObjectClass.BaseCooking, - _ when (type & 0x00800000u) != 0u => PluginObjectClass.BaseAlchemy, - _ when (type & 0x01000000u) != 0u => PluginObjectClass.BaseFletching, - _ when (type & 0x02000000u) != 0u => PluginObjectClass.CraftedCooking, - _ when (type & 0x04000000u) != 0u => PluginObjectClass.CraftedAlchemy, - _ when (type & 0x08000000u) != 0u => PluginObjectClass.CraftedFletching, - _ when (type & 0x20000000u) != 0u => PluginObjectClass.Ust, - _ when (type & 0x40000000u) != 0u => PluginObjectClass.Salvage, - _ => PluginObjectClass.Unknown, - }; - - result = flags switch - { - _ when (flags & 0x00000008u) != 0u => PluginObjectClass.Player, - _ when (flags & 0x00000200u) != 0u => PluginObjectClass.Vendor, - _ when (flags & 0x00001000u) != 0u => PluginObjectClass.Door, - _ when (flags & 0x00002000u) != 0u => PluginObjectClass.Corpse, - _ when (flags & 0x00004000u) != 0u => PluginObjectClass.Lifestone, - _ when (flags & 0x00008000u) != 0u => PluginObjectClass.Food, - _ when (flags & 0x00010000u) != 0u => PluginObjectClass.HealingKit, - _ when (flags & 0x00020000u) != 0u => PluginObjectClass.Lockpick, - _ when (flags & 0x00040000u) != 0u => PluginObjectClass.Portal, - _ when (flags & 0x00800000u) != 0u => PluginObjectClass.Foci, - _ when (flags & 0x00000001u) != 0u => PluginObjectClass.Container, - _ => result, - }; - - if ((type & 0x00002000u) != 0u && result == PluginObjectClass.Unknown) - { - result = (flags & 0x00000002u) != 0u - ? PluginObjectClass.Journal - : (flags & 0x00000004u) != 0u - ? PluginObjectClass.Sign - : (flags & 0x0000000Fu) != 0u - ? PluginObjectClass.Book - : result; - } - if ((type & 0x00002000u) != 0u && item.SpellId is > 0u) - result = PluginObjectClass.Scroll; - if (result == PluginObjectClass.Monster && (flags & 0x10u) == 0u) - result = PluginObjectClass.Npc; - if (result == PluginObjectClass.Monster && (flags & 0x04000000u) != 0u) - result = PluginObjectClass.CombatPet; - return result; - } - - private static PluginNavigationObject EnrichNavigationObject( - in PluginNavigationObject value, - ClientObject? item) - { - if (item is null) - return value; - bool hasOpen = item.Properties.Bools.TryGetValue( - (uint)PropertyBool.Open, - out bool isOpen); - bool hasLocked = item.Properties.Bools.TryGetValue( - (uint)PropertyBool.Locked, - out bool isLocked); - return value with - { - IsDoor = ((PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u) - & PublicWeenieFlags.Door) != 0, - IsOpen = hasOpen && isOpen, - IsLocked = hasLocked && isLocked, - HasLockState = hasOpen || hasLocked, - LockDifficulty = item.Properties.GetInt( - (uint)PropertyInt.ResistLockpick), - }; - } - - private static Position? ConvertPosition( - AcDream.Core.Net.Messages.CreateObject.ServerPosition? position) => - position is not { } value - ? null - : new Position( - value.LandblockId, - new System.Numerics.Vector3( - value.PositionX, - value.PositionY, - value.PositionZ), - new System.Numerics.Quaternion( - value.RotationX, - value.RotationY, - value.RotationZ, - value.RotationW)); - // ── IMagicCommands ──────────────────────────────────────────────────── /// /// True while an action the server has not acknowledged is in flight. @@ -2056,1693 +456,16 @@ internal sealed class AppAutomationSurface return cast is not null && cast.Cast(spellId) == CastRequestResult.Sent; } - public PluginCastCompletion LastCompletion - { - get - { - ObserveSuccessfulLocalCast(); - RuntimeSpellCastState? cast; - lock (_gate) - cast = _cast; - RuntimeSpellCastCompletion completion = - cast?.LastCompletion ?? default; - return new PluginCastCompletion( - completion.Revision, - completion.SpellId, - completion.TargetObjectId, - completion.WeenieError); - } - } - - public PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) - { - if (!SelectExplicitTarget(targetObjectId)) - return PluginCastGate.Refused; - return EvaluateGate(spellId); - } - - public bool Cast(uint spellId, uint targetObjectId) => - SelectExplicitTarget(targetObjectId) && Cast(spellId); - - // ── IEquipmentAutomation ───────────────────────────────────────────── - bool IEquipmentAutomation.IsAvailable - { - get - { - lock (_gate) - return !_disposed && _equip is not null && IsAvailable; - } - } - - bool IEquipmentAutomation.IsBusy - { - get - { - Func? busy; - lock (_gate) - busy = _equipmentBusy; - return busy?.Invoke() == true; - } - } - - public IReadOnlyList CaptureOwnedEquipment() - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return Array.Empty(); - - uint playerId = runtime.PlayerIdentity.ServerGuid; - if (playerId == 0u) - return Array.Empty(); - ClientObjectTable objects = runtime.InventoryOwner.Objects; - var built = new List(); - foreach (ClientObject item in objects.Objects) - { - if (item.ValidLocations == EquipMask.None - || !IsPlayerOwned(item, playerId, objects)) - { - continue; - } - built.Add(new PluginEquipmentItem( - item.ObjectId, - item.GetAppropriateName(), - (uint)item.Type, - (uint)item.ValidLocations, - (uint)item.CurrentlyEquippedLocation, - item.ContainerId, - item.WielderId, - item.CombatUse ?? 0, - item.Properties.GetInt((uint)PropertyInt.DamageType), - item.Properties.GetInt((uint)PropertyInt.WeaponSkill), - item.Properties.GetInt((uint)PropertyInt.Damage), - item.Properties.GetFloat((uint)PropertyFloat.DamageVariance)) - { - AmmoType = item.AmmoType ?? (uint)Math.Max( - 0, - item.Properties.GetInt((uint)PropertyInt.AmmoType)), - StackSize = Math.Max(1, item.StackSize), - WeaponType = item.Properties.GetInt( - (uint)PropertyInt.WeaponType), - }); - } - built.Sort(static (left, right) => - { - int equipped = right.IsEquipped.CompareTo(left.IsEquipped); - if (equipped != 0) - return equipped; - int name = string.CompareOrdinal(left.Name, right.Name); - return name != 0 - ? name - : left.ObjectId.CompareTo(right.ObjectId); - }); - return built; - } - - public PluginEquipmentCommandResult Equip( - uint objectId, - uint requestedLocation = 0u) - { - Func? equip; - Func? busy; - GameRuntime? runtime; - lock (_gate) - { - equip = _equip; - busy = _equipmentBusy; - runtime = _runtime; - } - if (equip is null || runtime is null || !IsAvailable) - return new(PluginEquipmentCommandStatus.Unavailable); - if (objectId == 0u - || runtime.InventoryOwner.Objects.Get(objectId) is not { } item - || item.ValidLocations == EquipMask.None) - { - return new(PluginEquipmentCommandStatus.InvalidItem); - } - if (item.CurrentlyEquippedLocation != EquipMask.None - && (requestedLocation == 0u - || ((uint)item.CurrentlyEquippedLocation & requestedLocation) - == requestedLocation)) - { - return new(PluginEquipmentCommandStatus.AlreadyEquipped); - } - if (busy?.Invoke() == true) - return new(PluginEquipmentCommandStatus.Busy); - return equip(objectId, requestedLocation) - ? new(PluginEquipmentCommandStatus.Started) - : new(PluginEquipmentCommandStatus.Refused); - } - - // ── IItemAutomation ────────────────────────────────────────────────── - bool IItemAutomation.IsAvailable - { - get - { - lock (_gate) - return !_disposed && _useItem is not null - && _applyItem is not null && IsAvailable; - } - } - - bool IItemAutomation.IsBusy - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime is not null - && !runtime.InventoryOwner.Transactions.CanBeginRequest; - } - } - - int IItemAutomation.ActiveOwnedPetCount - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return 0; - uint playerId = runtime.PlayerIdentity.ServerGuid; - int count = 0; - foreach (ClientObject candidate in runtime.InventoryOwner.Objects.Objects) - { - if (candidate.PetOwnerId == playerId - && (candidate.Type & ItemType.Creature) != 0) - { - count++; - } - } - return count; - } - } - - PluginItemUseCompletion IItemAutomation.LastCompletion - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - RuntimeItemUseCompletion completion = - runtime?.ActionOwner.Transactions.LastItemUseCompletion ?? default; - return new PluginItemUseCompletion( - completion.Revision, - completion.SourceObjectId, - completion.TargetObjectId, - completion.WeenieError); - } - } - - PluginInventoryCompletion IItemAutomation.LastInventoryCompletion - { - get - { - lock (_gate) - return _lastInventoryCompletion; - } - } - - public uint ActiveVendorObjectId - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime?.InventoryOwner.Vendor.VendorId ?? 0u; - } - } - - public IReadOnlyList CaptureOwnedItems() - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return Array.Empty(); - - uint playerId = runtime.PlayerIdentity.ServerGuid; - if (playerId == 0u) - return Array.Empty(); - ClientObjectTable objects = runtime.InventoryOwner.Objects; - var built = new List(); - foreach (ClientObject item in objects.Objects) - { - if (!IsPlayerOwned(item, playerId, objects)) - continue; - built.Add(new PluginInventoryItem( - item.ObjectId, - item.WeenieClassId, - item.GetAppropriateName(), - (uint)item.Type, - item.ContainerId, - item.WielderId, - (uint)item.ValidLocations, - (uint)item.CurrentlyEquippedLocation, - item.Useability ?? 0u, - item.TargetType ?? 0u, - item.PublicWeenieBitfield ?? 0u, - item.StackSize, - item.Structure, - item.MaxStructure, - item.SpellId - ?? (item.Properties.DataIds.TryGetValue( - (uint)PropertyDataId.Spell, - out uint itemSpell) ? itemSpell : 0u), - item.Properties.GetInt((uint)PropertyInt.PetClass), - item.Properties.GetInt((uint)PropertyInt.SummoningMastery), - item.Properties.DataIds.TryGetValue( - (uint)PropertyDataId.ProcSpell, - out uint procSpell) ? procSpell : 0u, - item.Properties.GetBool((uint)PropertyBool.ProcSpellSelfTargeted), - item.Properties.GetFloat((uint)PropertyFloat.ProcSpellRate), - item.Properties.GetInt((uint)PropertyInt.WeaponSkill), - item.Properties.GetInt((uint)PropertyInt.DamageType), - item.Properties.GetInt((uint)PropertyInt.Damage), - item.Properties.GetFloat((uint)PropertyFloat.DamageVariance), - item.Properties.GetInt((uint)PropertyInt.UseRequiresSkill), - item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillLevel), - item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillSpec)) - { - CombatUse = item.CombatUse ?? 0, - ItemSpellcraft = item.Properties.GetInt( - (uint)PropertyInt.ItemSpellcraft), - WieldRequirements = item.Properties.GetInt( - (uint)PropertyInt.WieldRequirements), - WieldSkillType = item.Properties.GetInt( - (uint)PropertyInt.WieldSkilltype), - WieldDifficulty = item.Properties.GetInt( - (uint)PropertyInt.WieldDifficulty), - AttackType = item.Properties.GetInt( - (uint)PropertyInt.AttackType), - WeaponType = item.Properties.GetInt( - (uint)PropertyInt.WeaponType), - BoosterVital = item.Properties.GetInt( - (uint)PropertyInt.BoosterEnum), - BoostValue = item.Properties.GetInt( - (uint)PropertyInt.BoostValue), - HealKitModifier = item.Properties.GetFloat( - (uint)PropertyFloat.HealkitMod), - AppraisedSpellIds = item.AppraisedSpellIds.Count == 0 - ? Array.Empty() - : item.AppraisedSpellIds.ToArray(), - GearDamage = item.Properties.GetInt((uint)PropertyInt.GearDamage), - GearDamageResistance = item.Properties.GetInt( - (uint)PropertyInt.GearDamageResist), - GearCriticalChance = item.Properties.GetInt( - (uint)PropertyInt.GearCrit), - GearCriticalResistance = item.Properties.GetInt( - (uint)PropertyInt.GearCritResist), - GearCriticalDamage = item.Properties.GetInt( - (uint)PropertyInt.GearCritDamage), - GearCriticalDamageResistance = item.Properties.GetInt( - (uint)PropertyInt.GearCritDamageResist), - MaximumStackSize = item.StackSizeMax, - ContainerSlot = item.ContainerSlot, - ItemsCapacity = item.ItemsCapacity, - ContainersCapacity = item.ContainersCapacity, - Burden = item.Burden, - Value = item.Value, - ItemCurrentMana = item.Properties.GetInt( - (uint)PropertyInt.ItemCurMana), - ItemMaximumMana = item.Properties.GetInt( - (uint)PropertyInt.ItemMaxMana), - Workmanship = item.Workmanship, - MaterialType = item.MaterialType ?? 0u, - ObjectClass = ClassifyObject(item), - Palettes = ProjectPalettes(runtime, item.ObjectId), - }); - } - built.Sort(static (left, right) => - { - int name = string.CompareOrdinal(left.Name, right.Name); - return name != 0 ? name : left.ObjectId.CompareTo(right.ObjectId); - }); - return built; - } - - public bool TryCaptureProperties( - uint objectId, - out PluginItemProperties properties) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - { - properties = default; - return false; - } - ClientObjectTable objects = runtime.InventoryOwner.Objects; - uint playerId = runtime.PlayerIdentity.ServerGuid; - if (!TryGetOwned(objects, playerId, objectId, out ClientObject? item)) - { - properties = default; - return false; - } - PropertyBundle source = item!.Properties; - properties = new PluginItemProperties( - new Dictionary(source.Ints), - new Dictionary(source.Int64s), - new Dictionary(source.Bools), - new Dictionary(source.Floats), - new Dictionary(source.Strings), - new Dictionary(source.DataIds), - new Dictionary(source.InstanceIds)); - return true; - } - - public PluginItemCommandResult Use(uint objectId) - => DispatchItem(objectId, 0u); - - public PluginItemCommandResult Apply(uint objectId, uint targetObjectId) - => DispatchItem(objectId, targetObjectId); - - public PluginItemCommandResult MoveToContainer( - uint objectId, - uint containerObjectId, - uint amount = 0u, - int placement = 0) - { - Func? move; - GameRuntime? runtime; - lock (_gate) - { - move = _moveItem; - runtime = _runtime; - } - if (runtime is null || move is null || !IsAvailable) - return new(PluginItemCommandStatus.Unavailable); - ClientObjectTable objects = runtime.InventoryOwner.Objects; - uint playerId = runtime.PlayerIdentity.ServerGuid; - if (!TryGetOwned(objects, playerId, objectId, out ClientObject? item)) - return new(PluginItemCommandStatus.InvalidItem); - if (containerObjectId == 0u - || objects.Get(containerObjectId) is not { } container - || (containerObjectId != playerId - && !IsPlayerOwned(container, playerId, objects))) - { - return new(PluginItemCommandStatus.InvalidTarget); - } - if (!ValidAmount(item!, amount)) - return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); - if (!runtime.InventoryOwner.Transactions.CanBeginRequest) - return new(PluginItemCommandStatus.Busy); - return move(objectId, containerObjectId, amount, placement) - ? new(PluginItemCommandStatus.Started) - : new(PluginItemCommandStatus.Refused); - } - - public PluginItemCommandResult Merge( - uint sourceObjectId, - uint targetObjectId, - uint amount = 0u) - { - Func? merge; - GameRuntime? runtime; - lock (_gate) - { - merge = _mergeItems; - runtime = _runtime; - } - if (runtime is null || merge is null || !IsAvailable) - return new(PluginItemCommandStatus.Unavailable); - ClientObjectTable objects = runtime.InventoryOwner.Objects; - uint playerId = runtime.PlayerIdentity.ServerGuid; - if (!TryGetOwned(objects, playerId, sourceObjectId, out ClientObject? source)) - return new(PluginItemCommandStatus.InvalidItem); - if (!TryGetOwned(objects, playerId, targetObjectId, out _)) - return new(PluginItemCommandStatus.InvalidTarget); - if (!ValidAmount(source!, amount)) - return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); - if (!runtime.InventoryOwner.Transactions.CanBeginRequest) - return new(PluginItemCommandStatus.Busy); - return merge(sourceObjectId, targetObjectId, amount) - ? new(PluginItemCommandStatus.Started) - : new(PluginItemCommandStatus.Refused); - } - - public PluginItemCommandResult Drop(uint objectId, uint amount = 0u) - { - Func? drop; - GameRuntime? runtime; - lock (_gate) - { - drop = _dropItem; - runtime = _runtime; - } - if (runtime is null || drop is null || !IsAvailable) - return new(PluginItemCommandStatus.Unavailable); - ClientObjectTable objects = runtime.InventoryOwner.Objects; - if (!TryGetOwned( - objects, - runtime.PlayerIdentity.ServerGuid, - objectId, - out ClientObject? item)) - { - return new(PluginItemCommandStatus.InvalidItem); - } - if (!ValidAmount(item!, amount)) - return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); - if (!runtime.InventoryOwner.Transactions.CanBeginRequest) - return new(PluginItemCommandStatus.Busy); - return drop(objectId, amount) - ? new(PluginItemCommandStatus.Started) - : new(PluginItemCommandStatus.Refused); - } - - public PluginItemCommandResult Give( - uint objectId, - uint targetObjectId, - uint amount = 0u) - { - Func? give; - GameRuntime? runtime; - lock (_gate) - { - give = _giveItem; - runtime = _runtime; - } - if (runtime is null || give is null || !IsAvailable) - return new(PluginItemCommandStatus.Unavailable); - ClientObjectTable objects = runtime.InventoryOwner.Objects; - if (!TryGetOwned( - objects, - runtime.PlayerIdentity.ServerGuid, - objectId, - out ClientObject? item)) - { - return new(PluginItemCommandStatus.InvalidItem); - } - if (targetObjectId == 0u || objects.Get(targetObjectId) is null) - return new(PluginItemCommandStatus.InvalidTarget); - if (!ValidAmount(item!, amount)) - return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); - if (!runtime.InventoryOwner.Transactions.CanBeginRequest) - return new(PluginItemCommandStatus.Busy); - return give(objectId, targetObjectId, amount) - ? new(PluginItemCommandStatus.Started) - : new(PluginItemCommandStatus.Refused); - } - - public PluginItemCommandResult Salvage( - uint toolObjectId, - IReadOnlyList itemObjectIds) - { - Func, bool>? salvage; - GameRuntime? runtime; - lock (_gate) - { - salvage = _salvageItems; - runtime = _runtime; - } - if (runtime is null || salvage is null || !IsAvailable) - return new(PluginItemCommandStatus.Unavailable); - if (itemObjectIds is null || itemObjectIds.Count == 0) - return new(PluginItemCommandStatus.InvalidItem); - - ClientObjectTable objects = runtime.InventoryOwner.Objects; - uint playerId = runtime.PlayerIdentity.ServerGuid; - if (!TryGetOwned(objects, playerId, toolObjectId, out ClientObject? tool) - || (tool!.Type & ItemType.TinkeringTool) == 0) - { - return new(PluginItemCommandStatus.InvalidTarget); - } - foreach (uint itemObjectId in itemObjectIds) - { - if (!TryGetOwned(objects, playerId, itemObjectId, out _)) - return new(PluginItemCommandStatus.InvalidItem); - } - if (!runtime.InventoryOwner.Transactions.CanBeginRequest) - return new(PluginItemCommandStatus.Busy); - return salvage(toolObjectId, itemObjectIds) - ? new(PluginItemCommandStatus.Started) - : new(PluginItemCommandStatus.Refused); - } - - public PluginItemCommandResult Sell(uint objectId, uint amount = 0u) - { - Func? sell; - GameRuntime? runtime; - lock (_gate) - { - sell = _sellItem; - runtime = _runtime; - } - if (runtime is null || sell is null || !IsAvailable) - return new(PluginItemCommandStatus.Unavailable); - uint vendorId = runtime.InventoryOwner.Vendor.VendorId; - if (vendorId == 0u) - return new(PluginItemCommandStatus.InvalidTarget, "No vendor is open."); - - ClientObjectTable objects = runtime.InventoryOwner.Objects; - uint playerId = runtime.PlayerIdentity.ServerGuid; - if (!TryGetOwned(objects, playerId, objectId, out ClientObject? item)) - return new(PluginItemCommandStatus.InvalidItem); - if (!ValidAmount(item!, amount)) - return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); - int quantity = checked((int)(amount == 0u - ? (uint)Math.Max(1, item!.StackSize) - : amount)); - int perUnitValue = VendorPricing.PerUnitValue(item!.Value, item.StackSize); - VendorShopProfile profile = runtime.InventoryOwner.Vendor.Profile; - VendorSellRejection rejection = VendorSellAcceptability.Evaluate( - ownedByPlayer: true, - containedItemCount: objects.GetContents(objectId).Count, - itemTypeMask: (uint)item.Type, - perUnitValue, - profile.MerchandiseItemTypes, - profile.MerchandiseMinValue, - profile.MerchandiseMaxValue, - item.PublicWeenieBitfield ?? 0u); - if (rejection != VendorSellRejection.None) - { - return new PluginItemCommandResult( - PluginItemCommandStatus.Refused, - VendorSellAcceptability.MessageFor(rejection)); - } - if (!runtime.InventoryOwner.Transactions.CanBeginRequest) - return new(PluginItemCommandStatus.Busy); - return sell(vendorId, objectId, quantity) - ? new(PluginItemCommandStatus.Started) - : new(PluginItemCommandStatus.Refused); - } - - private PluginItemCommandResult DispatchItem( - uint objectId, - uint targetObjectId) - { - Func? use; - Func? apply; - GameRuntime? runtime; - lock (_gate) - { - use = _useItem; - apply = _applyItem; - runtime = _runtime; - } - if (runtime is null || use is null || apply is null || !IsAvailable) - return new(PluginItemCommandStatus.Unavailable); - ClientObjectTable objects = runtime.InventoryOwner.Objects; - uint playerId = runtime.PlayerIdentity.ServerGuid; - if (objectId == 0u - || objects.Get(objectId) is not { } item - || !IsPlayerOwned(item, playerId, objects)) - { - return new(PluginItemCommandStatus.InvalidItem); - } - if (targetObjectId != 0u && objects.Get(targetObjectId) is null) - return new(PluginItemCommandStatus.InvalidTarget); - if (!runtime.InventoryOwner.Transactions.CanBeginRequest) - return new(PluginItemCommandStatus.Busy); - bool started = targetObjectId == 0u - ? use(objectId) - : apply(objectId, targetObjectId); - return started - ? new(PluginItemCommandStatus.Started) - : new(PluginItemCommandStatus.Refused); - } - - private static bool IsPlayerOwned( - ClientObject item, - uint playerId, - ClientObjectTable objects) - { - if (item.WielderId == playerId || item.ContainerId == playerId) - return true; - uint parentId = item.ContainerId; - for (int depth = 0; parentId != 0u && depth < 4; depth++) - { - ClientObject? parent = objects.Get(parentId); - if (parent is null) - return false; - if (parent.WielderId == playerId || parent.ContainerId == playerId) - return true; - parentId = parent.ContainerId; - } - return false; - } - - private static bool TryGetOwned( - ClientObjectTable objects, - uint playerId, - uint objectId, - out ClientObject? item) - { - item = objectId == 0u ? null : objects.Get(objectId); - return item is not null && IsPlayerOwned(item, playerId, objects); - } - - private static bool ValidAmount(ClientObject item, uint amount) => - amount == 0u || amount <= (uint)Math.Max(1, item.StackSize); - - // ── ILootAutomation ────────────────────────────────────────────────── - bool ILootAutomation.IsAvailable - { - get - { - lock (_gate) - return !_disposed && _useItem is not null - && _pickupItem is not null - && _identifyItem is not null - && IsAvailable; - } - } - - bool ILootAutomation.IsBusy - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime is not null - && !runtime.InventoryOwner.Transactions.CanBeginRequest; - } - } - - uint ILootAutomation.RequestedContainerId - { - get - { - lock (_gate) - return _runtime?.InventoryOwner.ExternalContainers - .RequestedContainerId ?? 0u; - } - } - - uint ILootAutomation.CurrentContainerId - { - get - { - lock (_gate) - return _runtime?.InventoryOwner.ExternalContainers - .CurrentContainerId ?? 0u; - } - } - - PluginItemUseCompletion ILootAutomation.LastItemUseCompletion => - ((IItemAutomation)this).LastCompletion; - - PluginInventoryCompletion ILootAutomation.LastInventoryCompletion - { - get - { - lock (_gate) - return _lastInventoryCompletion; - } - } - - PluginAppraisalState ILootAutomation.Appraisal - { - get - { - lock (_gate) - { - RuntimeInteractionTransactionState? transactions = - _runtime?.ActionOwner.Transactions; - return transactions is null - ? default - : new PluginAppraisalState( - transactions.Revision, - transactions.AwaitingAppraisalId, - transactions.CurrentAppraisalId); - } - } - } - - public IReadOnlyList CaptureCorpses( - float maximumDistance) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable - || float.IsNaN(maximumDistance) - || maximumDistance <= 0f) - { - return Array.Empty(); - } - - ExternalContainerState external = - runtime.InventoryOwner.ExternalContainers; - var result = new List(); - foreach (ClientObject candidate in runtime.InventoryOwner.Objects.Objects) - { - if (((PublicWeenieFlags)(candidate.PublicWeenieBitfield ?? 0u) - & PublicWeenieFlags.Corpse) == 0 - || candidate.ContainerId != 0u - || !RuntimeFriendlyTargetQuery.TryGetDistance( - runtime, - candidate.ObjectId, - out float distance) - || distance > maximumDistance) - { - continue; - } - - result.Add(new PluginLootContainer( - candidate.ObjectId, - candidate.WeenieClassId, - candidate.GetAppropriateName(), - distance, - external.HasCorpseBeenOpened(candidate.ObjectId), - external.RequestedContainerId == candidate.ObjectId, - external.CurrentContainerId == candidate.ObjectId) - { - LongDescription = candidate.Properties.GetString( - (uint)PropertyString.LongDesc), - IsGeneratedRare = candidate.Properties.GetBool( - (uint)PropertyBool.CorpseGeneratedRare), - IsIdentified = candidate.Properties.Strings.ContainsKey( - (uint)PropertyString.LongDesc), - }); - } - result.Sort(static (left, right) => - { - int distance = left.Distance.CompareTo(right.Distance); - return distance != 0 - ? distance - : left.ObjectId.CompareTo(right.ObjectId); - }); - return result.Count == 0 - ? Array.Empty() - : result.ToArray(); - } - - public IReadOnlyList CaptureCurrentContents() - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return Array.Empty(); - - uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; - if (root == 0u) - return Array.Empty(); - - ClientObjectTable objects = runtime.InventoryOwner.Objects; - var result = new List(); - var visited = new HashSet { root }; - CaptureContainerTree(runtime, objects, root, visited, result); - return result.Count == 0 - ? Array.Empty() - : result.ToArray(); - } - - bool ILootAutomation.TryCaptureProperties( - uint objectId, - out PluginItemProperties properties) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - { - properties = default; - return false; - } - - uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; - ClientObjectTable objects = runtime.InventoryOwner.Objects; - if (root == 0u - || !CaptureContainerIds(objects, root).Contains(objectId) - || objects.Get(objectId) is not { } item) - { - properties = default; - return false; - } - properties = CaptureProperties(item.Properties); - return true; - } - - public PluginItemCommandResult Open(uint containerObjectId) - { - GameRuntime? runtime; - Func? use; - lock (_gate) - { - runtime = _runtime; - use = _useItem; - } - if (runtime is null || use is null || !IsAvailable) - return new(PluginItemCommandStatus.Unavailable); - if (containerObjectId == 0u - || runtime.InventoryOwner.Objects.Get(containerObjectId) - is not { } container - || ((PublicWeenieFlags)(container.PublicWeenieBitfield ?? 0u) - & (PublicWeenieFlags.Corpse | PublicWeenieFlags.Openable)) == 0) - { - return new(PluginItemCommandStatus.InvalidTarget); - } - if (!runtime.InventoryOwner.Transactions.CanBeginRequest) - return new(PluginItemCommandStatus.Busy); - return use(containerObjectId) - ? new(PluginItemCommandStatus.Started) - : new(PluginItemCommandStatus.Refused); - } - - public PluginItemCommandResult Identify(uint objectId) - { - GameRuntime? runtime; - Func? identify; - lock (_gate) - { - runtime = _runtime; - identify = _identifyItem; - } - if (runtime is null || identify is null || !IsAvailable) - return new(PluginItemCommandStatus.Unavailable); - uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; - ClientObjectTable objects = runtime.InventoryOwner.Objects; - ClientObject? item = objectId == 0u ? null : objects.Get(objectId); - bool corpse = item is not null - && ((PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u) - & PublicWeenieFlags.Corpse) != 0; - bool currentContent = root != 0u - && CaptureContainerIds(objects, root).Contains(objectId); - if (item is null || (!corpse && !currentContent)) - { - return new(PluginItemCommandStatus.InvalidItem); - } - if (!runtime.InventoryOwner.Transactions.CanBeginRequest) - return new(PluginItemCommandStatus.Busy); - return identify(objectId) - ? new(PluginItemCommandStatus.Started) - : new(PluginItemCommandStatus.Refused); - } - - public PluginItemCommandResult Pickup(uint objectId, bool mainPack = false) - { - GameRuntime? runtime; - Func? pickup; - lock (_gate) - { - runtime = _runtime; - pickup = _pickupItem; - } - if (runtime is null || pickup is null || !IsAvailable) - return new(PluginItemCommandStatus.Unavailable); - uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; - ClientObjectTable objects = runtime.InventoryOwner.Objects; - if (objectId == 0u - || root == 0u - || objects.Get(objectId) is null - || !CaptureContainerIds(objects, root).Contains(objectId)) - { - return new(PluginItemCommandStatus.InvalidItem); - } - if (!runtime.InventoryOwner.Transactions.CanBeginRequest) - return new(PluginItemCommandStatus.Busy); - return pickup(objectId, mainPack) - ? new(PluginItemCommandStatus.Started) - : new(PluginItemCommandStatus.Refused); - } - - private static HashSet CaptureContainerIds( - ClientObjectTable objects, - uint root) - { - var result = new HashSet(); - var pending = new Stack(); - pending.Push(root); - while (pending.Count != 0) - { - uint containerId = pending.Pop(); - foreach (uint childId in objects.GetContents(containerId)) - { - if (!result.Add(childId)) - continue; - if (objects.Get(childId) is { } child - && (child.ItemsCapacity != 0 - || child.ContainersCapacity != 0 - || (child.Type & ItemType.Container) != 0)) - { - pending.Push(childId); - } - } - } - return result; - } - - private void CaptureContainerTree( - GameRuntime runtime, - ClientObjectTable objects, - uint containerId, - HashSet visited, - List result) - { - foreach (uint childId in objects.GetContents(containerId)) - { - if (!visited.Add(childId) - || objects.Get(childId) is not { } child) - { - continue; - } - result.Add(ProjectInventoryItem(runtime, child)); - if (child.ItemsCapacity != 0 - || child.ContainersCapacity != 0 - || (child.Type & ItemType.Container) != 0) - { - CaptureContainerTree(runtime, objects, childId, visited, result); - } - } - } - - private static PluginItemProperties CaptureProperties(PropertyBundle source) => - new( - new Dictionary(source.Ints), - new Dictionary(source.Int64s), - new Dictionary(source.Bools), - new Dictionary(source.Floats), - new Dictionary(source.Strings), - new Dictionary(source.DataIds), - new Dictionary(source.InstanceIds)); - - private PluginInventoryItem ProjectInventoryItem( - GameRuntime runtime, - ClientObject item) => - new( - item.ObjectId, - item.WeenieClassId, - item.GetAppropriateName(), - (uint)item.Type, - item.ContainerId, - item.WielderId, - (uint)item.ValidLocations, - (uint)item.CurrentlyEquippedLocation, - item.Useability ?? 0u, - item.TargetType ?? 0u, - item.PublicWeenieBitfield ?? 0u, - item.StackSize, - item.Structure, - item.MaxStructure, - item.SpellId - ?? (item.Properties.DataIds.TryGetValue( - (uint)PropertyDataId.Spell, - out uint itemSpell) ? itemSpell : 0u), - item.Properties.GetInt((uint)PropertyInt.PetClass), - item.Properties.GetInt((uint)PropertyInt.SummoningMastery), - item.Properties.DataIds.TryGetValue( - (uint)PropertyDataId.ProcSpell, - out uint procSpell) ? procSpell : 0u, - item.Properties.GetBool((uint)PropertyBool.ProcSpellSelfTargeted), - item.Properties.GetFloat((uint)PropertyFloat.ProcSpellRate), - item.Properties.GetInt((uint)PropertyInt.WeaponSkill), - item.Properties.GetInt((uint)PropertyInt.DamageType), - item.Properties.GetInt((uint)PropertyInt.Damage), - item.Properties.GetFloat((uint)PropertyFloat.DamageVariance), - item.Properties.GetInt((uint)PropertyInt.UseRequiresSkill), - item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillLevel), - item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillSpec)) - { - CombatUse = item.CombatUse ?? 0, - ItemSpellcraft = item.Properties.GetInt( - (uint)PropertyInt.ItemSpellcraft), - WieldRequirements = item.Properties.GetInt( - (uint)PropertyInt.WieldRequirements), - WieldSkillType = item.Properties.GetInt( - (uint)PropertyInt.WieldSkilltype), - WieldDifficulty = item.Properties.GetInt( - (uint)PropertyInt.WieldDifficulty), - AttackType = item.Properties.GetInt((uint)PropertyInt.AttackType), - WeaponType = item.Properties.GetInt((uint)PropertyInt.WeaponType), - BoosterVital = item.Properties.GetInt((uint)PropertyInt.BoosterEnum), - BoostValue = item.Properties.GetInt((uint)PropertyInt.BoostValue), - HealKitModifier = item.Properties.GetFloat( - (uint)PropertyFloat.HealkitMod), - AppraisedSpellIds = item.AppraisedSpellIds.Count == 0 - ? Array.Empty() - : item.AppraisedSpellIds.ToArray(), - GearDamage = item.Properties.GetInt((uint)PropertyInt.GearDamage), - GearDamageResistance = item.Properties.GetInt( - (uint)PropertyInt.GearDamageResist), - GearCriticalChance = item.Properties.GetInt( - (uint)PropertyInt.GearCrit), - GearCriticalResistance = item.Properties.GetInt( - (uint)PropertyInt.GearCritResist), - GearCriticalDamage = item.Properties.GetInt( - (uint)PropertyInt.GearCritDamage), - GearCriticalDamageResistance = item.Properties.GetInt( - (uint)PropertyInt.GearCritDamageResist), - MaximumStackSize = item.StackSizeMax, - ContainerSlot = item.ContainerSlot, - ItemsCapacity = item.ItemsCapacity, - ContainersCapacity = item.ContainersCapacity, - Burden = item.Burden, - Value = item.Value, - ItemCurrentMana = item.Properties.GetInt((uint)PropertyInt.ItemCurMana), - ItemMaximumMana = item.Properties.GetInt((uint)PropertyInt.ItemMaxMana), - Workmanship = item.Workmanship, - MaterialType = item.MaterialType ?? 0u, - ObjectClass = ClassifyObject(item), - Palettes = ProjectPalettes(runtime, item.ObjectId), - }; - - private IReadOnlyList ProjectPalettes( - GameRuntime runtime, - uint objectId) - { - IChargenPaletteColorSource? colors; - lock (_gate) - colors = _paletteColors; - if (colors is null - || !runtime.EntityObjects.Entities.TryGetActive( - objectId, - out RuntimeEntityRecord record) - || record.Snapshot.SubPalettes.Count == 0) - { - return Array.Empty(); - } - - var result = new PluginPaletteInfo[record.Snapshot.SubPalettes.Count]; - for (int index = 0; index < result.Length; index++) - { - var palette = record.Snapshot.SubPalettes[index]; - int sampleIndex = (palette.Length * 16) + (palette.Offset * 32) + 8; - _ = colors.TryGetColor( - palette.SubPaletteId, - sampleIndex, - out var rgb); - result[index] = new PluginPaletteInfo( - palette.SubPaletteId, - palette.Offset, - palette.Length, - rgb.R, - rgb.G, - rgb.B); - } - return result; - } - - // ── IFellowshipAutomation ──────────────────────────────────────────── - public bool IsInFellowship - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime?.Fellowship.Snapshot.IsInFellowship == true; - } - } - - public string FellowshipName - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime?.Fellowship.Snapshot.Name ?? string.Empty; - } - } - - string IFellowshipAutomation.Name => FellowshipName; - - public uint LeaderObjectId - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime?.Fellowship.Snapshot.LeaderGuid ?? 0u; - } - } - - public bool IsOpen - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime?.Fellowship.Snapshot.IsOpen == true; - } - } - - public bool IsLocked - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime?.Fellowship.Snapshot.Locked == true; - } - } - - public int MemberCount - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - return runtime?.Fellowship.Snapshot.MemberCount ?? 0; - } - } - - public IReadOnlyList CaptureMembers() - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable - || !runtime.Fellowship.Snapshot.IsInFellowship) - { - return Array.Empty(); - } - - uint self = runtime.PlayerIdentity.ServerGuid; - var result = new List(); - foreach (RuntimeFellowMemberSnapshot member - in runtime.Fellowship.GetMembers()) - { - if (member.Guid == self - || !RuntimeFriendlyTargetQuery.TryGetDistance( - runtime, - member.Guid, - out float distance)) - { - continue; - } - result.Add(new PluginFellowMember( - member.Guid, - member.Name, - member.CurrentHealth, - member.MaxHealth, - member.CurrentStamina, - member.MaxStamina, - member.CurrentMana, - member.MaxMana, - distance) - { - ShareLoot = member.ShareLoot, - }); - } - return result.Count == 0 - ? Array.Empty() - : result.ToArray(); - } - - public IReadOnlyList CaptureRoster() => - CaptureFellowshipMembers(includeSelf: true); - - public PluginFellowshipCommandResult Create( - string name, - bool shareExperience) => InvokeFellowship(commands => - commands.FellowshipCommands.Create( - commands.Generation, - name, - shareExperience)); - - public PluginFellowshipCommandResult Recruit(uint targetObjectId) => - InvokeFellowship(commands => commands.FellowshipCommands.Recruit( - commands.Generation, - targetObjectId)); - - public PluginFellowshipCommandResult Dismiss(uint targetObjectId) => - InvokeFellowship(commands => commands.FellowshipCommands.Dismiss( - commands.Generation, - targetObjectId)); - - public PluginFellowshipCommandResult Quit(bool disband) => - InvokeFellowship(commands => commands.FellowshipCommands.Quit( - commands.Generation, - disband)); - - public PluginFellowshipCommandResult AssignLeader(uint targetObjectId) => - InvokeFellowship(commands => commands.FellowshipCommands.AssignLeader( - commands.Generation, - targetObjectId)); - - public PluginFellowshipCommandResult SetOpen(bool isOpen) => - InvokeFellowship(commands => commands.FellowshipCommands.SetOpen( - commands.Generation, - isOpen)); - - private PluginFellowshipCommandResult InvokeFellowship( - Func invoke) - { - CurrentGameRuntimeAdapter? commands; - lock (_gate) - commands = _sessionCommands; - if (commands is null || !IsAvailable) - return new(PluginFellowshipCommandStatus.Unavailable); - RuntimeCommandResult result = invoke(commands); - return new(result.Status switch - { - RuntimeCommandStatus.Accepted => PluginFellowshipCommandStatus.Accepted, - RuntimeCommandStatus.Rejected => PluginFellowshipCommandStatus.Rejected, - _ => PluginFellowshipCommandStatus.Unavailable, - }); - } - - private IReadOnlyList CaptureFellowshipMembers(bool includeSelf) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable - || !runtime.Fellowship.Snapshot.IsInFellowship) - { - return Array.Empty(); - } - - uint self = runtime.PlayerIdentity.ServerGuid; - var result = new List(); - foreach (RuntimeFellowMemberSnapshot member in runtime.Fellowship.GetMembers()) - { - if (!includeSelf && member.Guid == self) - continue; - float distance = 0f; - if (member.Guid != self - && !RuntimeFriendlyTargetQuery.TryGetDistance( - runtime, - member.Guid, - out distance)) - { - continue; - } - result.Add(new PluginFellowMember( - member.Guid, - member.Name, - member.CurrentHealth, - member.MaxHealth, - member.CurrentStamina, - member.MaxStamina, - member.CurrentMana, - member.MaxMana, - distance) - { - ShareLoot = member.ShareLoot, - }); - } - return result.Count == 0 - ? Array.Empty() - : result.ToArray(); - } - - // ── IEnchantmentAutomation ────────────────────────────────────────── - public IReadOnlyList Capture(uint targetObjectId) - { - if (targetObjectId == 0u) - return Array.Empty(); - ObserveSuccessfulLocalCast(); - DateTimeOffset now = DateTimeOffset.UtcNow; - lock (_gate) - { - PruneTrackedEnchantments(now); - PluginTrackedEnchantment[] result = _trackedEnchantments - .Where(pair => pair.Key.Target == targetObjectId) - .Select(pair => new PluginTrackedEnchantment( - pair.Key.Target, - pair.Key.Spell, - pair.Value.Family, - pair.Value.Quality, - pair.Value.IsUntargeted, - Math.Max(0d, (pair.Value.ExpiresAt - now).TotalSeconds))) - .OrderBy(static entry => entry.Family) - .ThenBy(static entry => entry.SpellId) - .ToArray(); - return result.Length == 0 - ? Array.Empty() - : result; - } - } - - public bool ReportCast( - uint targetObjectId, - uint spellId, - double durationSeconds) - { - if (targetObjectId == 0u - || spellId == 0u - || !double.IsFinite(durationSeconds) - || durationSeconds <= 0d) - { - return false; - } - - lock (_gate) - { - if (_disposed - || _spellbook is null - || !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata)) - { - return false; - } - TrackEnchantment( - targetObjectId, - metadata, - durationSeconds, - DateTimeOffset.UtcNow); - return true; - } - } - - private void ObserveSuccessfulLocalCast() - { - lock (_gate) - { - RuntimeSpellCastCompletion completion = - _cast?.LastCompletion ?? default; - if (completion.Revision == 0 - || completion.Revision <= _trackedCastCompletionRevision) - { - return; - } - _trackedCastCompletionRevision = completion.Revision; - if (!completion.IsSuccess - || completion.TargetObjectId == 0u - || _spellbook is null - || !_spellbook.TryGetMetadata( - completion.SpellId, - out SpellMetadata metadata) - || metadata.Duration <= 0f) - { - return; - } - TrackEnchantment( - completion.TargetObjectId, - metadata, - metadata.Duration, - DateTimeOffset.UtcNow); - } - } - - private void TrackEnchantment( - uint targetObjectId, - SpellMetadata metadata, - double durationSeconds, - DateTimeOffset now) - { - var tracked = new TrackedEnchantment( - metadata.Family, - metadata.Difficulty, - metadata.IsUntargeted, - now.AddSeconds(durationSeconds)); - (uint Target, uint Spell) key = (targetObjectId, metadata.SpellId); - if (!_trackedEnchantments.TryGetValue(key, out TrackedEnchantment old) - || tracked.ExpiresAt > old.ExpiresAt) - { - _trackedEnchantments[key] = tracked; - } - } - - private void PruneTrackedEnchantments(DateTimeOffset now) - { - foreach ((uint Target, uint Spell) key in - _trackedEnchantments - .Where(pair => pair.Value.ExpiresAt <= now) - .Select(static pair => pair.Key) - .ToArray()) - { - _trackedEnchantments.Remove(key); - } - } - - // ── ICombatAutomation ──────────────────────────────────────────────── - public PluginCombatSnapshot Snapshot - { - get - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return default; - - RuntimeActionSnapshot action = runtime.ActionOwner.View.Snapshot; - RuntimeCombatAttackSnapshot attack = action.CombatAttack; - return new PluginCombatSnapshot( - action.SelectedObjectId, - Project(action.CombatMode), - Project(attack.RequestedHeight), - attack.DesiredPower, - attack.PowerBarLevel, - attack.BuildInProgress, - attack.RequestInProgress, - attack.ServerResponsePending, - attack.RepeatAttackInProgress) - { - CompletionRevision = attack.CompletionRevision, - CompletionSequence = attack.CompletionSequence, - CompletionWeenieError = attack.CompletionWeenieError, - }; - } - } - - public IReadOnlyList CaptureHostileTargets( - float maximumDistance) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return Array.Empty(); - - IReadOnlyList captured = - RuntimeHostileTargetQuery.Capture(runtime, maximumDistance); - if (captured.Count == 0) - return Array.Empty(); - - var projected = new PluginCombatTarget[captured.Count]; - Func speciesName; - lock (_gate) - speciesName = _speciesName; - for (int i = 0; i < captured.Count; i++) - { - RuntimeHostileTargetSnapshot target = captured[i]; - projected[i] = new PluginCombatTarget( - target.ObjectId, - target.Name, - target.WeenieClassId, - target.Distance, - target.RelativeAngleDegrees, - target.IsHealthKnown, - target.HealthFraction) - { - SpeciesId = target.SpeciesId, - SpeciesName = speciesName(target.SpeciesId), - MaximumHealth = target.MaximumHealth, - HasShield = target.HasShield, - Incarnation = target.Incarnation, - HealthRevision = target.HealthRevision, - SecondsSinceHealthUpdate = target.SecondsSinceHealthUpdate, - }; - } - return projected; - } - - public PluginCombatCommandResult EnterDefaultMode() - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return new(PluginCombatCommandStatus.Unavailable); - if (runtime.ActionOwner.Combat.CurrentMode != CombatMode.NonCombat) - return new(PluginCombatCommandStatus.AlreadyReady); - - RuntimeCombatModeRequestResult result = runtime.ActionOwner.CombatMode.Toggle(); - return result.Status switch - { - RuntimeCombatModeRequestStatus.Sent => new( - PluginCombatCommandStatus.ModeChangeSent), - RuntimeCombatModeRequestStatus.Rejected => new( - PluginCombatCommandStatus.Refused, result.Notice), - _ => new(PluginCombatCommandStatus.Unavailable, result.Notice), - }; - } - - public PluginCombatCommandResult EnterMode(PluginCombatMode mode) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return new(PluginCombatCommandStatus.Unavailable); - CombatMode requested = mode switch - { - PluginCombatMode.Peace => CombatMode.NonCombat, - PluginCombatMode.Melee => CombatMode.Melee, - PluginCombatMode.Missile => CombatMode.Missile, - PluginCombatMode.Magic => CombatMode.Magic, - _ => (CombatMode)(-1), - }; - if ((int)requested < 0) - return new(PluginCombatCommandStatus.Refused, "Invalid combat mode."); - if (runtime.ActionOwner.Combat.CurrentMode == requested) - return new(PluginCombatCommandStatus.AlreadyReady); - - RuntimeCombatModeRequestResult result = - runtime.ActionOwner.CombatMode.Request(requested); - return result.Status switch - { - RuntimeCombatModeRequestStatus.Sent => new( - PluginCombatCommandStatus.ModeChangeSent), - RuntimeCombatModeRequestStatus.Rejected => new( - PluginCombatCommandStatus.Refused, result.Notice), - _ => new(PluginCombatCommandStatus.Unavailable, result.Notice), - }; - } - - public PluginCombatCommandResult DismissGhostTarget(uint targetObjectId) - { - Func? dismiss; - lock (_gate) - dismiss = _dismissGhost; - if (dismiss is null || !IsAvailable) - return new(PluginCombatCommandStatus.Unavailable); - return dismiss(targetObjectId) - ? new(PluginCombatCommandStatus.Stopped) - : new(PluginCombatCommandStatus.InvalidTarget); - } - - public PluginCombatCommandResult BeginPhysicalAttack( - uint targetObjectId, - PluginAttackHeight height, - float power) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return new(PluginCombatCommandStatus.Unavailable); - if (!RuntimeHostileTargetQuery.IsHostile(runtime, targetObjectId)) - return new(PluginCombatCommandStatus.InvalidTarget); - if (!CombatInputPlanner.SupportsTargetedAttack( - runtime.ActionOwner.Combat.CurrentMode)) - { - return new(PluginCombatCommandStatus.WrongMode); - } - - RuntimeCombatAttackState attack = runtime.ActionOwner.CombatAttack; - if (attack.AttackRequestInProgress - || attack.AttackServerResponsePending - || attack.RepeatAttackInProgress) - { - return new(PluginCombatCommandStatus.Busy); - } - - runtime.ActionOwner.Selection.Select( - targetObjectId, - SelectionChangeSource.Plugin); - attack.SetDesiredPower(Math.Clamp(power, 0f, 1f)); - attack.PressAttack(Project(height)); - return attack.AttackRequestInProgress - ? new(PluginCombatCommandStatus.Started) - : new(PluginCombatCommandStatus.Refused); - } - - public PluginCombatCommandResult ReleasePhysicalAttack() - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable) - return new(PluginCombatCommandStatus.Unavailable); - - RuntimeCombatAttackState attack = runtime.ActionOwner.CombatAttack; - if (!attack.AttackRequestInProgress) - return new(PluginCombatCommandStatus.Refused); - attack.ReleaseAttack(); - return new(PluginCombatCommandStatus.Released); - } - - public PluginCombatCommandResult AbortPhysicalAttack() - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null) - return new(PluginCombatCommandStatus.Unavailable); - runtime.ActionOwner.CombatAttack.AbortAutomaticAttack(); - return new(PluginCombatCommandStatus.Stopped); - } - - private bool SelectExplicitTarget(uint targetObjectId) - { - GameRuntime? runtime; - lock (_gate) - runtime = _runtime; - if (runtime is null || !IsAvailable || targetObjectId == 0u - || !runtime.EntityObjects.Entities.TryGetActive(targetObjectId, out _)) - { - return false; - } - runtime.ActionOwner.Selection.Select( - targetObjectId, - SelectionChangeSource.Plugin); - return true; - } - - private static PluginCombatMode Project(CombatMode mode) => mode switch - { - CombatMode.NonCombat => PluginCombatMode.Peace, - CombatMode.Melee => PluginCombatMode.Melee, - CombatMode.Missile => PluginCombatMode.Missile, - CombatMode.Magic => PluginCombatMode.Magic, - _ => PluginCombatMode.Unknown, - }; - - private static PluginAttackHeight Project(AttackHeight height) => height switch - { - AttackHeight.High => PluginAttackHeight.High, - AttackHeight.Low => PluginAttackHeight.Low, - _ => PluginAttackHeight.Medium, - }; - - private static AttackHeight Project(PluginAttackHeight height) => height switch - { - PluginAttackHeight.High => AttackHeight.High, - PluginAttackHeight.Low => AttackHeight.Low, - _ => AttackHeight.Medium, - }; - - private readonly record struct TrackedEnchantment( - uint Family, - int Quality, - bool IsUntargeted, - DateTimeOffset ExpiresAt); - public void Dispose() { - if (_events is not null) - _events.Tick -= OnPeerTick; lock (_gate) { if (_disposed) return; _disposed = true; - _equip = null; - _equipmentBusy = null; - _useItem = null; - _applyItem = null; - _moveItem = null; - _mergeItems = null; - _dropItem = null; - _giveItem = null; - _pickupItem = null; - _identifyItem = null; - _salvageItems = null; - _sellItem = null; - _selectionAction = null; DetachLocked(); } _knownSelfBuffs = Array.Empty(); - _knownAttackSpells = Array.Empty(); - _knownCombatSpells = Array.Empty(); _enchantments = Array.Empty(); - _peers.Dispose(); } } diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index 06cf8f02..ad5fe525 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -10,10 +10,7 @@ public sealed class AppPluginHost : IPluginHost IEvents events, ISelectionService selection, IUiRegistry ui, - IAutomationSurface automation, - IPluginStorage? storage = null, - IPluginCommandRegistry? commands = null, - IPluginLootClassifierRegistry? lootClassifiers = null) + IAutomationSurface automation) { Log = log; State = state; @@ -21,10 +18,6 @@ public sealed class AppPluginHost : IPluginHost Selection = selection; Ui = ui; Automation = automation; - Storage = storage ?? NoOpPluginStorage.Instance; - Commands = commands ?? NoOpPluginCommandRegistry.Instance; - LootClassifiers = lootClassifiers - ?? NoOpPluginLootClassifierRegistry.Instance; } public bool HasUi => true; @@ -34,7 +27,4 @@ public sealed class AppPluginHost : IPluginHost public ISelectionService Selection { get; } public IUiRegistry Ui { get; } public IAutomationSurface Automation { get; } - public IPluginStorage Storage { get; } - public IPluginCommandRegistry Commands { get; } - public IPluginLootClassifierRegistry LootClassifiers { get; } } diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs index 3ec34095..dc3c565e 100644 --- a/src/AcDream.App/Plugins/BufferedUiRegistry.cs +++ b/src/AcDream.App/Plugins/BufferedUiRegistry.cs @@ -11,36 +11,18 @@ namespace AcDream.App.Plugins; /// public sealed class BufferedUiRegistry : IScopedUiRegistry { - public readonly record struct Pending( - PluginUiOwner Owner, - PluginPanelDescriptor Descriptor, - string MarkupPath, - object Binding) + public readonly record struct Pending(string MarkupPath, object Binding) { internal long RegistrationId { get; init; } - internal string? MarkupContent { get; init; } - - /// Stable, manifest-scoped retained-window persistence key. - public string WindowName => - $"plugin:{Owner.Id}:{Descriptor.WindowId}"; } - private sealed class Registration( - PluginUiOwner owner, - PluginPanelDescriptor descriptor, - string markupPath, - object binding, - string? markupContent = null) + private sealed class Registration(string markupPath, object binding) { - internal PluginUiOwner Owner { get; } = owner; - internal PluginPanelDescriptor Descriptor { get; } = descriptor; internal string MarkupPath { get; } = markupPath; internal object Binding { get; } = binding; - internal string? MarkupContent { get; } = markupContent; internal bool Drained { get; set; } internal UiRoot? Root { get; set; } internal UiElement? Element { get; set; } - internal Action? WindowCleanup { get; set; } } private readonly object _gate = new(); @@ -50,114 +32,15 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry public void AddMarkupPanel(string markupPath, object binding) => _ = RegisterMarkupPanel(markupPath, binding); - public void AddPanel( - PluginPanelDescriptor descriptor, - string markupPath, - object binding) - => _ = RegisterPanel( - new PluginUiOwner("unscoped", descriptor.Title), - descriptor, - markupPath, - binding); - - public IDisposable RegisterPanel( - PluginPanelDescriptor descriptor, - string markupPath, - object binding) => RegisterPanel( - new PluginUiOwner("unscoped", descriptor.Title), - descriptor, - markupPath, - binding); - - public IDisposable RegisterPanelContent( - PluginPanelDescriptor descriptor, - string markupContent, - object binding) => RegisterPanelContent( - new PluginUiOwner("unscoped", descriptor.Title), - descriptor, - markupContent, - binding); - - public bool ViewExists(string viewName) => - ViewExists(new PluginUiOwner("unscoped", "Plugin"), viewName); - - public bool IsViewVisible(string viewName) => - IsViewVisible(new PluginUiOwner("unscoped", "Plugin"), viewName); - - public bool ControlExists(string viewName, string controlName) => - ControlExists( - new PluginUiOwner("unscoped", "Plugin"), viewName, controlName); - - public bool SetControlLabel( - string viewName, - string controlName, - string label) => SetControlLabel( - new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, label); - - public bool SetControlVisible( - string viewName, - string controlName, - bool visible) => SetControlVisible( - new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, visible); - public IDisposable RegisterMarkupPanel(string markupPath, object binding) - => RegisterPanel( - new PluginUiOwner("legacy", "Plugin"), - new PluginPanelDescriptor( - Path.GetFileNameWithoutExtension(markupPath), - Path.GetFileNameWithoutExtension(markupPath)), - markupPath, - binding); - - public IDisposable RegisterPanel( - PluginUiOwner owner, - PluginPanelDescriptor descriptor, - string markupPath, - object binding) { - ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id); - ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName); - ArgumentNullException.ThrowIfNull(descriptor); - ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId); - ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title); ArgumentException.ThrowIfNullOrWhiteSpace(markupPath); ArgumentNullException.ThrowIfNull(binding); long id; lock (_gate) { id = checked(++_nextRegistrationId); - _registrations.Add( - id, - new Registration(owner, descriptor, markupPath, binding)); - } - return new RegistrationToken(this, id); - } - - public IDisposable RegisterPanelContent( - PluginUiOwner owner, - PluginPanelDescriptor descriptor, - string markupContent, - object binding) - { - ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id); - ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName); - ArgumentNullException.ThrowIfNull(descriptor); - ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId); - ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title); - ArgumentException.ThrowIfNullOrWhiteSpace(markupContent); - ArgumentNullException.ThrowIfNull(binding); - long id; - lock (_gate) - { - id = checked(++_nextRegistrationId); - _registrations.Add( - id, - new Registration( - owner, - descriptor, - $"", - binding, - markupContent)); + _registrations.Add(id, new Registration(markupPath, binding)); } return new RegistrationToken(this, id); } @@ -174,13 +57,10 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry continue; registration.Drained = true; pending.Add(new Pending( - registration.Owner, - registration.Descriptor, registration.MarkupPath, registration.Binding) { RegistrationId = id, - MarkupContent = registration.MarkupContent, }); } return pending; @@ -208,27 +88,6 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry root.RemoveChild(element); } - /// - /// Publishes the window-manager half of a mounted registration. Disposal - /// may race between retained-tree mount and window registration, so a late - /// publication cleans itself up immediately when ownership is already gone. - /// - internal void CompleteWindowMount(Pending pending, Action cleanup) - { - ArgumentNullException.ThrowIfNull(cleanup); - bool stillRegistered; - lock (_gate) - { - stillRegistered = _registrations.TryGetValue( - pending.RegistrationId, - out Registration? registration); - if (stillRegistered) - registration!.WindowCleanup = cleanup; - } - if (!stillRegistered) - cleanup(); - } - internal void FailMount(Pending pending) => Remove(pending.RegistrationId); internal int RegistrationCount @@ -240,114 +99,18 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry } } - public bool ViewExists(PluginUiOwner owner, string viewName) - { - lock (_gate) - return FindRegistrationLocked(owner, viewName) is not null; - } - - public bool IsViewVisible(PluginUiOwner owner, string viewName) - { - UiElement? view; - lock (_gate) - view = FindRegistrationLocked(owner, viewName)?.Element; - return view?.Visible == true; - } - - public bool ControlExists( - PluginUiOwner owner, - string viewName, - string controlName) => - FindControl(owner, viewName, controlName) is not null; - - public bool SetControlLabel( - PluginUiOwner owner, - string viewName, - string controlName, - string label) - { - UiElement? control = FindControl(owner, viewName, controlName); - switch (control) - { - case UiSimpleButton button: - button.TextSource = null; - button.Text = label; - return true; - case UiMarkupToggle toggle: - toggle.TextSource = null; - toggle.Text = label; - return true; - case UiLabel text: - text.TextSource = null; - text.Text = label; - return true; - default: - return false; - } - } - - public bool SetControlVisible( - PluginUiOwner owner, - string viewName, - string controlName, - bool visible) - { - UiElement? control = FindControl(owner, viewName, controlName); - if (control is null) - return false; - control.VisibleSource = null; - control.Visible = visible; - return true; - } - - private UiElement? FindControl( - PluginUiOwner owner, - string viewName, - string controlName) - { - UiElement? view; - lock (_gate) - view = FindRegistrationLocked(owner, viewName)?.Element; - return view is null ? null : FindByName(view, controlName); - } - - private Registration? FindRegistrationLocked( - PluginUiOwner owner, - string viewName) => _registrations.Values.FirstOrDefault(registration => - registration.Owner == owner - && (registration.Descriptor.WindowId.Equals( - viewName, StringComparison.Ordinal) - || registration.Descriptor.Title.Equals( - viewName, StringComparison.Ordinal))); - - private static UiElement? FindByName(UiElement root, string name) - { - if (root.Name?.Equals(name, StringComparison.Ordinal) == true) - return root; - foreach (UiElement child in root.Children) - { - UiElement? found = FindByName(child, name); - if (found is not null) - return found; - } - return null; - } - private void Remove(long id) { UiRoot? root; UiElement? element; - Action? windowCleanup; lock (_gate) { if (!_registrations.Remove(id, out Registration? registration)) return; root = registration.Root; element = registration.Element; - windowCleanup = registration.WindowCleanup; } - windowCleanup?.Invoke(); if (root is not null && element is not null) root.RemoveChild(element); } diff --git a/src/AcDream.App/Plugins/FilePluginStorage.cs b/src/AcDream.App/Plugins/FilePluginStorage.cs deleted file mode 100644 index 85a0c60a..00000000 --- a/src/AcDream.App/Plugins/FilePluginStorage.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Text; -using AcDream.Plugin.Abstractions; - -namespace AcDream.App.Plugins; - -/// Crash-safe filesystem implementation behind scoped plugin keys. -internal sealed class FilePluginStorage : IPluginStorage -{ - private readonly string _root; - - internal FilePluginStorage(string root) - { - ArgumentException.ThrowIfNullOrWhiteSpace(root); - _root = Path.GetFullPath(root); - } - - public bool IsAvailable => true; - - public string? ReadText(string key) - { - string path = Resolve(key); - return File.Exists(path) - ? File.ReadAllText(path, Encoding.UTF8) - : null; - } - - public IReadOnlyList List(string prefix) - { - string directory = Resolve(prefix); - if (!Directory.Exists(directory)) - return Array.Empty(); - return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) - .Select(path => Path.GetRelativePath(_root, path) - .Replace(Path.DirectorySeparatorChar, '/')) - .OrderBy(static key => key, StringComparer.OrdinalIgnoreCase) - .ToArray(); - } - - public void WriteText(string key, string content) - { - ArgumentNullException.ThrowIfNull(content); - string path = Resolve(key); - string directory = Path.GetDirectoryName(path)!; - Directory.CreateDirectory(directory); - string temporary = Path.Combine( - directory, - $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); - try - { - File.WriteAllText(temporary, content, new UTF8Encoding(false)); - File.Move(temporary, path, overwrite: true); - } - finally - { - if (File.Exists(temporary)) - File.Delete(temporary); - } - } - - public bool Delete(string key) - { - string path = Resolve(key); - if (!File.Exists(path)) - return false; - File.Delete(path); - return true; - } - - private string Resolve(string key) - { - ArgumentException.ThrowIfNullOrWhiteSpace(key); - if (Path.IsPathRooted(key)) - throw new ArgumentException("Plugin storage keys must be relative.", nameof(key)); - string path = Path.GetFullPath(Path.Combine(_root, key)); - string relative = Path.GetRelativePath(_root, path); - if (Path.IsPathRooted(relative) - || relative.Equals("..", StringComparison.Ordinal) - || relative.StartsWith( - ".." + Path.DirectorySeparatorChar, - StringComparison.Ordinal)) - { - throw new ArgumentException("Plugin storage key escapes its root.", nameof(key)); - } - return path; - } -} diff --git a/src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs b/src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs deleted file mode 100644 index 17c7b37e..00000000 --- a/src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System.Text.Json; -using AcDream.Plugin.Abstractions; - -namespace AcDream.App.Plugins; - -/// -/// Small cross-process peer roster for plugins. UtilityBelt used a local TCP -/// relay; acdream uses bounded heartbeat documents in the user's local app -/// data, which provides the same machine-local discovery without a privileged -/// daemon or a fixed port. The files carry data only—never commands. -/// -internal sealed class LocalPluginPeerRegistry : IDisposable -{ - internal static readonly TimeSpan StaleAfter = TimeSpan.FromSeconds(15); - private const long MaximumDocumentBytes = 64 * 1024; - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - }; - - private readonly string _directory; - private readonly string _path; - private readonly TimeProvider _time; - private readonly Guid _instanceId; - private bool _disposed; - - public LocalPluginPeerRegistry( - string directory, - TimeProvider? timeProvider = null, - Guid? instanceId = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(directory); - _directory = Path.GetFullPath(directory); - _time = timeProvider ?? TimeProvider.System; - _instanceId = instanceId ?? Guid.NewGuid(); - _path = Path.Combine(_directory, $"peer-{_instanceId:N}.json"); - ClientId = BitConverter.ToUInt32(_instanceId.ToByteArray(), 0); - if (ClientId == 0u) - ClientId = 1u; - } - - public uint ClientId { get; private set; } - - public void Publish(in PluginNetworkClient client) - { - ObjectDisposedException.ThrowIf(_disposed, this); - Directory.CreateDirectory(_directory); - var document = PeerDocument.From( - client with { ClientId = ClientId }, - _instanceId, - _time.GetUtcNow().ToUnixTimeMilliseconds()); - string temporary = _path + "." + Guid.NewGuid().ToString("N") + ".tmp"; - try - { - File.WriteAllText(temporary, JsonSerializer.Serialize(document, JsonOptions)); - File.Move(temporary, _path, overwrite: true); - } - finally - { - if (File.Exists(temporary)) - File.Delete(temporary); - } - } - - public IReadOnlyList CaptureRemoteClients() - { - ObjectDisposedException.ThrowIf(_disposed, this); - if (!Directory.Exists(_directory)) - return Array.Empty(); - long newestAllowed = _time.GetUtcNow().Subtract(StaleAfter) - .ToUnixTimeMilliseconds(); - var result = new List(); - foreach (string file in Directory.EnumerateFiles( - _directory, - "peer-*.json", - SearchOption.TopDirectoryOnly)) - { - if (file.Equals(_path, StringComparison.OrdinalIgnoreCase)) - continue; - try - { - var info = new FileInfo(file); - if (info.Length is <= 0 or > MaximumDocumentBytes) - continue; - PeerDocument? document = JsonSerializer.Deserialize( - File.ReadAllText(file), - JsonOptions); - if (document is null - || document.InstanceId == _instanceId - || document.UpdatedUnixMs < newestAllowed - || document.ClientId == 0u - || document.PlayerId == 0u - || string.IsNullOrWhiteSpace(document.Name) - || document.Name.Length > 128 - || document.WorldName is null - || document.WorldName.Length > 128 - || document.Tags is null - || document.Tags.Length > 128 - || !double.IsFinite(document.EastWest) - || !double.IsFinite(document.NorthSouth) - || !double.IsFinite(document.Elevation) - || !float.IsFinite(document.Heading)) - { - continue; - } - result.Add(document.ToClient()); - } - catch (IOException) - { - // A peer can atomically replace or remove its own heartbeat - // between enumeration and read. It will reappear next scan. - } - catch (UnauthorizedAccessException) - { - } - catch (JsonException) - { - } - } - return result - .OrderBy(static client => client.Name, StringComparer.OrdinalIgnoreCase) - .ThenBy(static client => client.ClientId) - .ToArray(); - } - - public void Withdraw() - { - if (_disposed) - return; - try - { - if (File.Exists(_path)) - File.Delete(_path); - } - catch (IOException) - { - } - catch (UnauthorizedAccessException) - { - } - } - - public void Dispose() - { - if (_disposed) - return; - Withdraw(); - _disposed = true; - } - - private sealed class PeerDocument - { - public Guid InstanceId { get; set; } - public long UpdatedUnixMs { get; set; } - public uint ClientId { get; set; } - public uint PlayerId { get; set; } - public string Name { get; set; } = string.Empty; - public string WorldName { get; set; } = string.Empty; - public string[] Tags { get; set; } = []; - public uint CellId { get; set; } - public double EastWest { get; set; } - public double NorthSouth { get; set; } - public double Elevation { get; set; } - public bool IsOutdoor { get; set; } - public float Heading { get; set; } - public uint CurrentHealth { get; set; } - public uint CurrentMana { get; set; } - public uint CurrentStamina { get; set; } - public uint MaxHealth { get; set; } - public uint MaxMana { get; set; } - public uint MaxStamina { get; set; } - - public static PeerDocument From( - in PluginNetworkClient client, - Guid instanceId, - long updatedUnixMs) => new() - { - InstanceId = instanceId, - UpdatedUnixMs = updatedUnixMs, - ClientId = client.ClientId, - PlayerId = client.PlayerId, - Name = client.Name, - WorldName = client.WorldName, - Tags = client.Tags - .Where(static tag => !string.IsNullOrWhiteSpace(tag)) - .Select(static tag => tag.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(128) - .ToArray(), - CellId = client.Position.CellId, - EastWest = client.Position.EastWest, - NorthSouth = client.Position.NorthSouth, - Elevation = client.Position.Elevation, - IsOutdoor = client.Position.IsOutdoor, - Heading = client.Heading, - CurrentHealth = client.CurrentHealth, - CurrentMana = client.CurrentMana, - CurrentStamina = client.CurrentStamina, - MaxHealth = client.MaxHealth, - MaxMana = client.MaxMana, - MaxStamina = client.MaxStamina, - }; - - public PluginNetworkClient ToClient() => new( - ClientId, - PlayerId, - Name, - WorldName, - new PluginNavigationPosition( - CellId, - EastWest, - NorthSouth, - Elevation, - Heading, - IsOutdoor), - Tags, - CurrentHealth, - CurrentMana, - CurrentStamina, - MaxHealth, - MaxMana, - MaxStamina, - Heading); - } -} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index b736280d..32c3fecf 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -160,13 +160,7 @@ using IDisposable atmosphericPackRegistration = renderPackRegistry.Register( "spv"))); // Constructed here and handed to both sides: GameWindow binds it to the live // session's Runtime owners, the plugin host exposes it to plugins. -using var automation = new AcDream.App.Plugins.AppAutomationSurface( - worldEvents, - new AcDream.App.Plugins.LocalPluginPeerRegistry(Path.Combine( - applicationPaths.DataDirectory, - "plugin-peers")), - runtimeOptions.PluginTags); -var lootClassifiers = new AcDream.Core.Plugins.PluginLootClassifierRegistry(); +using var automation = new AcDream.App.Plugins.AppAutomationSurface(); using var window = new GameWindow( runtimeOptions, worldGameState, @@ -181,11 +175,7 @@ var host = new AppPluginHost( worldEvents, window.Selection, uiRegistry, - automation, - new FilePluginStorage( - Path.Combine(applicationPaths.ConfigDirectory, "plugins")), - automation.PluginCommands, - lootClassifiers); + automation); GraphicalPluginSession pluginSession = GraphicalPluginSession.Create( applicationPaths, runtimeOptions.Plugins, diff --git a/src/AcDream.App/Rendering/CameraFrameController.cs b/src/AcDream.App/Rendering/CameraFrameController.cs index a0cb3202..ff56239d 100644 --- a/src/AcDream.App/Rendering/CameraFrameController.cs +++ b/src/AcDream.App/Rendering/CameraFrameController.cs @@ -85,28 +85,6 @@ internal sealed class CameraFrameController : ICameraFramePhase retail.AdjustPitch(+adjustment * 0.02f); if (input.Lower) retail.AdjustPitch(-adjustment * 0.02f); - if (input.RotateLeft) - retail.YawOffset += adjustment * 0.02f; - if (input.RotateRight) - retail.YawOffset -= adjustment * 0.02f; - } - else - { - ChaseCameraAdjustmentInput input = _input.CaptureChaseAdjustment(); - float adjustment = CameraDiagnostics.CameraAdjustmentSpeed - * timing.SimulationDeltaSecondsSingle; - if (input.ZoomIn) - legacy.AdjustDistance(-adjustment); - if (input.ZoomOut) - legacy.AdjustDistance(+adjustment); - if (input.Raise) - legacy.AdjustPitch(+adjustment * 0.02f); - if (input.Lower) - legacy.AdjustPitch(-adjustment * 0.02f); - if (input.RotateLeft) - legacy.YawOffset += adjustment * 0.02f; - if (input.RotateRight) - legacy.YawOffset -= adjustment * 0.02f; } if (!_localFrame.TryGetPresentationAfterNetwork(out var playerFrame)) @@ -116,23 +94,11 @@ internal sealed class CameraFrameController : ICameraFramePhase _spatialReconciler.Reconcile(); MovementResult result = playerFrame.Movement; - // #429 defect 2: the chase camera smooths toward the PRESENTED player - // position, which lives on the retail 30 Hz object clock (see - // PlayerMovementController.PresentedDeltaSeconds). Integrating the - // damping with wall dt made the camera step full wall time on long - // frames while the presented player under-advanced against the - // quantum — measured ~1 m of camera/player decoherence in one frame, - // the felt run-hitch. Retail ties the camera to the physics-update - // callback (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60), i.e. - // the same clock as the body; the presented delta restores that. - // Manual zoom/pitch adjustment above stays on wall dt — it is a - // user-input rate, not target chasing. - float cameraDt = controller.PresentedDeltaSeconds; legacy.Update( result.RenderPosition, controller.Yaw, isOnGround: result.IsOnGround, - dt: cameraDt); + dt: timing.SimulationDeltaSecondsSingle); retail?.Update( result.RenderPosition, @@ -140,7 +106,7 @@ internal sealed class CameraFrameController : ICameraFramePhase playerVelocity: controller.BodyVelocity, isOnGround: result.IsOnGround, contactPlaneNormal: controller.ContactPlane.Normal, - dt: cameraDt, + dt: timing.SimulationDeltaSecondsSingle, cellId: controller.CellId, selfEntityId: controller.LocalEntityId, trackedTargetPoint: _combatTarget.GetTrackedTargetPoint()); diff --git a/src/AcDream.App/Rendering/ChaseCamera.cs b/src/AcDream.App/Rendering/ChaseCamera.cs index f8cf01b4..10bf2111 100644 --- a/src/AcDream.App/Rendering/ChaseCamera.cs +++ b/src/AcDream.App/Rendering/ChaseCamera.cs @@ -10,21 +10,6 @@ namespace AcDream.App.Rendering; /// public sealed class ChaseCamera : ICamera { - private const float RetailDefaultBack = 2.5f; - private const float RetailDefaultUp = 0.75f; - private bool _lookingDown; - private bool _mapMode; - private bool _inHead; - private bool _savedInHead; - private float _savedDistance; - private float _savedPitch; - private float _savedYawOffset; - private Vector3? _targetDirectionLocal; - private Vector3? _savedTargetDirectionLocal; - - public bool IsLookingDown => _lookingDown; - public bool IsMapMode => _mapMode; - public bool IsInHead => _inHead; public Vector3 Position { get; private set; } public float Aspect { get; set; } = 16f / 9f; // #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView. @@ -123,35 +108,10 @@ public sealed class ChaseCamera : ICamera float horizontalDist = Distance * MathF.Cos(Pitch); float verticalDist = Distance * MathF.Sin(Pitch); - if (_inHead) - { - Vector3 forward = new(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f); - Position = new Vector3( - playerPosition.X, - playerPosition.Y, - _trackedZ + EyeHeight) + forward * 0.18f; - _lookAt = Position + forward; - } - else if (_targetDirectionLocal is { } localDirection) - { - Vector3 pivot = new(playerPosition.X, playerPosition.Y, _trackedZ + EyeHeight); - var directedPose = RetailChaseCamera.ComputeTargetDirectionPose( - pivot, - new Vector3(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f), - Distance, - Pitch, - localDirection); - Position = directedPose.eye; - Vector3 direction = directedPose.forward; - _lookAt = Position + direction; - } - else - { - Position = new Vector3( - playerPosition.X - forwardX * horizontalDist, - playerPosition.Y - forwardY * horizontalDist, - _trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne) - } + Position = new Vector3( + playerPosition.X - forwardX * horizontalDist, + playerPosition.Y - forwardY * horizontalDist, + _trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne) } /// @@ -159,8 +119,6 @@ public sealed class ChaseCamera : ICamera /// public void AdjustPitch(float delta) { - ExitLookDownForAdjustment(); - ExitInHeadForAdjustment(); Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); } @@ -169,101 +127,6 @@ public sealed class ChaseCamera : ICamera /// public void AdjustDistance(float delta) { - ExitLookDownForAdjustment(); - ExitInHeadForAdjustment(); Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax); } - - public void SetRetailDefaultView() - { - _lookingDown = false; - _mapMode = false; - _inHead = false; - _targetDirectionLocal = null; - YawOffset = 0f; - EyeHeight = 1.5f; - SetViewerOffset(RetailDefaultBack, RetailDefaultUp); - } - - public void SetRetailFirstPersonView() - { - _lookingDown = false; - _mapMode = false; - _inHead = true; - _targetDirectionLocal = null; - YawOffset = 0f; - Distance = 0.18f; - Pitch = 0f; - } - - public void ToggleRetailLookDownView() - { - if (_lookingDown) - { - RestoreLookDownView(); - return; - } - SaveLookDownView(); - _lookingDown = true; - _mapMode = false; - _inHead = false; - _targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f); - SetViewerOffset(2f, RetailDefaultUp); - } - - public void ToggleRetailMapModeView() - { - if (_mapMode) - { - RestoreLookDownView(); - return; - } - if (!_lookingDown) - SaveLookDownView(); - _lookingDown = true; - _mapMode = true; - _inHead = false; - _targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f); - SetViewerOffset(450f, RetailDefaultUp); - } - - private void SaveLookDownView() - { - _savedDistance = Distance; - _savedPitch = Pitch; - _savedYawOffset = YawOffset; - _savedTargetDirectionLocal = _targetDirectionLocal; - _savedInHead = _inHead; - } - - private void RestoreLookDownView() - { - Distance = _savedDistance; - Pitch = _savedPitch; - YawOffset = _savedYawOffset; - _targetDirectionLocal = _savedTargetDirectionLocal; - _inHead = _savedInHead; - _lookingDown = false; - _mapMode = false; - } - - private void ExitLookDownForAdjustment() - { - if (_lookingDown) - RestoreLookDownView(); - } - - private void ExitInHeadForAdjustment() - { - if (!_inHead) - return; - _inHead = false; - Distance = DistanceMin; - } - - private void SetViewerOffset(float back, float up) - { - Distance = MathF.Sqrt(back * back + up * up); - Pitch = MathF.Atan2(up, back); - } } diff --git a/src/AcDream.App/Rendering/ClipFrameAssembler.cs b/src/AcDream.App/Rendering/ClipFrameAssembler.cs index c7b50def..38b72df4 100644 --- a/src/AcDream.App/Rendering/ClipFrameAssembler.cs +++ b/src/AcDream.App/Rendering/ClipFrameAssembler.cs @@ -39,13 +39,6 @@ public enum TerrainClipMode /// public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes); -/// -/// Identifies one cell inside one nested building look-in. The same EnvCell can -/// be reached by more than one building PView, so a cell id alone is not a -/// sufficient routing key. -/// -public readonly record struct LookInClipCell(int FrameIndex, uint CellId); - /// /// Result of : populated clip buffers /// plus routing data consumed by the render orchestration. @@ -64,12 +57,6 @@ public sealed class ClipFrameAssembly /// Full retail portal_view slices per visible cell. public Dictionary CellIdToViewSlices { get; } = new(); - /// First drawable slice slot per nested look-in cell. - public Dictionary LookInCellToSlot { get; } = new(); - - /// All retail portal_view slices per nested look-in cell. - public Dictionary LookInCellToViewSlices { get; } = new(); - /// Full retail outside_view slices. public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty(); @@ -106,8 +93,6 @@ public sealed class ClipFrameAssembly Frame = frame; foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values) ReturnSlices(slices); - foreach (ClipViewSlice[] slices in LookInCellToViewSlices.Values) - ReturnSlices(slices); foreach (int[] slots in CellIdToViewSlots.Values) ReturnSlots(slots); if (OutsideViewSlices.Length != 0) @@ -116,8 +101,6 @@ public sealed class ClipFrameAssembly CellIdToSlot.Clear(); CellIdToViewSlots.Clear(); CellIdToViewSlices.Clear(); - LookInCellToSlot.Clear(); - LookInCellToViewSlices.Clear(); PerCellPlaneCounts.Clear(); OutsideViewSlices = System.Array.Empty(); SliceScratch.Clear(); @@ -380,70 +363,6 @@ public static class ClipFrameAssembler return assembly; } - /// - /// Appends the cell views used by nested DrawBuilding -> DrawPortal - /// PViews to the already assembled frame. Retail installs each nested - /// cell's own portal_view before drawing its shell and object list; - /// these slots must therefore be published with the main frame before the - /// first draw is recorded. - /// - public static void AppendLookInFrames( - ClipFrame frame, - IReadOnlyList lookInFrames, - ClipFrameAssembly assembly) - { - System.ArgumentNullException.ThrowIfNull(frame); - System.ArgumentNullException.ThrowIfNull(lookInFrames); - System.ArgumentNullException.ThrowIfNull(assembly); - if (!ReferenceEquals(frame, assembly.Frame)) - throw new System.ArgumentException( - "The look-in slots must be appended to the assembly's clip frame.", - nameof(frame)); - - for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++) - { - PortalVisibilityFrame lookIn = lookInFrames[frameIndex]; - foreach (uint cellId in lookIn.OrderedVisibleCells) - { - if (!lookIn.CellViews.TryGetValue(cellId, out CellView? view)) - continue; - - List slices = assembly.SliceScratch; - slices.Clear(); - foreach (ViewPolygon poly in view.Polygons) - { - ClipPlaneSet cps = ClipPlaneSet.From(poly); - if (cps.IsNothingVisible) - continue; - - int slot; - Vector4[] planes; - if (cps.Count > 0) - { - planes = cps.PlaneArray; - slot = frame.AppendSlot(planes); - } - else - { - planes = System.Array.Empty(); - slot = 0; - assembly.ScissorFallbacks++; - } - - slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes)); - } - - if (slices.Count == 0) - continue; - - ClipViewSlice[] packed = assembly.CopySlices(slices); - var key = new LookInClipCell(frameIndex, cellId); - assembly.LookInCellToViewSlices.Add(key, packed); - assembly.LookInCellToSlot.Add(key, packed[0].Slot); - } - } - } - private static Vector4 AabbOf(ViewPolygon poly) => new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY); diff --git a/src/AcDream.App/Rendering/CollisionMeshWireframe.cs b/src/AcDream.App/Rendering/CollisionMeshWireframe.cs new file mode 100644 index 00000000..5954e4a8 --- /dev/null +++ b/src/AcDream.App/Rendering/CollisionMeshWireframe.cs @@ -0,0 +1,372 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; + +namespace AcDream.App.Rendering; + +/// +/// #337 collision-mesh wireframe (2026-08-06 — TEMPORARY, strip with the #337 +/// probe family). +/// +/// +/// The F2 collision overlay predating this class drew, for a BSP object, a +/// proxy cylinder sized from the object's registered BROADPHASE radius. That +/// answers "where does the collision system think this object roughly is" and +/// nothing more. The open question in Neftet is a different one — whether an +/// object's collision SURFACES are where its visual mesh is drawn — and a +/// proxy sphere cannot answer it in either direction. +/// +/// +/// +/// This draws the actual geometry instead, in three colours that are meant to +/// be read against each other: +/// +/// Cyan — the object's real physics-BSP polygon edges, in world +/// space. These are the surfaces a body can stand on or be stopped by. +/// Where the cyan mesh sits away from the rock you can see, the collision +/// is displaced; where a visible rock has no cyan on it at all, it has no +/// collision geometry. +/// Magenta — the same object's VISUAL mesh bounding box, from +/// the same prepared assets the renderer draws from. It is the reference +/// the cyan is judged against, so the comparison does not depend on the +/// eye's guess about where the visual "really" is. +/// Yellow — the outdoor terrain surface under the player, as a +/// grid of the physics engine's own sampled heights. If a body is resting +/// on the yellow rather than on cyan, terrain is holding it up and the +/// object's collision is not involved at all. +/// +/// Dim orange keeps the old broadphase proxy visible so nothing the previous +/// overlay showed has been taken away. +/// +/// +/// +/// Geometry is resolved through the SAME prepared collision accessors the +/// resolver queries (PhysicsDataCache.GetFlatGfxObj / +/// GetVisualBounds) and placed with the SAME world transform the +/// collision probes use, so this cannot draw a shape the collision system does +/// not actually hold. Reading the geometry by a second route is how AP-156 +/// managed to report a sphere the registry never emitted. +/// +/// +/// +/// Pure reads. Nothing here mutates physics, registry, or render state; the +/// caller owns the frame. +/// +/// +internal sealed class CollisionMeshWireframe +{ + // Colours, in the order the class comment lists them. + private static readonly Vector3 PhysicsColor = new(0f, 1f, 1f); + private static readonly Vector3 VisualColor = new(1f, 0f, 1f); + private static readonly Vector3 TerrainColor = new(1f, 1f, 0f); + private static readonly Vector3 BroadphaseColor = new(0.45f, 0.22f, 0f); + + /// + /// Per-frame line ceiling. Each line is 48 bytes in the debug renderer's + /// ring allocation, so this caps the overlay at ~1.9 MB a frame. Landblock + /// 0x8766 carries the largest single collision owner measured in the game + /// (an 81-cell footprint), and an uncapped walk of it would be the one + /// place this overlay falls over. + /// + private const int MaxLines = 40_000; + + /// Per-object polygon ceiling, so one enormous formation cannot + /// consume the whole budget and hide every other object near it. + private const int MaxPolygonsPerObject = 4_000; + + /// Half-width in metres of the terrain grid drawn under the + /// player, and its sample spacing. + private const float TerrainGridHalfWidth = 12f; + private const float TerrainGridStep = 2f; + + private readonly PhysicsEngine _physics; + + public CollisionMeshWireframe(PhysicsEngine physics) + => _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + + /// + /// Emit the overlay for everything within + /// of + /// . Returns what it drew so the caller can + /// report a capped frame rather than silently showing partial geometry. + /// + public CollisionMeshWireframeStats Draw(DebugLineRenderer lines, Vector3 centre) + { + ArgumentNullException.ThrowIfNull(lines); + + float radius = RenderingDiagnostics.CollisionMeshWireframeRadius; + float radiusSquared = radius * radius; + var budget = new LineBudget(MaxLines); + + int objects = 0; + int polygons = 0; + int withoutGeometry = 0; + + PhysicsDataCache? cache = _physics.DataCache; + + foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug()) + { + // Objects register their part ORIGIN, which for a BSP part is + // routinely nowhere near the geometry itself (376 of the 973 + // installed physics-BSP parts sit further from their own bounding + // centre than half their radius). Admitting on origin distance + // ALONE would drop exactly the large displaced-centre formations + // this overlay exists to look at, so the object's own radius is + // added to the window. + float reach = radius + shadow.Radius; + if (Vector3.DistanceSquared(shadow.Position, centre) > reach * reach) + continue; + + objects++; + + if (shadow.CollisionType != ShadowCollisionType.BSP) + { + DrawBroadphaseProxy(lines, in shadow, budget); + continue; + } + + FlatGfxObjCollisionAsset? asset = cache?.GetFlatGfxObj(shadow.GfxObjId); + int drawn = DrawPhysicsPolygons(lines, in shadow, asset, centre, radiusSquared, budget); + polygons += drawn; + if (drawn == 0) withoutGeometry++; + + DrawVisualBounds(lines, in shadow, cache?.GetVisualBounds(shadow.GfxObjId), budget); + DrawBroadphaseProxy(lines, in shadow, budget); + } + + DrawTerrainGrid(lines, centre, budget); + + return new CollisionMeshWireframeStats( + ObjectsConsidered: objects, + PolygonsDrawn: polygons, + ObjectsWithoutPhysicsGeometry: withoutGeometry, + LinesDrawn: budget.Used, + Capped: budget.Capped); + } + + /// + /// Walk the object's physics BSP and emit one closed edge loop per polygon + /// the tree actually indexes. Polygons the tree does not reference are NOT + /// drawn: no query can reach them, so showing them would overstate the + /// collision surface. Returns the polygon count emitted. + /// + private static int DrawPhysicsPolygons( + DebugLineRenderer lines, + in ShadowEntry shadow, + FlatGfxObjCollisionAsset? asset, + Vector3 centre, + float radiusSquared, + LineBudget budget) + { + FlatPhysicsBsp? bsp = asset?.PhysicsBsp; + if (bsp is not { RootIndex: >= 0 } || bsp.Nodes.Length == 0) + return 0; + + FlatPolygonTable table = bsp.PolygonTable; + ImmutableArray vertices = table.Vertices; + int emitted = 0; + + foreach (FlatPhysicsBspNode node in bsp.Nodes) + { + FlatIndexRange indices = node.PolygonIndexRange; + for (int i = indices.Start; i < indices.EndExclusive; i++) + { + if (emitted >= MaxPolygonsPerObject || budget.Exhausted) + return emitted; + + int polygonIndex = bsp.PolygonIndexStream[i]; + if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue; + + FlatIndexRange span = table.Polygons[polygonIndex].VertexRange; + if (span.Count < 2) continue; + + Vector3 first = ToWorld(vertices[span.Start], in shadow); + // Per-polygon distance rejection, AFTER the world transform: + // a big object admitted by the object-level window still only + // needs the faces near the player drawn. + if (Vector3.DistanceSquared(first, centre) > radiusSquared) continue; + + Vector3 previous = first; + for (int v = span.Start + 1; v < span.EndExclusive; v++) + { + Vector3 current = ToWorld(vertices[v], in shadow); + if (!budget.TryAdd()) return emitted; + lines.AddLine(previous, current, PhysicsColor); + previous = current; + } + + if (span.Count > 2) + { + if (!budget.TryAdd()) return emitted; + lines.AddLine(previous, first, PhysicsColor); + } + + emitted++; + } + } + + return emitted; + } + + /// + /// The visual mesh box, placed with the SAME transform as the physics + /// polygons above. It is drawn as the object's own rotated box (eight + /// transformed corners, twelve edges) rather than as a world-axis-aligned + /// box, so a rotated object's magenta lines still bound its actual visual. + /// + private static void DrawVisualBounds( + DebugLineRenderer lines, + in ShadowEntry shadow, + GfxObjVisualBounds? visual, + LineBudget budget) + { + if (visual is null || budget.Exhausted) return; + + Vector3 min = visual.Min; + Vector3 max = visual.Max; + Span corners = + [ + ToWorld(new Vector3(min.X, min.Y, min.Z), in shadow), + ToWorld(new Vector3(max.X, min.Y, min.Z), in shadow), + ToWorld(new Vector3(max.X, max.Y, min.Z), in shadow), + ToWorld(new Vector3(min.X, max.Y, min.Z), in shadow), + ToWorld(new Vector3(min.X, min.Y, max.Z), in shadow), + ToWorld(new Vector3(max.X, min.Y, max.Z), in shadow), + ToWorld(new Vector3(max.X, max.Y, max.Z), in shadow), + ToWorld(new Vector3(min.X, max.Y, max.Z), in shadow), + ]; + + ReadOnlySpan edges = + [ + 0, 1, 1, 2, 2, 3, 3, 0, + 4, 5, 5, 6, 6, 7, 7, 4, + 0, 4, 1, 5, 2, 6, 3, 7, + ]; + + for (int e = 0; e < edges.Length; e += 2) + { + if (!budget.TryAdd()) return; + lines.AddLine(corners[edges[e]], corners[edges[e + 1]], VisualColor); + } + } + + /// + /// The registered broadphase shape — what the pre-#337 overlay showed, and + /// what the collision system's reach filter measures against. Kept so this + /// overlay is a superset of the one it replaces. + /// + private static void DrawBroadphaseProxy( + DebugLineRenderer lines, + in ShadowEntry shadow, + LineBudget budget) + { + // AddCylinder emits a fixed 36 lines. Reserve them together so a + // partial ring cannot be drawn. + if (!budget.TryAdd(36)) return; + + if (shadow.CollisionType == ShadowCollisionType.Cylinder) + { + float height = shadow.CylHeight > 0f ? shadow.CylHeight : shadow.Radius * 2f; + lines.AddCylinder(shadow.Position, shadow.Radius, height, BroadphaseColor); + return; + } + + lines.AddCylinder( + shadow.Position - new Vector3(0f, 0f, shadow.Radius), + shadow.Radius, + shadow.Radius * 2f, + BroadphaseColor); + } + + /// + /// The terrain surface under the player, sampled through the physics + /// engine's own height resolver — the same numbers the resolver grounds + /// against, not a re-derivation. Drawn as a grid rather than as the single + /// containing triangle so the slope around the player reads at a glance. + /// + private void DrawTerrainGrid(DebugLineRenderer lines, Vector3 centre, LineBudget budget) + { + int steps = (int)(TerrainGridHalfWidth * 2f / TerrainGridStep); + float originX = centre.X - TerrainGridHalfWidth; + float originY = centre.Y - TerrainGridHalfWidth; + + for (int ix = 0; ix <= steps; ix++) + { + for (int iy = 0; iy <= steps; iy++) + { + float x = originX + ix * TerrainGridStep; + float y = originY + iy * TerrainGridStep; + float? z = _physics.SampleTerrainZ(x, y); + if (z is null) continue; + + var here = new Vector3(x, y, z.Value); + + if (ix < steps) + { + float nx = x + TerrainGridStep; + if (_physics.SampleTerrainZ(nx, y) is { } nz) + { + if (!budget.TryAdd()) return; + lines.AddLine(here, new Vector3(nx, y, nz), TerrainColor); + } + } + + if (iy < steps) + { + float ny = y + TerrainGridStep; + if (_physics.SampleTerrainZ(x, ny) is { } nz2) + { + if (!budget.TryAdd()) return; + lines.AddLine(here, new Vector3(x, ny, nz2), TerrainColor); + } + } + } + } + } + + /// + /// The one placement formula, matching the [resolve-bldg] probe's + /// world transform for a shadow part + /// (TransitionTypes.FindObjCollisionsInCell): scale in the part's + /// own frame, then rotate, then translate to the registered position. + /// + private static Vector3 ToWorld(Vector3 local, in ShadowEntry shadow) + => shadow.Position + Vector3.Transform(local * shadow.Scale, shadow.Rotation); + + /// + /// Mutable line counter shared across the draw. A class rather than a + /// struct so the per-shape helpers can be static and still share it + /// without ref-plumbing through every signature. + /// + private sealed class LineBudget(int limit) + { + public int Used { get; private set; } + + public bool Capped { get; private set; } + + public bool Exhausted => Used >= limit; + + public bool TryAdd(int count = 1) + { + if (Used + count > limit) + { + Capped = true; + return false; + } + Used += count; + return true; + } + } +} + +/// What one emitted. +/// is the interesting one: a +/// non-zero count means objects near the player carry no reachable collision +/// polygons at all. +internal readonly record struct CollisionMeshWireframeStats( + int ObjectsConsidered, + int PolygonsDrawn, + int ObjectsWithoutPhysicsGeometry, + int LinesDrawn, + bool Capped); diff --git a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs index 9b34a6ed..f27b37d3 100644 --- a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs +++ b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs @@ -134,16 +134,13 @@ internal sealed class RetailCreatureAppraisalFrameView : width = 0; height = 0; if (_controller.ActiveView is not ( - AppraisalView.Creature or AppraisalView.Character)) + AppraisalView.Creature or AppraisalView.Character) + || !IsEffectivelyVisible(_windowFrame) + || !IsEffectivelyVisible(_viewport) + || _controller.CurrentObjectId == 0u) { return false; } - if (!IsEffectivelyVisible(_windowFrame)) - return false; - if (!IsEffectivelyVisible(_viewport)) - return false; - if (_controller.CurrentObjectId == 0u) - return false; serverGuid = _controller.CurrentObjectId; width = (int)_viewport.Width; @@ -206,10 +203,11 @@ internal sealed class RetailCreatureAppraisalCloneFactory : synchronizedClone = null; boundsMin = Vector3.Zero; boundsMax = Vector3.Zero; - if (!_entities.TryGet(serverGuid, out WorldEntity source)) - return false; - if (source.MeshRefs.Count == 0) + if (!_entities.TryGet(serverGuid, out WorldEntity source) + || source.MeshRefs.Count == 0) + { return false; + } WorldEntity clone = currentClone is not null && currentClone.SourceGfxObjOrSetupId == source.SourceGfxObjOrSetupId diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs index d3d618fb..665d757c 100644 --- a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -58,6 +58,17 @@ internal sealed class DatLiveEntityProjectionMaterializer /// private readonly RuntimeWorldTransitState _transit; + private int _received; + private int _hydrated; + private int _noPosition; + private int _noSetup; + private int _missingSetup; + private int _noMesh; + private int _noCycle; + private int _zeroFramerate; + private int _singleFrame; + private int _missingPartFrames; + public DatLiveEntityProjectionMaterializer( RuntimeOptions options, IDatReaderWriter dats, @@ -105,6 +116,16 @@ internal sealed class DatLiveEntityProjectionMaterializer public void ResetSessionState() { + _received = 0; + _hydrated = 0; + _noPosition = 0; + _noSetup = 0; + _missingSetup = 0; + _noMesh = 0; + _noCycle = 0; + _zeroFramerate = 0; + _singleFrame = 0; + _missingPartFrames = 0; } public bool TryMaterialize( @@ -136,10 +157,18 @@ internal sealed class DatLiveEntityProjectionMaterializer if (appearanceUpdate is not null && expectedRecord is null) return false; + _received++; + bool dumpLiveSpawns = _options.DumpLiveSpawns; + DumpSpawn(canonicalSpawn, dumpLiveSpawns); + if (!_origin.IsKnown) return false; if (canonicalSpawn.Position is null || canonicalSpawn.SetupTableId is null) { + if (canonicalSpawn.Position is null) + _noPosition++; + else + _noSetup++; return false; } @@ -187,6 +216,13 @@ internal sealed class DatLiveEntityProjectionMaterializer _collisionAssets.CacheSetup(canonicalSpawn.SetupTableId.Value, setup); if (setup is null) { + _missingSetup++; + if (dumpLiveSpawns) + { + Console.WriteLine( + $"live: DROP setup dat 0x{canonicalSpawn.SetupTableId.Value:X8} missing " + + $"(guid=0x{canonicalSpawn.Guid:X8})"); + } return false; } @@ -212,6 +248,14 @@ internal sealed class DatLiveEntityProjectionMaterializer List flattened = [.. SetupMesh.Flatten(setup, idleFrame)]; IReadOnlyList animPartChanges = canonicalSpawn.AnimPartChanges ?? Array.Empty(); + bool dumpClothing = _options.DumpClothing && setup.Parts.Count >= 10; + DumpClothingHeader( + canonicalSpawn, + setup, + flattened, + idleFrame, + animPartChanges, + dumpClothing); foreach (CreateObject.AnimPartChange change in animPartChanges) { @@ -229,14 +273,17 @@ internal sealed class DatLiveEntityProjectionMaterializer baseId => ResolveCollisionPart(baseId)); if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup)) - ApplyRetailCloseDegrades(flattened); + ApplyRetailCloseDegrades(flattened, dumpClothing); IReadOnlyList textureChanges = canonicalSpawn.TextureChanges ?? Array.Empty(); Dictionary>? surfaceOverrides = ResolveSurfaceOverrides( + canonicalSpawn, flattened, - textureChanges); + textureChanges, + dumpClothing, + dumpLiveSpawns); float scale = canonicalSpawn.ObjScale ?? 1f; Matrix4x4 scaleMatrix = Matrix4x4.CreateScale(scale); @@ -247,6 +294,7 @@ internal sealed class DatLiveEntityProjectionMaterializer var indexedPartAvailable = new bool[flattened.Count]; var animatedPartTemplate = new LiveAnimationPartTemplate[flattened.Count]; var bounds = new LocalBoundsAccumulator(); + int clothingTriangles = 0; for (int partIndex = 0; partIndex < flattened.Count; partIndex++) { @@ -270,10 +318,23 @@ internal sealed class DatLiveEntityProjectionMaterializer drawable); if (gfx is null) { + if (dumpClothing) + Console.WriteLine($" EMIT part={partIndex:D2} gfx=0x{part.GfxObjId:X8} GFXOBJ_DAT_MISSING -> 0 tris"); continue; } _collisionAssets.CacheGfxObj(part.GfxObjId, gfx); + if (dumpClothing) + { + var subMeshes = GfxObjMesh.Build(gfx, _dats); + int triangles = 0; + foreach (var subMesh in subMeshes) + triangles += subMesh.Indices.Length / 3; + clothingTriangles += triangles; + Console.WriteLine( + $" EMIT part={partIndex:D2} gfx=0x{part.GfxObjId:X8} " + + $"subMeshes={subMeshes.Count} tris={triangles}"); + } if (GfxObjBounds.Get(gfx) is { } partBounds) bounds.Add(transform, partBounds); @@ -285,8 +346,21 @@ internal sealed class DatLiveEntityProjectionMaterializer if (meshRefs.Count == 0) { + _noMesh++; + if (dumpLiveSpawns) + { + Console.WriteLine( + $"live: DROP no mesh refs from setup 0x{canonicalSpawn.SetupTableId.Value:X8} " + + $"(guid=0x{canonicalSpawn.Guid:X8})"); + } return false; } + if (dumpClothing) + { + Console.WriteLine( + $" TOTAL tris={clothingTriangles} meshRefs={meshRefs.Count} " + + $"(parts.Count={flattened.Count})"); + } PaletteOverride? paletteOverride = CreatePaletteOverride(canonicalSpawn); PartOverride[] partOverrides = CreatePartOverrides(animPartChanges); @@ -382,6 +456,7 @@ internal sealed class DatLiveEntityProjectionMaterializer indexedPartAvailable, animatedPartTemplate, bounds, + dumpLiveSpawns, expectedCreateIntegrationVersion, synchronizeAnimation: supersessionRecovery); } @@ -402,7 +477,9 @@ internal sealed class DatLiveEntityProjectionMaterializer return slotZeroId; } - private void ApplyRetailCloseDegrades(List parts) + private void ApplyRetailCloseDegrades( + List parts, + bool dumpClothing) { for (int partIndex = 0; partIndex < parts.Count; partIndex++) { @@ -418,13 +495,33 @@ internal sealed class DatLiveEntityProjectionMaterializer } parts[partIndex] = new MeshRef(resolvedId, part.PartTransform); + if (dumpClothing) + { + Console.WriteLine( + $" DEGRADE part={partIndex:D2} gfx=0x{part.GfxObjId:X8} " + + $"-> close=0x{resolvedId:X8}"); + } } } private Dictionary>? ResolveSurfaceOverrides( + WorldSession.EntitySpawn spawn, IReadOnlyList parts, - IReadOnlyList textureChanges) + IReadOnlyList textureChanges, + bool dumpClothing, + bool dumpLiveSpawns) { + if (dumpClothing) + { + Console.WriteLine($" TextureChanges count={textureChanges.Count}"); + foreach (CreateObject.TextureChange change in textureChanges) + { + Console.WriteLine( + $" TC part={change.PartIndex:D2} oldTex=0x{change.OldTexture:X8} " + + $"-> newTex=0x{change.NewTexture:X8}"); + } + } + if (textureChanges.Count == 0) return null; @@ -439,6 +536,8 @@ internal sealed class DatLiveEntityProjectionMaterializer oldToNew[change.OldTexture] = change.NewTexture; } + bool statueDiagnostic = dumpLiveSpawns + && spawn.Name?.Contains("Statue", StringComparison.OrdinalIgnoreCase) == true; var result = new Dictionary>(); for (int partIndex = 0; partIndex < parts.Count; partIndex++) { @@ -448,6 +547,12 @@ internal sealed class DatLiveEntityProjectionMaterializer GfxObj? gfx = _dats.Get(parts[partIndex].GfxObjId); if (gfx is null) { + if (statueDiagnostic) + { + Console.WriteLine( + $"live: [STATUE] resolve part={partIndex} " + + $"GfxObj 0x{parts[partIndex].GfxObjId:X8} missing"); + } continue; } _collisionAssets.CacheGfxObj(parts[partIndex].GfxObjId, gfx); @@ -624,6 +729,7 @@ internal sealed class DatLiveEntityProjectionMaterializer IReadOnlyList indexedPartAvailable, IReadOnlyList animatedPartTemplate, LocalBoundsAccumulator bounds, + bool dumpLiveSpawns, ulong expectedCreateIntegrationVersion, bool synchronizeAnimation) { @@ -729,6 +835,7 @@ internal sealed class DatLiveEntityProjectionMaterializer _worldEvents.UpsertCurrent(snapshot); if (_runtime.TryMarkWorldSpawnPublished(spawn.Guid)) _worldEvents.FireEntitySpawned(snapshot); + _hydrated++; if (!_runtime.IsCurrentCreateIntegration( expectedRecord, @@ -814,6 +921,18 @@ internal sealed class DatLiveEntityProjectionMaterializer return false; } + if (dumpLiveSpawns && _received % 20 == 0) + { + Console.WriteLine( + $"live: animated={_runtime.AnimationRuntimeCount} " + + $"animReject: noCycle={_noCycle} fr0={_zeroFramerate} " + + $"1frame={_singleFrame} partFrames={_missingPartFrames}"); + Console.WriteLine( + $"live: summary recv={_received} hydrated={_hydrated} " + + $"drops: noPos={_noPosition} noSetup={_noSetup} " + + $"setupMissing={_missingSetup} noMesh={_noMesh}"); + } + return _runtime.IsCurrentCreateIntegration( expectedRecord, expectedCreateIntegrationVersion); @@ -842,6 +961,18 @@ internal sealed class DatLiveEntityProjectionMaterializer spawn, idleCycle); } + if (!retainedAnimation) + { + if (idleCycle is null) + _noCycle++; + else if (idleCycle.Framerate == 0f) + _zeroFramerate++; + else if (idleCycle.HighFrame <= idleCycle.LowFrame) + _singleFrame++; + else if (idleCycle.Animation.PartFrames.Count <= 1) + _missingPartFrames++; + } + if (!retainedAnimation && idleCycle is not null && idleCycle.Framerate != 0f @@ -1028,6 +1159,56 @@ internal sealed class DatLiveEntityProjectionMaterializer : null; } + private void DumpSpawn(WorldSession.EntitySpawn spawn, bool enabled) + { + if (!enabled) + return; + + string position = spawn.Position is { } p + ? $"({p.PositionX:F1},{p.PositionY:F1},{p.PositionZ:F1})@0x{p.LandblockId:X8}" + : "no-pos"; + string setup = spawn.SetupTableId is { } setupId + ? $"0x{setupId:X8}" + : "no-setup"; + string physicsTable = spawn.Physics?.PhysicsScriptTableId is { } tableId + ? $"0x{tableId:X8}" + : "no-petable"; + string name = spawn.Name is { Length: > 0 } foundName + ? $"\"{foundName}\"" + : "no-name"; + string itemType = spawn.ItemType is { } foundItemType + ? $"0x{foundItemType:X8}" + : "no-itemtype"; + Console.WriteLine( + $"live: spawn guid=0x{spawn.Guid:X8} name={name} setup={setup} pos={position} " + + $"petable={physicsTable} itemType={itemType} " + + $"animParts={spawn.AnimPartChanges?.Count ?? 0} " + + $"texChanges={spawn.TextureChanges?.Count ?? 0} " + + $"subPalettes={spawn.SubPalettes?.Count ?? 0}"); + } + + private void DumpClothingHeader( + WorldSession.EntitySpawn spawn, + Setup setup, + IReadOnlyList flattened, + AnimationFrame? idleFrame, + IReadOnlyList changes, + bool enabled) + { + if (!enabled) + return; + + Console.WriteLine( + $"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' " + + $"setup=0x{setup.Id:X8} setup.Parts.Count={setup.Parts.Count} " + + $"flatten.Count={flattened.Count} APC={changes.Count} ==="); + foreach (CreateObject.AnimPartChange change in changes) + Console.WriteLine($" APC part={change.PartIndex:D2} -> gfx=0x{change.NewModelId:X8}"); + Console.WriteLine( + $" basePalette=0x{spawn.BasePaletteId ?? 0:X8} " + + $"subPalettes={spawn.SubPalettes?.Count ?? 0}"); + } + private static bool IsIssue47HumanoidSetup(Setup setup) { if (setup.Parts.Count != 34) diff --git a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs index 75e9518c..035ffa9f 100644 --- a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs +++ b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs @@ -38,12 +38,7 @@ internal readonly record struct DirectionalSunShadowRenderInput( float ResidentMaximumReachMeters = float.PositiveInfinity, bool MeasureGpuTimers = true, bool MeasureCpuStages = false, - AtmosphericFrameBufferBinding AtmosphericFrame = default, - // #429 owner-approved pipelining: false keeps the retained caster/draw - // topology this frame (transform refresh only) so the rebuild lands on a - // quieter frame. The prepare seams below re-validate and rebuild anyway - // whenever deferral would be unsafe. - bool AllowTopologyRebuild = true); + AtmosphericFrameBufferBinding AtmosphericFrame = default); internal readonly record struct DirectionalSunShadowCpuStageTicks( long EnvironmentGateTicks, @@ -399,9 +394,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L; DirectionalShadowPreparedDraws worldDraws = - world.PrepareDirectionalShadowDraws( - input.Casters, - input.AllowTopologyRebuild); + world.PrepareDirectionalShadowDraws(input.Casters); DirectionalShadowTerrainPreparedDraws terrainDraws = terrain.PrepareDirectionalShadowDraws(); DirectionalShadowMeshGeometry? worldGeometry = diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6fba8f40..93f8da2d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,6 +1,5 @@ using AcDream.Core.Plugins; using AcDream.App.Composition; -using AcDream.App.Input; using AcDream.App.Physics; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Scene; @@ -18,7 +17,6 @@ using DatReaderWriter; using Silk.NET.Input; using Silk.NET.Maths; using Silk.NET.Windowing; -using AcDream.UI.Abstractions.Input; namespace AcDream.App.Rendering; @@ -136,8 +134,8 @@ public sealed class GameWindow : private DebugLineRenderer? _debugLines; // K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder // wireframes are noisy outdoors and confuse first-time users into - // thinking they're a rendering bug. Ctrl+F2 toggles. (The DebugPanel - // button that also toggled it is gone — #434.) + // thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel + // → Diagnostics → "Toggle collision wires" button toggles too. private readonly AcDream.App.Rendering.WorldSceneDebugState _worldSceneDebugState = new(); @@ -601,18 +599,14 @@ public sealed class GameWindow : // startup — no other call to RetailDefaults() / AcdreamCurrentDefaults() // should land in the GameWindow construction path. private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings; - private bool _keyBindingsPersisted; private readonly GraphicalHostPlatformServices _platformServices; private readonly ApplicationPathSet _applicationPaths; private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings( string path) { - var bindings = AcDream.App.Input.RetailKeymapProfileStore.LoadActiveOrJson( - path, out string profileName); - Console.WriteLine( - $"keybinds: loaded {bindings.All.Count} bindings; active retail profile " - + $"'{profileName}', JSON mirror {path}"); + var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path); + Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}"); return bindings; } @@ -715,7 +709,6 @@ public sealed class GameWindow : // reset across generations. Re-binding per session would be re-binding // the same two references. _automation?.Bind(_runtime, _runtime.CharacterOwner, _runtime.ActionOwner.SpellCast); - _automation?.BindProjectileCollision(_physicsEngine); _localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState( _runtime.PlayerIdentity); _updateFrameClock = new AcDream.App.Update.UpdateFrameClock( @@ -961,10 +954,6 @@ public sealed class GameWindow : // zero skills with no error to explain it. if (_automation is null) return; - _automation.BindSpeciesNameResolver( - AcDream.App.UI.Layout.CreatureDisplayNameResolver.Load(value).Resolve); - _automation.BindPaletteColorResolver( - new AcDream.Content.CharGen.ChargenAppearanceCatalog(value)); if (!value.TryGet(0x0E000004u, out var skillTable) || skillTable is null) { @@ -988,11 +977,8 @@ public sealed class GameWindow : "prepared asset source"); void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog( - MagicCatalog value) - { + MagicCatalog value) => PublishCompositionOwner(ref _magicCatalog, value, "magic catalog"); - _automation?.BindMagicCatalog(value); - } void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader( AcDream.Core.Physics.IAnimationLoader value) => @@ -1152,25 +1138,6 @@ public sealed class GameWindow : _combatAttackController = result.CombatAttack; _externalContainerLifecycle = result.ExternalContainerLifecycle; _itemInteractionController = result.ItemInteraction; - _automation?.BindEquipment( - (itemId, requestedLocation) => - result.ItemInteraction.TryWieldItem( - itemId, - (AcDream.Core.Items.EquipMask)requestedLocation), - () => result.ItemInteraction.IsAutoWieldBusy); - _automation?.BindItems( - result.ItemInteraction.TryUseItemForAutomation, - result.ItemInteraction.TryApplyItem, - result.ItemInteraction.TryMoveItemForAutomation, - result.ItemInteraction.TryMergeItemsForAutomation, - result.ItemInteraction.TryDropItemForAutomation, - result.ItemInteraction.TryGiveItemForAutomation, - result.ItemInteraction.PlaceWorldItemInBackpack, - result.ItemInteraction.TryAppraiseForAutomation, - result.ItemInteraction.TrySalvageItemsForAutomation, - (vendorId, itemId, amount) => result.ItemInteraction.TrySell( - vendorId, - [(amount, itemId)])); _interactionUiLateBindings = result.LateBindings; _magicRuntime = result.Magic; if (result.RetainedUi is { } retained) @@ -1246,17 +1213,6 @@ public sealed class GameWindow : _retailSelectionScene = result.SelectionScene; _worldSelectionQuery = result.SelectionQuery; _selectionInteractions = result.SelectionInteractions; - _automation?.BindSelectionActions(action => - result.SelectionInteractions.HandleInputAction(action switch - { - AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousSelection => - InputAction.SelectionPreviousSelection, - AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousPlayer => - InputAction.SelectionPreviousPlayer, - AcDream.Plugin.Abstractions.PluginSelectionAction.NextPlayer => - InputAction.SelectionNextPlayer, - _ => InputAction.None, - })); _retainedUiGameplayBinding = result.RetainedGameplay; _paperdollViewportRenderer = result.PaperdollRenderer; _paperdollFramePresenter = result.PaperdollPresenter; @@ -1310,7 +1266,6 @@ public sealed class GameWindow : _worldReveal = result.WorldReveal; _spawnClaimHydration = result.SpawnClaimHydration; _liveEntityHydration = result.Hydration; - _automation?.BindGhostDeletion(result.Deletion.DeleteClientGhost); _liveEntityNetworkUpdates = result.NetworkUpdates; _liveEntityLiveness = result.Liveness; _liveEntitySessionEvents = result.SessionEvents; @@ -1321,7 +1276,6 @@ public sealed class GameWindow : _playerModeAutoEntry = result.PlayerModeAutoEntry; _localPlayerTeleport = result.LocalTeleport; _liveSessionHost = result.SessionHost; - _automation?.BindSessionCommands(result.GameRuntime); _gameplayInputActions = result.GameplayActions; _sessionPlayerBindings = result.RuntimeBindings; } @@ -1464,9 +1418,6 @@ public sealed class GameWindow : new ContentEffectsAudioDependencies( _datDir, _options.PreparedAssetPath, - _options.PreparedAssetOverlayPath, - _options.PreparedAssetBaseRecipeVersion, - _options.PreparedAssetEffectiveRecipeVersion, _options.ResidencyBudgets, _physicsDataCache, _animationDiagnostics.DumpMotionEnabled, @@ -1568,9 +1519,7 @@ public sealed class GameWindow : hostInputCamera.GpuFrameLifetime, () => WorldTime.CurrentCalendar, settingsDevTools.RenderPacks, - _renderPackDiagnostics.CaptureDiagnostics, - _applicationPaths.ScreenshotsDirectory, - _automation), + _renderPackDiagnostics.CaptureDiagnostics), _retailUiLease, this).Compose( platformResult, @@ -1617,7 +1566,6 @@ public sealed class GameWindow : _cellVisibility, _liveWorldOrigin, _localPlayerIdentity, - _chaseCameraInput, _pointerPosition, _playerApproachCompletions, _renderResourceLifetime, @@ -1694,9 +1642,6 @@ public sealed class GameWindow : _combatFeedback, _portalTunnelFallback, Console.WriteLine, - _automation is null - ? null - : _automation.TryHandlePluginCommand, _statusWriter), this).Compose( hostInputCamera, @@ -1873,7 +1818,6 @@ public sealed class GameWindow : if (!_lifetime.HasShutdownRoots) { - PersistKeyBindingsAtShutdown(); // Campaign LA slice LA1: capture BEFORE the shutdown roots run — // by the time teardown completes, IsInWorld is always false // regardless of whether a real session was ever connected. @@ -1914,33 +1858,6 @@ public sealed class GameWindow : ReportExited(report); } - private void PersistKeyBindingsAtShutdown() - { - // Construction-only tests and failed starts never create the input - // dispatcher. They must not materialize a profile in the real user's - // Documents folder merely because the half-built window is disposed. - if (_keyBindingsPersisted || _inputDispatcher is null) return; - _keyBindingsPersisted = true; - try - { - KeyBindings current = _inputDispatcher.Bindings; - var profiles = new AcDream.App.Input.RetailKeymapProfileStore( - _applicationPaths.KeyBindingsFile); - RetailKeymapSaveResult saved = profiles.SaveActive(current); - if (saved.Status != RetailKeymapSaveStatus.Saved) - { - Console.WriteLine( - $"keymap: shutdown save failed ({saved.Status}): {saved.Error}"); - return; - } - current.SaveToFile(_applicationPaths.KeyBindingsFile); - } - catch (Exception failure) - { - Console.WriteLine($"keymap: shutdown persistence failed: {failure.Message}"); - } - } - /// /// Writes the ONE terminal "exited" status event for this session /// (fix #406). A resource-shutdown transaction can converge cleanly diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs index 57470517..49a2543b 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs @@ -7,10 +7,8 @@ using Silk.NET.Windowing; namespace AcDream.App.Rendering.Gpu.Vk; /// -/// The Vulkan capability-probe and bring-up harness. Reached when -/// ACDREAM_VULKAN_PROBE=1. (This previously also required -/// ACDREAM_RENDER_BACKEND=vulkan; that variable died with the OpenGL -/// backend at Campaign V and is read nowhere — Vulkan is the only backend.) +/// The Vulkan capability-probe and bring-up harness. Reached only when +/// ACDREAM_RENDER_BACKEND=vulkan and ACDREAM_VULKAN_PROBE=1. /// /// What it is for. Answering two questions without starting the /// client: does this machine pass the Vulkan capability gate, and does the RHI diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs b/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs index ab9ec407..cb59ea3e 100644 --- a/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs +++ b/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs @@ -99,7 +99,7 @@ internal sealed class AtmosphericFrameInputState : IAtmosphericWorldFrameSink _host.DeltaSeconds, _host.ViewportWidth, _host.ViewportHeight, - IsOutdoor: world.Roots.IsAtmosphericallyOutdoor); + IsOutdoor: world.Roots.RenderSky && !world.Roots.CameraInsideCell); _published = true; } diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs index 2117d648..1cadfb34 100644 --- a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs +++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs @@ -197,10 +197,6 @@ internal sealed class AtmosphericPostProcessGraph : private readonly bool _fuseLowPostProcess; private readonly AtmosphericCpuStageProfiler? _cpuStageProfiler; private readonly DirectionalShadowCasterFrame _shadowCasters = new(); - // #429 owner-approved pipelining state — see RenderDirectionalShadows. - private ulong _lastObservedSceneShadowRevision; - private long _lastObservedAvailabilityVersion; - private int _shadowRebuildDeferrals; private TargetSet? _targets; private AtmosphericFrameInputs _lastInputs; private DirectionalSunShadowDiagnostics _lastShadowDiagnostics; @@ -399,39 +395,14 @@ internal sealed class AtmosphericPostProcessGraph : bool measureCpuStages = _cpuStageProfiler is not null && AtmosphericCpuStageProfiler.ShouldMeasure(frame.Serial); long stageStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; - // #429 owner-approved pipelining: on a frame where the shadow inputs - // just CHANGED (streaming publish churn — the same frame already pays - // the frame-view and landscape rebuilds), keep the retained shadow - // topology and let the rebuild land on the next quieter frame. Capped - // at two consecutive deferrals: the GPU frame fence is deeper than - // that, so retained prepared draws can never reference an arena range - // that was released AND reused while deferred. A caster rebuild the - // frame forces anyway (first build, generation change, journal - // overflow) re-enables the draws rebuild in the same frame — the - // prepared draws must never index a caster frame they were not built - // from. - ulong sceneShadowRevision = scene.DirectionalShadowTopologyRevision; - long availabilityVersion = worldMeshes.DirectionalShadowAvailabilityVersion; - bool shadowInputsChanged = - sceneShadowRevision != _lastObservedSceneShadowRevision - || availabilityVersion != _lastObservedAvailabilityVersion; - _lastObservedSceneShadowRevision = sceneShadowRevision; - _lastObservedAvailabilityVersion = availabilityVersion; - bool allowTopologyRebuild = - !shadowInputsChanged || _shadowRebuildDeferrals >= 2; - ulong casterSequenceBefore = _shadowCasters.BuildSequence; - _shadowCasters.Build(in scene, allowTopologyRebuild); - if (_shadowCasters.BuildSequence != casterSequenceBefore) - allowTopologyRebuild = true; - _shadowRebuildDeferrals = allowTopologyRebuild - ? 0 - : _shadowRebuildDeferrals + 1; + _shadowCasters.Build(in scene); long casterBuildFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; AuthoredCelestialShadowSource source = world.CelestialShadowSource; var environment = new DirectionalShadowEnvironmentInput( PackEnabled: true, PortalOrLoginCoverVisible: foundation.PortalViewportVisible, - PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell, + PlayerInsideCell: world.Roots.PlayerInsideCell + || world.Roots.CameraInsideCell, source, foundation.Atmosphere, ActiveDayGroupMultiplier: Math.Clamp( @@ -448,7 +419,7 @@ internal sealed class AtmosphericPostProcessGraph : // or deactivation can never leave a stale exclusion set applied to // the dispatcher (see FoliageWindExclusions's doc comment). worldMeshes.FoliageWindExclusions = _foliageWindExclusions; - bool isOutdoor = world.Roots.IsAtmosphericallyOutdoor; + bool isOutdoor = world.Roots.RenderSky && !world.Roots.CameraInsideCell; AtmosphericFrameBufferBinding shadowAtmosphericFrame = BuildShadowAtmosphericFrameBinding(frame, foundation.Atmosphere.Kind, isOutdoor); var input = new DirectionalSunShadowRenderInput( @@ -462,8 +433,7 @@ internal sealed class AtmosphericPostProcessGraph : Preset.Semantic, frame.Serial), MeasureCpuStages: measureCpuStages, - AtmosphericFrame: shadowAtmosphericFrame, - AllowTopologyRebuild: allowTopologyRebuild); + AtmosphericFrame: shadowAtmosphericFrame); long environmentFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; _lastShadowCasterCount = _shadowCasters.Stats.Accepted; _lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0; diff --git a/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs index e883456c..0bf5fafc 100644 --- a/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs +++ b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs @@ -210,7 +210,8 @@ internal class DeclaredFullscreenRenderPackGraph : var environment = new DirectionalShadowEnvironmentInput( PackEnabled: true, PortalOrLoginCoverVisible: foundation.PortalViewportVisible, - PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell, + PlayerInsideCell: world.Roots.PlayerInsideCell + || world.Roots.CameraInsideCell, source, foundation.Atmosphere, ActiveDayGroupMultiplier: Math.Clamp( diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index 815a794c..b2502a1a 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -14,8 +14,6 @@ internal interface IPaperdollDollRenderer { void SetDoll(WorldEntity? doll); - void Prepare(); - uint Render(int width, int height); } @@ -75,6 +73,9 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame public void Render() { + if (!_view.TryGetVisibleSize(out int width, out int height)) + return; + if (_dirty) { if (_factory.TryBuild(out WorldEntity? doll)) @@ -100,11 +101,6 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame } } - _renderer.Prepare(); - - if (!_view.TryGetVisibleSize(out int width, out int height)) - return; - _view.SetTextureHandle(_renderer.Render(width, height)); } diff --git a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs index f5aa80ff..03309a72 100644 --- a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs @@ -42,8 +42,6 @@ public sealed class PaperdollViewportRenderer : public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll); - public void Prepare() => _renderer.Prepare(); - public uint Render(int width, int height) => _renderer.Render(width, height); diff --git a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs index cd68e206..a1ff118e 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs @@ -71,8 +71,10 @@ public sealed unsafe partial class ParticleRenderer private const uint QuadStrideBytes = 4 * sizeof(float); - private static readonly uint MeshInstanceStrideBytes = - (uint)sizeof(MeshParticleGpuInstance); + /// Floats per mesh-particle instance: a mat4 plus an RGBA colour. + internal const int MeshInstanceFloats = 20; + + private const uint MeshInstanceStrideBytes = MeshInstanceFloats * sizeof(float); /// /// The billboard layout: the shared unit quad at vertex rate, and one @@ -101,8 +103,7 @@ public sealed unsafe partial class ParticleRenderer // Location 6 is `in uint aTextureIndex` — an INTEGER shader input, // so R8G8B8A8's normalized cousin would be wrong in kind. It is one // 32-bit unsigned value; Float1 would reinterpret its bits. - new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1), - new GpuVertexAttribute(7, GpuVertexFormat.UInt1, 68, Binding: 1))); + new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1))); /// /// The mesh-particle layout: the shared world-mesh vertex at vertex rate, @@ -125,8 +126,7 @@ public sealed unsafe partial class ParticleRenderer new GpuVertexAttribute(4, GpuVertexFormat.Float4, 16, Binding: 1), new GpuVertexAttribute(5, GpuVertexFormat.Float4, 32, Binding: 1), new GpuVertexAttribute(6, GpuVertexFormat.Float4, 48, Binding: 1), - new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1), - new GpuVertexAttribute(8, GpuVertexFormat.UInt1, 80, Binding: 1))); + new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1))); /// /// The RHI arm's constructor. No GL context, no Shader, no @@ -340,22 +340,22 @@ public sealed unsafe partial class ParticleRenderer while (submission.Kind == ParticleSubmissionKind.Mesh && _meshDrawListScratch[submission.DrawIndex].Key == meshKey); - int neededInstances = _meshRunScratch.Count; - if (_meshInstanceScratch.Length < neededInstances) - _meshInstanceScratch = new MeshParticleGpuInstance[neededInstances + 256]; + int neededFloats = _meshRunScratch.Count * MeshInstanceFloats; + if (_meshInstanceScratch.Length < neededFloats) + _meshInstanceScratch = new float[neededFloats + 256 * MeshInstanceFloats]; for (int instance = 0; instance < _meshRunScratch.Count; instance++) { WriteMeshGpuInstance( - ref _meshInstanceScratch[instance], + _meshInstanceScratch, + instance * MeshInstanceFloats, _meshRunScratch[instance]); } - GpuRingAllocation instances = WriteVertexRing( + GpuRingAllocation instances = WriteVertexRing( frame, - _meshInstanceScratch.AsSpan(0, neededInstances)); + _meshInstanceScratch.AsSpan(0, neededFloats)); DrawMeshBatchRhi( encoder, - frame, global, batch, viewProjection, @@ -384,13 +384,7 @@ public sealed unsafe partial class ParticleRenderer GpuRingAllocation ring = WriteVertexRing( frame, _instanceScratch.AsSpan(0, instances.Count)); - BindBillboardPipeline( - encoder, - frame, - viewProjection, - additive, - ring.Buffer, - ring.OffsetBytes); + BindBillboardPipeline(encoder, viewProjection, additive, ring.Buffer, ring.OffsetBytes); encoder.DrawIndexed( (uint)QuadIndices.Length, (uint)instances.Count, @@ -407,7 +401,6 @@ public sealed unsafe partial class ParticleRenderer /// private void BindBillboardPipeline( IGpuPassEncoder encoder, - IGpuFrame frame, Matrix4x4 viewProjection, bool additive, IGpuBuffer instanceBuffer, @@ -433,15 +426,10 @@ public sealed unsafe partial class ParticleRenderer encoder.BindVertexBuffer(0, _quadVertexBuffer!, 0); encoder.BindVertexBuffer(1, instanceBuffer, instanceOffsetBytes); encoder.BindIndexBuffer(_quadIndexBuffer!, 0, GpuIndexType.UInt32); - WorldFrameSectionBinding.BindClipRegions( - encoder, - _scope!.Sections, - frame); } private void DrawMeshBatchRhi( IGpuPassEncoder encoder, - IGpuFrame frame, GlobalMeshBuffer global, ObjectRenderBatch batch, Matrix4x4 viewProjection, @@ -484,10 +472,6 @@ public sealed unsafe partial class ParticleRenderer "The shared mesh arena has no index store."), 0, GpuIndexType.UInt16); - WorldFrameSectionBinding.BindClipRegions( - encoder, - _scope!.Sections, - frame); encoder.DrawIndexed( (uint)batch.IndexCount, instanceCount, @@ -536,8 +520,9 @@ public sealed unsafe partial class ParticleRenderer Array.Resize(ref _preparedInstanceOffsets, count + 256); if (_instanceScratch.Length < count) Array.Resize(ref _instanceScratch, count + 256); - if (_meshInstanceScratch.Length < count) - _meshInstanceScratch = new MeshParticleGpuInstance[count + 256]; + int neededMeshFloats = count * MeshInstanceFloats; + if (_meshInstanceScratch.Length < neededMeshFloats) + _meshInstanceScratch = new float[neededMeshFloats + 256 * MeshInstanceFloats]; int billboardCount = 0; int meshCount = 0; @@ -556,7 +541,8 @@ public sealed unsafe partial class ParticleRenderer { _preparedInstanceOffsets[i] = (uint)meshCount; WriteMeshGpuInstance( - ref _meshInstanceScratch[meshCount++], + _meshInstanceScratch, + meshCount++ * MeshInstanceFloats, deferred.Mesh.Instance); } } @@ -567,9 +553,9 @@ public sealed unsafe partial class ParticleRenderer _instanceScratch.AsSpan(0, billboardCount))) : default; _preparedMeshInstances = meshCount > 0 - ? SectionOf(WriteVertexRing( + ? SectionOf(WriteVertexRing( frame, - _meshInstanceScratch.AsSpan(0, meshCount))) + _meshInstanceScratch.AsSpan(0, meshCount * MeshInstanceFloats))) : default; _preparedAlphaCount = count; } @@ -605,7 +591,6 @@ public sealed unsafe partial class ParticleRenderer { BindBillboardPipeline( encoder, - RequireRhiFrame(), viewProjection, key.Additive, billboards, @@ -647,7 +632,6 @@ public sealed unsafe partial class ParticleRenderer { DrawMeshBatchRhi( encoder, - RequireRhiFrame(), global, batch, meshViewProjection, diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 6a963121..63036896 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -47,7 +47,6 @@ public sealed unsafe partial class ParticleRenderer : IDisposable public readonly uint ColorArgb; public readonly AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot; public readonly float DistanceSq; - public readonly uint ClipSlot; public ParticleInstance( Vector3 position, @@ -55,8 +54,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable Vector3 axisY, uint colorArgb, AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot, - float distanceSq, - uint clipSlot) + float distanceSq) { Position = position; AxisX = axisX; @@ -64,7 +62,6 @@ public sealed unsafe partial class ParticleRenderer : IDisposable ColorArgb = colorArgb; TextureSlot = textureSlot; DistanceSq = distanceSq; - ClipSlot = clipSlot; } } @@ -83,16 +80,6 @@ public sealed unsafe partial class ParticleRenderer : IDisposable public Vector4 AxisY; public Vector4 Color; public uint TextureIndex; - public uint ClipSlot; - } - - /// Vertex-instance ABI shared with particle_mesh.vert. - [StructLayout(LayoutKind.Sequential)] - internal struct MeshParticleGpuInstance - { - public Matrix4x4 Model; - public Vector4 Color; - public uint ClipSlot; } private readonly struct MeshParticleInstance @@ -100,18 +87,12 @@ public sealed unsafe partial class ParticleRenderer : IDisposable public readonly Matrix4x4 Model; public readonly uint ColorArgb; public readonly float DistanceSq; - public readonly uint ClipSlot; - public MeshParticleInstance( - Matrix4x4 model, - uint colorArgb, - float distanceSq, - uint clipSlot) + public MeshParticleInstance(Matrix4x4 model, uint colorArgb, float distanceSq) { Model = model; ColorArgb = colorArgb; DistanceSq = distanceSq; - ClipSlot = clipSlot; } } @@ -143,7 +124,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics => (0, 0); private BillboardGpuInstance[] _instanceScratch = new BillboardGpuInstance[256]; - private MeshParticleGpuInstance[] _meshInstanceScratch = new MeshParticleGpuInstance[256]; + private float[] _meshInstanceScratch = new float[256 * 20]; // MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame // (sky pre/post, scene, per-visible-cell, dynamics, unattached passes), @@ -222,8 +203,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable cameraRight, cameraUp, emitterFilter, - scopedEmitters: null, - clipSlot: 0); + scopedEmitters: null); FinishDraw(camera, renderPass); } @@ -233,8 +213,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable ParticleRenderPass renderPass, IReadOnlySet attachedOwnerIds, bool includeUnattached = false, - IReadOnlySet? excludedAttachedOwnerIds = null, - uint clipSlot = 0) + IReadOnlySet? excludedAttachedOwnerIds = null) { if (camera is null) return; @@ -254,8 +233,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable cameraRight, cameraUp, emitterFilter: null, - _scopedEmitterScratch, - clipSlot); + _scopedEmitterScratch); FinishDraw(camera, renderPass); } @@ -354,8 +332,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable Vector3 cameraRight, Vector3 cameraUp, Func? emitterFilter, - IReadOnlyList? scopedEmitters, - uint clipSlot) + IReadOnlyList? scopedEmitters) { var draws = _drawListScratch; draws.Clear(); @@ -371,7 +348,6 @@ public sealed unsafe partial class ParticleRenderer : IDisposable cameraWorldPos, cameraRight, cameraUp, - clipSlot, ref sequence); } return; @@ -380,13 +356,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable foreach (RuntimeParticleEmitter emitter in _particles.EnumerateRenderableEmitters(renderPass)) { if (emitterFilter is null || emitterFilter(emitter)) - AppendEmitterDraws( - emitter, - cameraWorldPos, - cameraRight, - cameraUp, - clipSlot, - ref sequence); + AppendEmitterDraws(emitter, cameraWorldPos, cameraRight, cameraUp, ref sequence); } } @@ -395,7 +365,6 @@ public sealed unsafe partial class ParticleRenderer : IDisposable Vector3 cameraWorldPos, Vector3 cameraRight, Vector3 cameraUp, - uint clipSlot, ref int sequence) { List draws = _drawListScratch; @@ -416,13 +385,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId; if (gfxObjId != 0 && ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh - && TryAppendMeshDraws( - em, - p, - gfxObjId, - cameraWorldPos, - clipSlot, - ref sequence)) + && TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence)) { continue; } @@ -520,8 +483,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable axisY, p.ColorArgb, gfxInfo.TextureSlot, - distSq, - clipSlot))); + distSq))); _submissionScratch.Add(new ParticleSubmission( ParticleSubmissionKind.Billboard, drawIndex, @@ -535,7 +497,6 @@ public sealed unsafe partial class ParticleRenderer : IDisposable Particle particle, uint gfxObjId, Vector3 cameraWorldPosition, - uint clipSlot, ref int sequence) { if (_meshAdapter is null || !MeshParticlesAvailable) @@ -559,11 +520,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable model, cameraWorldPosition); float distanceSq = viewerDistance * viewerDistance; - var instance = new MeshParticleInstance( - model, - particle.ColorArgb, - distanceSq, - clipSlot); + var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq); for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++) { @@ -624,24 +581,35 @@ public sealed unsafe partial class ParticleRenderer : IDisposable TextureIndex = particle.TextureSlot.IsAssigned ? particle.TextureSlot.Index : NoTextureSlot, - ClipSlot = particle.ClipSlot, }; } private static void WriteMeshGpuInstance( - ref MeshParticleGpuInstance destination, + float[] destination, + int offset, MeshParticleInstance instance) { - destination = new MeshParticleGpuInstance - { - Model = instance.Model, - Color = new Vector4( - ((instance.ColorArgb >> 16) & 0xFF) / 255f, - ((instance.ColorArgb >> 8) & 0xFF) / 255f, - (instance.ColorArgb & 0xFF) / 255f, - ((instance.ColorArgb >> 24) & 0xFF) / 255f), - ClipSlot = instance.ClipSlot, - }; + Matrix4x4 model = instance.Model; + destination[offset + 0] = model.M11; + destination[offset + 1] = model.M12; + destination[offset + 2] = model.M13; + destination[offset + 3] = model.M14; + destination[offset + 4] = model.M21; + destination[offset + 5] = model.M22; + destination[offset + 6] = model.M23; + destination[offset + 7] = model.M24; + destination[offset + 8] = model.M31; + destination[offset + 9] = model.M32; + destination[offset + 10] = model.M33; + destination[offset + 11] = model.M34; + destination[offset + 12] = model.M41; + destination[offset + 13] = model.M42; + destination[offset + 14] = model.M43; + destination[offset + 15] = model.M44; + destination[offset + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f; + destination[offset + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f; + destination[offset + 18] = (instance.ColorArgb & 0xFF) / 255f; + destination[offset + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f; } private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch) diff --git a/src/AcDream.App/Rendering/PlayerPresentationProbe.cs b/src/AcDream.App/Rendering/PlayerPresentationProbe.cs new file mode 100644 index 00000000..d3a471b0 --- /dev/null +++ b/src/AcDream.App/Rendering/PlayerPresentationProbe.cs @@ -0,0 +1,75 @@ +using System.Diagnostics; +using System.Globalization; +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// TEMPORARY #429 apparatus. One CSV row per rendered frame: +/// seconds,camX,camY,camZ,playerX,playerY,playerZ, where seconds is a +/// Stopwatch-derived monotonic time and player is the frame's +/// PlayerViewPosition — the presented local-player position driving +/// lighting/visibility this frame. Off unless +/// ACDREAM_PROBE_PLAYER_PRESENT=<path> is set (one null check per +/// frame). Analysis: a hitch frame shows the player's per-frame delta +/// collapsing to ~0 or doubling; whether the SAME frame's dt is smooth +/// separates a presentation-phase bug (pack frame graph sampling a stale +/// snapshot) from genuine frame-pacing spikes. Strip with the #429 fix. +/// +internal sealed class PlayerPresentationProbe : IDisposable +{ + private readonly StreamWriter _writer; + private readonly long _startTimestamp = Stopwatch.GetTimestamp(); + private int _linesSinceFlush; + + private PlayerPresentationProbe(StreamWriter writer) + { + _writer = writer; + _writer.WriteLine("seconds,camX,camY,camZ,playerX,playerY,playerZ"); + } + + internal static PlayerPresentationProbe? CreateFromEnvironment() + { + string? path = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PLAYER_PRESENT"); + if (string.IsNullOrWhiteSpace(path)) + return null; + + try + { + return new PlayerPresentationProbe(new StreamWriter(path, append: false)); + } + catch (Exception ex) + { + Console.Error.WriteLine( + $"[player-present] probe file '{path}' could not be opened: {ex.Message}"); + return null; + } + } + + internal void Observe(in Vector3 cameraPosition, in Vector3 playerViewPosition) + { + double seconds = (Stopwatch.GetTimestamp() - _startTimestamp) + / (double)Stopwatch.Frequency; + _writer.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"{seconds:F6},{cameraPosition.X:F4},{cameraPosition.Y:F4},{cameraPosition.Z:F4},{playerViewPosition.X:F4},{playerViewPosition.Y:F4},{playerViewPosition.Z:F4}")); + if (++_linesSinceFlush >= 240) + { + _linesSinceFlush = 0; + _writer.Flush(); + } + } + + public void Dispose() + { + try + { + _writer.Flush(); + _writer.Dispose(); + } + catch + { + // A probe must never turn teardown fallible. + } + } +} diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs index c1f0fea3..ba4614cf 100644 --- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -1,7 +1,6 @@ using System.Numerics; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Wb; -using AcDream.App.Streaming; using AcDream.App.UI; using AcDream.Content.Vfx; using AcDream.Core.Lighting; @@ -97,7 +96,6 @@ public sealed class PortalTunnelPresentation : IDisposable private float _rotationEndAngle; private float _rotationCurrentAngle; private bool _waitCueVisible; - private bool _probeFreezeReached; private bool _disposeRequested; private bool _disposing; private bool _disposed; @@ -258,7 +256,6 @@ public sealed class PortalTunnelPresentation : IDisposable _rotationCurrentAngle = 0f; _camera.DirectionDegrees = 0f; _waitCueVisible = false; - _probeFreezeReached = false; _visible = true; RebuildPose(); } @@ -270,7 +267,6 @@ public sealed class PortalTunnelPresentation : IDisposable return; _visible = false; _waitCueVisible = false; - _probeFreezeReached = false; _animationHooks.Clear(); _sequence.ClearAnimations(); } @@ -279,25 +275,11 @@ public sealed class PortalTunnelPresentation : IDisposable { if (!_visible || dt < 0f) return; - if (_probeFreezeReached) - return; _sequence.Update(dt, frame: null); RebuildPose(); _animationHooks.Drain(Vector3.Zero); TickRotation(dt); - - if (StreamingDiagnostics.TunnelFreezeFrame is not { } freezeFrame - || CurrentAnimationFrame < freezeFrame) - { - return; - } - - _probeFreezeReached = true; - Console.WriteLine( - $"[tunnel-freeze] frame={CurrentAnimationFrame} " - + $"target={freezeFrame} setup=0x{_setupDid:X8} " - + $"animation=0x{_animationDid:X8} state=held"); } /// diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index c0ef5f89..2edaaf2a 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -26,21 +26,11 @@ public sealed class PortalVisibilityFrame private int _processedViewCountsUnderusedFrames; private int _orderedVisibleCellsUnderusedFrames; private int _todoUnderusedFrames; - private int _exteriorSeedPortalsUnderusedFrames; internal PortalPolygonVertexStore PolygonVertices => _polygonVertices; internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount; internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount; - // Interior-root look-ins are constructed one building at a time. Keep - // the source identity on the retained frame so the landscape pass can - // pair retail's portal-only traversal with that building's own exterior - // shell instead of repainting every nearby shell after every look-in. - // Building ids are publication-local, so the landblock is part of the - // identity. An unstamped building uses its seed cell id as the key. - internal uint SourceBuildingKey { get; set; } - internal uint SourceBuildingLandblockId { get; set; } - /// Screen region (NDC) where outdoor terrain/scenery may draw — exit portals /// recursively clipped to their portal chain. The cellar-flap fix. public CellView OutsideView { get; private set; } = new(); @@ -59,15 +49,6 @@ public sealed class PortalVisibilityFrame /// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5). public Dictionary CrossBuildingViews { get; } = new(); - /// - /// Exact outside-facing building portals that successfully seeded this - /// exterior construction, together with the clipped view region produced - /// by that portal. Retail's DrawBuilding pass punches these CBldPortal - /// apertures themselves; it does not infer them later from every exit on a - /// cell reached by the resulting flood. - /// - public List ExteriorSeedPortals { get; } = new(); - // Build scratch belongs to the frame so a caller that reuses a frame also reuses the large // hash tables and the 128-entry convergence trace. This is especially important outdoors, // where retail runs one small ConstructView flood per nearby building every frame. @@ -133,7 +114,6 @@ public sealed class PortalVisibilityFrame int processedViewCount = ProcessedViewCountsScratch.Count; int orderedVisibleCellCount = OrderedVisibleCells.Count; int todoCount = TodoScratch.Count; - int exteriorSeedPortalCount = ExteriorSeedPortals.Count; if (OutsideView.IsRetainable) OutsideView.Reset(); @@ -141,14 +121,9 @@ public sealed class PortalVisibilityFrame OutsideView = new CellView(); ReturnCellViews(CellViews); ReturnCellViews(CrossBuildingViews); - for (int index = 0; index < ExteriorSeedPortals.Count; index++) - ReturnCellView(ExteriorSeedPortals[index].View); CellViews.Clear(); OrderedVisibleCells.Clear(); CrossBuildingViews.Clear(); - ExteriorSeedPortals.Clear(); - SourceBuildingKey = 0; - SourceBuildingLandblockId = 0; QueuedScratch.Clear(); DrawListedScratch.Clear(); ProcessedViewCountsScratch.Clear(); @@ -177,10 +152,6 @@ public sealed class PortalVisibilityFrame orderedVisibleCellCount, ref _orderedVisibleCellsUnderusedFrames); TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames); - TrimIfCold( - ExteriorSeedPortals, - exteriorSeedPortalCount, - ref _exteriorSeedPortalsUnderusedFrames); } internal ViewPolygon CopyPolygon(ReadOnlySpan vertices) @@ -207,14 +178,11 @@ public sealed class PortalVisibilityFrame private void ReturnCellViews(Dictionary views) { foreach (CellView view in views.Values) - ReturnCellView(view); - } - - private void ReturnCellView(CellView view) - { - view.Reset(); - if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews) - _cellViewPool.Push(view); + { + view.Reset(); + if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews) + _cellViewPool.Push(view); + } } private static void TrimIfCold( @@ -275,15 +243,6 @@ public sealed class PortalVisibilityFrame } } -/// -/// One outside-facing portal accepted as an exterior building-view seed. -/// is the exact installed-view-clipped aperture. -/// -public readonly record struct ExteriorPortalSeed( - uint CellId, - int PortalIndex, - CellView View); - public static class PortalVisibilityBuilder { // Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995 @@ -306,6 +265,12 @@ public static class PortalVisibilityBuilder // stands near a doorway plane). private const float SeedInPlaneEpsilon = 0.0002f; + // TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the + // local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell. + private static readonly bool s_pvDump = + Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1"; + private static readonly Dictionary s_pvDumpCount = new(); + /// /// #120 observable: total convergence-tripwire firings across both the /// interior and the exterior look-in propagation. @@ -421,6 +386,36 @@ public static class PortalVisibilityBuilder int churnReenqueues = 0; var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null; + bool pvDump = false; + if (s_pvDump) + { + lock (s_pvDumpCount) + { + s_pvDumpCount.TryGetValue(cameraCell.CellId, out int dc); + if (dc < 2) { s_pvDumpCount[cameraCell.CellId] = dc + 1; pvDump = true; } + } + if (pvDump) + { + Console.WriteLine($"[pv-dump] camCell=0x{cameraCell.CellId:X8} portals={cameraCell.Portals.Count} polyLists={cameraCell.PortalPolygons.Count} vp[M11={viewProj.M11:F3} M22={viewProj.M22:F3} M33={viewProj.M33:F3} M34={viewProj.M34:F3} M43={viewProj.M43:F3} M44={viewProj.M44:F3}]"); + // Camera-cell portal census (A8.F triage 2026-05-29): report, for EVERY + // portal, the exact inputs the BFS guards read — BEFORE the guards run, so + // a portal the loop silently `continue`s past is still visible here. An + // empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty + // polygon from EnvCellLandblockBuildBuilder), interiorSide=false (camera back-facing the + // portal — a legitimately-empty result, not a bug), or (if both OK) a + // downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines. + for (int ci = 0; ci < cameraCell.Portals.Count; ci++) + { + int plen = ci < cameraCell.PortalPolygons.Count + ? (cameraCell.PortalPolygons[ci]?.Length ?? -1) : -2; + bool hasPlane = ci < cameraCell.ClipPlanes.Count; + bool interiorSide = !hasPlane || CameraOnInteriorSide(cameraCell, ci, cameraPos); + var n = hasPlane ? cameraCell.ClipPlanes[ci].Normal : Vector3.Zero; + Console.WriteLine($"[pv-dump] CAMPORTAL[{ci}] other=0x{cameraCell.Portals[ci].OtherCellId:X4} polyLen={plen} hasPlane={hasPlane} interiorSide={interiorSide} planeN=({n.X:F3},{n.Y:F3},{n.Z:F3})"); + } + } + } + // T2 (BR-4): retail's growth propagation is IN PLACE, never by re-enqueue // — PView::AddViewToPortals (Ghidra 0x005a52d0, pc:433446): first // discovery enqueues via InsCellTodoList; growth into a cell whose @@ -486,6 +481,8 @@ public static class PortalVisibilityBuilder continue; } + bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF; + // (R-A2b Phase 1 pin, throwaway) Log the side-test inputs for EVERY portal so a back-portal // traversal (cell=0x..0173 p->0x0171) can be attributed to the side test. // Strip with the rest of the [pv-trace] apparatus. @@ -508,6 +505,7 @@ public static class PortalVisibilityBuilder && !CameraOnInteriorSide(cell, i, cameraPos)) { trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side"); + if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}"); continue; } @@ -528,6 +526,8 @@ public static class PortalVisibilityBuilder endCount - processedCount, clippedRegion, out int clipVerts); + if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})"); + if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}"); // Empty clip = no flood through this portal, period — retail's empty-GetClip rule // (polyClipFinish <3 survivors → reject; ClipPortals adds no view). The @@ -544,6 +544,13 @@ public static class PortalVisibilityBuilder if (portal.OtherCellId == 0xFFFF) { + if (pvDump) + { + Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} clipPolys={clippedRegion.Count}"); + Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]"); + foreach (var cp in clippedRegion) + Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]"); + } // Exit portal -> outdoors visible through this (clipped) opening. // OutsideView gates DRAWN color (terrain/sky/scissor), and the // shell that rasterizes this aperture draws +drawLiftZ above @@ -681,6 +688,9 @@ public static class PortalVisibilityBuilder ProcessCellPortals(cell, 0); } + if (pvDump) + Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}"); + // Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — read-only per-frame snapshot of the // root cell's per-portal side-test + projection + the frame's exit/visible counts. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) @@ -751,27 +761,10 @@ public static class PortalVisibilityBuilder // ever built from a knife-edge aperture. if (i < cell.ClipPlanes.Count) { + if (CameraOnInteriorSide(cell, i, cameraPos)) + continue; if (EyeInPlaneOfPortal(cell, i, cameraPos)) continue; - - // Do NOT reuse the ordinary EnvCell traversal tolerance - // here. PortalSideEpsilon deliberately admits a 1 cm - // stale-root margin, but retail ConstructView(CBldPortal) - // classifies the seed with Sidedness's exact F_EPSILON - // (0.0002 m). At Sanctuary the 0104 and 0106 exterior - // planes coincide; the chase eye can sit ~4 mm outside the - // opposite cathedral half while its EnvCell root remains - // 0104. Applying the 1 cm margin calls that eye "inside" - // 0106 and drops the entire look-in flood, exposing the - // landscape/waterfall through half the cathedral. - if (CameraOnInteriorSide( - cell, - i, - cameraPos, - SeedInPlaneEpsilon)) - { - continue; - } } float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); @@ -799,17 +792,6 @@ public static class PortalVisibilityBuilder if (clippedRegion.Count == 0) continue; - // Preserve the exact CBldPortal that produced this view. The - // renderer must punch only accepted seed apertures; iterating - // every OtherCellId==0xFFFF portal on the reached cell turns a - // single accepted opening into unrelated far-depth holes. - var seedPortalView = frame.RentCellView(); - AddRegion(seedPortalView, clippedRegion); - frame.ExteriorSeedPortals.Add(new ExteriorPortalSeed( - cell.CellId, - i, - seedPortalView)); - var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId); bool grew = AddRegion(seedView, clippedRegion); @@ -1137,17 +1119,13 @@ public static class PortalVisibilityBuilder // InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra // 0x005a4b70); building/exterior SEED portals additionally reject in-plane // via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0). - private static bool CameraOnInteriorSide( - LoadedCell cell, - int portalIndex, - Vector3 cameraPos, - float epsilon = PortalSideEpsilon) + private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos) { var plane = cell.ClipPlanes[portalIndex]; if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform); float dot = Vector3.Dot(plane.Normal, localCam) + plane.D; - return plane.InsideSide == 0 ? dot >= -epsilon : dot <= epsilon; + return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon; } // T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs index 84961aab..f293218b 100644 --- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs @@ -94,17 +94,11 @@ internal sealed class PrivateEntityViewportRenderer : /// feature does not exist for them, not just "unused". private readonly EntitySlot? _backdropSlot; - // One stable sampled texture-table slot is part of the retained viewport's - // presentation contract. Rotating the slot with the Vulkan flight index - // made the UI sample a freshly-created/cleared sibling after world reveal. - // The frame submission order already protects this target's write -> sample - // transition; keep its identity stable until resize or disposal. private IGpuRenderTarget? _target; private IGpuSampler? _sampler; private GpuTextureSlot _slot = GpuTextureSlot.Unassigned; private int _fbW; private int _fbH; - private bool _hasRenderedScene; public PrivateEntityViewportRenderer( IWorldPassScope scope, @@ -175,29 +169,6 @@ internal sealed class PrivateEntityViewportRenderer : public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity); - /// - /// Advances the private entity's mesh and texture-composite readiness - /// without allocating or clearing a render target. Paperdoll uses this - /// while its tab is hidden so first-open work is already resident. - /// - public bool Prepare() - { - if (!_mainSlot.PrepareForDraw() - || !(_backdropSlot?.PrepareForDraw() ?? true)) - { - return false; - } - WorldEntity? entity = _mainSlot.Entity; - if (entity is null || entity.MeshRefs.Count == 0) - { - return false; - } - IReadOnlyList entities = BuildDrawEntities( - _backdropSlot?.Entity, - entity); - return _dispatcher.PreparePrivateEntityResources(entities); - } - /// /// Sets or clears the environment backdrop entity drawn BEHIND the main /// entity — GF-7/GF-14's fix, retail's gmCG3DView::m_pbgObject. Only @@ -226,37 +197,10 @@ internal sealed class PrivateEntityViewportRenderer : /// public uint Render(int width, int height) { - // #443: acquiring a synthetic mesh reference only schedules CPU - // preparation/GPU upload; it does not make the mesh drawable. Keep the - // last completed private scene intact until every drawable mesh in the - // replacement has crossed that upload barrier. On first open there is - // no completed scene, so return zero and let the authored panel art - // show through instead of publishing a freshly-cleared black target. - bool mainReady = _mainSlot.PrepareForDraw(); - bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true; - if (!mainReady || !backdropReady) - { - return _mainSlot.Entity is not null - && _hasRenderedScene - && _slot.IsAssigned - ? UiTextureTableHandle.FromSlot(_slot) - : 0u; - } - WorldEntity? entity = _mainSlot.Entity; if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0) return 0u; - IReadOnlyList drawEntities = BuildDrawEntities( - _backdropSlot?.Entity, - entity); - if (!_dispatcher.PreparePrivateEntityResources(drawEntities)) - { - return _hasRenderedScene && _slot.IsAssigned - ? UiTextureTableHandle.FromSlot(_slot) - : 0u; - } - EnsureRenderTarget(width, height); if (_target is null) return 0u; @@ -292,6 +236,7 @@ internal sealed class PrivateEntityViewportRenderer : UploadCreatureLight(); + IReadOnlyList drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity); var entries = new (uint, Vector3, Vector3, IReadOnlyList, IReadOnlyDictionary?)[] @@ -311,7 +256,6 @@ internal sealed class PrivateEntityViewportRenderer : neverCullLandblockId: PrivateLandblockId, visibleCellIds: null, animatedEntityIds: _animatedIds); - _hasRenderedScene = true; return UiTextureTableHandle.FromSlot(_slot); } @@ -423,7 +367,6 @@ internal sealed class PrivateEntityViewportRenderer : _target = null; _fbW = 0; _fbH = 0; - _hasRenderedScene = false; } public void Dispose() @@ -462,88 +405,31 @@ internal sealed class PrivateEntityViewportRenderer : } } - /// - /// One private entity's mesh-reference/texture-owner lifetime, independent - /// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh - /// references while preparation/upload runs, but does not replace the - /// drawable entity until all of its actual - /// are resident. Kept internal so #443's lifetime/readiness behavior can - /// be pinned without constructing a live GPU device. - /// - internal sealed class EntitySlot + private static IEnumerable CollectMeshIds(WorldEntity entity) { - private sealed class MeshSnapshot - { - private readonly ulong[] _ownedIds; - private readonly int _drawMeshCount; - - private MeshSnapshot(ulong[] ownedIds, int drawMeshCount) - { - _ownedIds = ownedIds; - _drawMeshCount = drawMeshCount; - } - - public IReadOnlyList OwnedIds => _ownedIds; - - public static MeshSnapshot Capture(WorldEntity entity) - { - int drawMeshCount = entity.MeshRefs.Count; - var ids = new ulong[drawMeshCount + entity.PartOverrides.Count]; - for (int i = 0; i < drawMeshCount; i++) - ids[i] = entity.MeshRefs[i].GfxObjId; - for (int i = 0; i < entity.PartOverrides.Count; i++) - ids[drawMeshCount + i] = entity.PartOverrides[i].GfxObjId; - return new MeshSnapshot(ids, drawMeshCount); - } - - /// - /// Residency identity deliberately excludes part transforms, - /// palette ranges and surface overrides: changing those does not - /// require another mesh upload. A different entity instance still - /// stages a replacement so its fixed texture owner is refreshed. - /// - public bool Matches(WorldEntity entity) - { - if (entity.MeshRefs.Count != _drawMeshCount - || entity.PartOverrides.Count != _ownedIds.Length - _drawMeshCount) - { - return false; - } - - for (int i = 0; i < _drawMeshCount; i++) - if (entity.MeshRefs[i].GfxObjId != _ownedIds[i]) - return false; - for (int i = 0; i < entity.PartOverrides.Count; i++) - if (entity.PartOverrides[i].GfxObjId != _ownedIds[_drawMeshCount + i]) - return false; - return true; - } - - public bool AreDrawMeshesReady(IWbMeshAdapter adapter) - { - for (int i = 0; i < _drawMeshCount; i++) - { - ulong id = _ownedIds[i]; - if (id == 0u || !adapter.IsRenderDataReady(id)) - return false; - } - return true; - } - } - - private sealed record PendingEntity( - WorldEntity Entity, - MeshSnapshot Snapshot, - SyntheticEntityMeshReferenceOwner MeshReferences); + for (int i = 0; i < entity.MeshRefs.Count; i++) + yield return entity.MeshRefs[i].GfxObjId; + for (int i = 0; i < entity.PartOverrides.Count; i++) + yield return entity.PartOverrides[i].GfxObjId; + } + /// + /// One private entity's own mesh-reference/texture-owner lifetime, + /// independent of any other slot on the same renderer. Factored out at + /// Campaign CC gate round 1 Batch D so the chargen backdrop entity gets + /// the EXACT SAME acquire/replace/retire behavior the main entity already + /// had — a single-owner class shared by both slots rather than a second, + /// hand-duplicated copy of 's + /// pre-Batch-D body. + /// + private sealed class EntitySlot + { private readonly IWbMeshAdapter _meshAdapter; private readonly FixedEntityTextureOwnerLease _textureOwnerLease; private readonly string _diagnosticName; private readonly List _retiringMeshReferences = []; private SyntheticEntityMeshReferenceOwner? _meshReferences; - private MeshSnapshot? _meshSnapshot; - private PendingEntity? _pending; public EntitySlot( IWbMeshAdapter meshAdapter, @@ -558,181 +444,75 @@ internal sealed class PrivateEntityViewportRenderer : public WorldEntity? Entity { get; private set; } - internal bool HasPending => _pending is not null; - public void Set(WorldEntity? entity) { ReleaseRetiringMeshReferences(); - if (entity is null) - { - Clear(); - return; - } - - // The common animated appraisal path reuses one clone object. Let - // PrepareForDraw perform its allocation-free mesh-id comparison; - // if no candidate is pending, reference equality is enough here. - if (ReferenceEquals(Entity, entity) && _pending is null) + if (ReferenceEquals(Entity, entity)) return; - if (ReferenceEquals(Entity, entity) - && _meshSnapshot?.Matches(entity) == true) - { - ReleasePending(); - return; - } - - if (_pending is { } pending - && ReferenceEquals(pending.Entity, entity) - && pending.Snapshot.Matches(entity)) - { - return; - } - - Stage(entity); - } - - /// - /// Refreshes an in-place MeshRefs mutation, re-arms missing uploads, - /// and atomically promotes a fully drawable candidate. False tells the - /// renderer to preserve its last completed render target this frame. - /// - public bool PrepareForDraw() - { - ReleaseRetiringMeshReferences(); - - if (_pending is { } pending) - { - if (!pending.Snapshot.Matches(pending.Entity)) - Stage(pending.Entity); - } - else if (Entity is { } current - && _meshSnapshot?.Matches(current) != true) - { - // Chargen animation and appraisal synchronization both mutate - // a retained WorldEntity in place. Detect a changed GfxObj set - // here even when the caller did not issue another Set call. - Stage(current); - } - - pending = _pending; - if (pending is null) - return true; - if (!pending.Snapshot.AreDrawMeshesReady(_meshAdapter)) - return false; - - PromotePending(pending); - return true; - } - - private void Stage(WorldEntity entity) - { - MeshSnapshot snapshot = MeshSnapshot.Capture(entity); - var replacement = new SyntheticEntityMeshReferenceOwner( - _meshAdapter, - snapshot.OwnedIds); - try + SyntheticEntityMeshReferenceOwner? replacement = null; + if (entity is not null) { + replacement = new SyntheticEntityMeshReferenceOwner( + _meshAdapter, + CollectMeshIds(entity)); replacement.Acquire(); } - catch (Exception acquisitionFailure) + + SyntheticEntityMeshReferenceOwner? previous = _meshReferences; + try { + _textureOwnerLease.Replace(entity is not null); + } + catch (Exception textureFailure) + { + if (replacement is null) + throw; + try { replacement.Dispose(); } catch (Exception rollbackFailure) { - _retiringMeshReferences.Add(replacement); throw new AggregateException( - $"The {_diagnosticName} candidate mesh acquisition failed " - + "and its rollback did not converge.", - acquisitionFailure, + $"The {_diagnosticName} texture-owner replacement failed " + + "and the replacement mesh-owner rollback did not converge.", + textureFailure, rollbackFailure); } System.Runtime.ExceptionServices.ExceptionDispatchInfo - .Capture(acquisitionFailure) + .Capture(textureFailure) .Throw(); - throw new InvalidOperationException("Unreachable exception dispatch path."); } - PendingEntity? previous = _pending; - _pending = new PendingEntity(entity, snapshot, replacement); - if (previous is not null) - Retire(previous.MeshReferences); - } - - private void PromotePending(PendingEntity pending) - { - // Release the fixed texture owner's prior composites only at the - // same atomic edge that publishes the new mesh set. If release - // fails, the candidate remains pending and can retry intact. - _textureOwnerLease.Replace(hasReplacement: true); - - SyntheticEntityMeshReferenceOwner? previous = _meshReferences; - _meshReferences = pending.MeshReferences; - _meshSnapshot = pending.Snapshot; - Entity = pending.Entity; - _pending = null; + _meshReferences = replacement; + Entity = entity; if (previous is not null) - Retire(previous); - } - - private void Clear() - { - if (Entity is null && _pending is null) - return; - - ReleasePending(); - _textureOwnerLease.Replace(hasReplacement: false); - - SyntheticEntityMeshReferenceOwner? previous = _meshReferences; - _meshReferences = null; - _meshSnapshot = null; - Entity = null; - if (previous is not null) - Retire(previous); - } - - private void ReleasePending() - { - PendingEntity? pending = _pending; - if (pending is null) - return; - _pending = null; - Retire(pending.MeshReferences); - } - - private void Retire(SyntheticEntityMeshReferenceOwner owner) - { - try { - owner.Dispose(); - } - catch - { - _retiringMeshReferences.Add(owner); - throw; + try + { + previous.Dispose(); + } + catch + { + _retiringMeshReferences.Add(previous); + throw; + } } } public void Dispose() { Entity = null; - _meshSnapshot = null; if (_meshReferences is { } current) { _meshReferences = null; _retiringMeshReferences.Add(current); } - if (_pending is { } pending) - { - _pending = null; - _retiringMeshReferences.Add(pending.MeshReferences); - } List? failures = null; try diff --git a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs index b9a331fe..7af1b277 100644 --- a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs @@ -128,10 +128,9 @@ internal readonly record struct ProcessResourceDiagnostics( int TrackedGpuTextures); /// -/// Immutable resource facts captured only by an explicit low-frequency -/// diagnostic such as UI-probe dumping or reveal timing. Grouping keeps the -/// consumers independent from every canonical renderer, VFX, mesh, texture, -/// and process owner used to produce the values. +/// Immutable resource facts captured only when explicit UI-probe dumping is enabled. +/// Grouping keeps the diagnostics controller independent from every canonical renderer, +/// VFX, mesh, texture, and process owner used to produce the values. /// internal readonly record struct RenderFrameResourceDiagnosticsSnapshot( VfxStreamResourceDiagnostics Vfx, diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index 5362b457..991466e1 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -29,12 +29,6 @@ namespace AcDream.App.Rendering; /// public sealed class RetailChaseCamera : ICamera { - private const float RetailDefaultBack = 2.5f; - private const float RetailDefaultUp = 0.75f; - private const float RetailLookDownBack = 2f; - private const float RetailMapBack = 450f; - private const float RetailFirstPersonForward = 0.18f; - // ICamera surface. public Vector3 Position { get; private set; } @@ -81,20 +75,6 @@ public sealed class RetailChaseCamera : ICamera /// Height of look-at anchor above the player's feet (m). Retail default 1.5. public float PivotHeight { get; set; } = 1.5f; - private bool _lookingDown; - private bool _mapMode; - private bool _inHead; - private bool _savedInHead; - private float _savedDistance; - private float _savedPitch; - private float _savedYawOffset; - private Vector3? _targetDirectionLocal; - private Vector3? _savedTargetDirectionLocal; - - public bool IsLookingDown => _lookingDown; - public bool IsMapMode => _mapMode; - public bool IsInHead => _inHead; - /// /// Optional spring-arm collision probe. When set (and /// is true), the damped eye @@ -192,13 +172,8 @@ public sealed class RetailChaseCamera : ICamera // target supplies the frame heading. Without this local rotation, enabling // Keep in View snaps the camera behind the target and disables RMB orbit. float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f; - (Vector3 targetEye, Vector3 targetForward) = _inHead - ? ComputeInHeadPose(pivotWorld, heading) - : _targetDirectionLocal is { } localDirection - ? ComputeTargetDirectionPose( - pivotWorld, heading, Distance, Pitch, localDirection) - : ComputeDesiredPose( - pivotWorld, heading, Distance, Pitch, viewerYawOffset); + (Vector3 targetEye, Vector3 targetForward) = ComputeDesiredPose( + pivotWorld, heading, Distance, Pitch, viewerYawOffset); // 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera // (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the @@ -304,120 +279,16 @@ public sealed class RetailChaseCamera : ICamera /// ... Mirrors /// legacy ChaseCamera.AdjustDistance. /// - public void AdjustDistance(float delta) - { - ExitLookDownForAdjustment(); - ExitInHeadForAdjustment(); + public void AdjustDistance(float delta) => Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax); - } /// /// Adjust the camera pitch by a delta (radians), clamped to /// ... Mirrors legacy /// ChaseCamera.AdjustPitch. /// - public void AdjustPitch(float delta) - { - ExitLookDownForAdjustment(); - ExitInHeadForAdjustment(); + public void AdjustPitch(float delta) => Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); - } - - public void SetRetailDefaultView() - { - _lookingDown = false; - _mapMode = false; - _inHead = false; - _targetDirectionLocal = null; - YawOffset = 0f; - PivotHeight = 1.5f; - SetViewerOffset(RetailDefaultBack, RetailDefaultUp); - } - - public void SetRetailFirstPersonView() - { - _lookingDown = false; - _mapMode = false; - _inHead = true; - _targetDirectionLocal = null; - YawOffset = 0f; - Distance = RetailFirstPersonForward; - Pitch = 0f; - // Do not spend a transition frame inside the head/neck. Retail's - // SetInHead installs the new viewer offset as one camera preset. - _initialised = false; - } - - public void ToggleRetailLookDownView() - { - if (_lookingDown) - { - RestoreLookDownView(); - return; - } - SaveLookDownView(); - _lookingDown = true; - _mapMode = false; - _inHead = false; - _targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f); - SetViewerOffset(RetailLookDownBack, RetailDefaultUp); - } - - public void ToggleRetailMapModeView() - { - if (_mapMode) - { - RestoreLookDownView(); - return; - } - if (!_lookingDown) - SaveLookDownView(); - _lookingDown = true; - _mapMode = true; - _inHead = false; - _targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f); - SetViewerOffset(RetailMapBack, RetailDefaultUp); - } - - private void SaveLookDownView() - { - _savedDistance = Distance; - _savedPitch = Pitch; - _savedYawOffset = YawOffset; - _savedTargetDirectionLocal = _targetDirectionLocal; - _savedInHead = _inHead; - } - - private void RestoreLookDownView() - { - Distance = _savedDistance; - Pitch = _savedPitch; - YawOffset = _savedYawOffset; - _targetDirectionLocal = _savedTargetDirectionLocal; - _inHead = _savedInHead; - _lookingDown = false; - _mapMode = false; - } - - private void ExitLookDownForAdjustment() - { - if (_lookingDown) - RestoreLookDownView(); - } - - private void ExitInHeadForAdjustment() - { - if (!_inHead) - return; - _inHead = false; - Distance = DistanceMin; - } - - private void SetViewerOffset(float back, float up) - { - Distance = MathF.Sqrt(back * back + up * up); - Pitch = MathF.Atan2(up, back); - } /// /// Public entry point for the mouse-input low-pass filter. Calls @@ -565,47 +436,6 @@ public sealed class RetailChaseCamera : ICamera return (eye, forward); } - /// - /// Retail CameraSet::SetInHead @ 0x00458CE0: the target direction - /// is local +Y and the viewer offset is local +Y * 0.18. It is not a - /// negative chase boom looking back toward the player's neck. - /// - internal static (Vector3 eye, Vector3 forward) ComputeInHeadPose( - Vector3 pivotWorld, - Vector3 heading) - { - Vector3 forward = Vector3.Normalize(heading); - return (pivotWorld + forward * RetailFirstPersonForward, forward); - } - - /// - /// Transform both retail viewer_offset and target_direction - /// through the target frame. LookDown/MapMode do not merely point a - /// horizontally-positioned camera toward the ground: the downward target - /// direction pitches the frame whose -Y/+Z offset places the viewer. For - /// MapMode's (0,-450,0.75) offset this puts the viewer high above and only - /// slightly behind the character, matching CameraSet::SetMapMode. - /// - internal static (Vector3 eye, Vector3 forward) ComputeTargetDirectionPose( - Vector3 pivotWorld, - Vector3 heading, - float distance, - float pitch, - Vector3 targetDirectionLocal) - { - var (frameForward, frameRight, frameUp) = BuildBasis(heading); - Vector3 targetForward = Vector3.Normalize( - frameForward * targetDirectionLocal.Y - - frameRight * targetDirectionLocal.X - + frameUp * targetDirectionLocal.Z); - var (_, _, targetUp) = BuildBasis(targetForward); - - float back = distance * MathF.Cos(pitch); - float up = distance * MathF.Sin(pitch); - Vector3 eye = pivotWorld - targetForward * back + targetUp * up; - return (eye, targetForward); - } - /// /// Build an orthonormal basis with forward = heading. World /// up is (0, 0, 1); if heading is near-parallel to it diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 5b90654a..f329013c 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -134,8 +134,6 @@ internal sealed class RetailPViewPassExecutor : private readonly TerrainDrawDiagnosticsController _terrainDiagnostics; private readonly RetailPViewParticleClassifications _particleClassifications = new(); private readonly HashSet _noSceneParticleEntityIds = []; - private readonly Dictionary _singleCellClipRouting = new(1); - private readonly Dictionary _noCellClipRouting = new(0); /// /// Borrowed until the next late landscape pass. The outdoor-root post-world @@ -209,8 +207,6 @@ internal sealed class RetailPViewPassExecutor : { List? failures = null; TryAbort(_frameGlState.RestoreFrameDefaults); - TryAbort(() => _envCells.SetClipRouting(null)); - TryAbort(_entities.ClearClipRouting); TryAbort(_particleClassifications.BeginFrame); TryAbort(_noSceneParticleEntityIds.Clear); if (failures is { Count: > 0 }) @@ -234,11 +230,6 @@ internal sealed class RetailPViewPassExecutor : ClipFrameAssembly reuseAssembly) => ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly); - public void AppendLookInClipFrames( - IReadOnlyList lookInFrames, - ClipFrameAssembly assembly) => - ClipFrameAssembler.AppendLookInFrames(_clipFrame, lookInFrames, assembly); - public void PrepareClipFrame(int terrainUploadCount) => _surface.PrepareClipFrame(terrainUploadCount); @@ -255,24 +246,6 @@ internal sealed class RetailPViewPassExecutor : _entities.ClearClipRouting(); } - public void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice) - { - _singleCellClipRouting.Clear(); - _singleCellClipRouting.Add(cellId, slice.Slot); - _envCells.SetClipRouting(_singleCellClipRouting); - // Retail DrawMesh only viewcone-checks an object's sphere under the - // installed PortalList and then draws the mesh whole. Hard clipping the - // object here slices a stationary player when the chase camera crosses - // into the opposite cathedral cell while the player remains behind. - _entities.ClearClipRouting(); - } - - private void UseOutdoorPortalViewRouting(ClipViewSlice slice) => - _entities.SetClipRouting( - _noCellClipRouting, - outdoorSlot: slice.Slot, - outdoorVisible: true); - public void PrepareCellBatches( RetailPViewFrameInput frame, HashSet visibleCellIds) => @@ -329,6 +302,37 @@ internal sealed class RetailPViewPassExecutor : slice, sliceIndex); + public void EmitOutStageOwner( + WorldEntity entity, + Vector3 sphereCenter, + float sphereRadius, + int sliceIndex, + bool passed) => + _diagnostics.EmitOutStageOwner( + RenderingDiagnostics.ProbeOutStageEnabled, + RenderingDiagnostics.DumpEntitySourceIds, + entity, + sphereCenter, + sphereRadius, + sliceIndex, + passed); + + public void EmitOutStageRouting( + int sliceIndex, + IReadOnlyList entities, + ViewconeCuller viewcone) => + _diagnostics.EmitOutStageRouting( + RenderingDiagnostics.ProbeOutStageEnabled, + sliceIndex, + entities, + viewcone); + + public void EmitPhantomObjects(uint cellId, int survivorCount) => + _diagnostics.EmitPhantomObjects( + RenderingDiagnostics.ProbePhantomEnabled, + cellId, + survivorCount); + public void DrawLandscapeSlice( RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context) @@ -403,7 +407,6 @@ internal sealed class RetailPViewPassExecutor : if (scissor) _surface.EndScissor(); - _entities.ClearClipRouting(); DisableClipDistances(); } @@ -446,7 +449,13 @@ internal sealed class RetailPViewPassExecutor : _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); - if (_particleClassifications.Outdoor.Count > 0 + _diagnostics.EmitOutStageParticles( + RenderingDiagnostics.ProbeOutStageEnabled, + _particles, + _particleClassifications.Outdoor); + + if (!frame.RootCell.IsOutdoorNode + && _particleClassifications.Outdoor.Count > 0 && _particles is not null && _particleRenderer is not null) { @@ -454,8 +463,7 @@ internal sealed class RetailPViewPassExecutor : frame.Camera, frame.CameraWorldPosition, ParticleRenderPass.Scene, - _particleClassifications.Outdoor, - clipSlot: (uint)context.Slice.Slot); + _particleClassifications.Outdoor); } EnableClipDistances(); @@ -484,77 +492,6 @@ internal sealed class RetailPViewPassExecutor : if (scissor) _surface.EndScissor(); - _entities.ClearClipRouting(); - DisableClipDistances(); - } - - public void DrawLandscapeStaticParticles( - RetailPViewFrameInput frame, - RetailPViewLandscapeStaticParticleContext context) - { - bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb); - _surface.BindTerrainClip(); - DisableClipDistances(); - - _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); - if (_particleClassifications.Outdoor.Count > 0 - && _particles is not null - && _particleRenderer is not null) - { - _particleRenderer.DrawForOwners( - frame.Camera, - frame.CameraWorldPosition, - ParticleRenderPass.Scene, - _particleClassifications.Outdoor, - clipSlot: (uint)context.Slice.Slot); - } - - if (scissor) - _surface.EndScissor(); - _entities.ClearClipRouting(); - DisableClipDistances(); - } - - public void DrawLandscapeBuildingShellSlice( - RetailPViewFrameInput frame, - RetailPViewLandscapeBuildingShellSliceContext context) - { - UseOutdoorPortalViewRouting(context.Slice); - bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb); - _surface.BindTerrainClip(); - DisableClipDistances(); - - if (context.EntityDraw is RenderFrameEntityDrawRequest request) - { - RenderFrameView drawView = request.View; - _entities.DrawPackedProductionRoute( - frame.Camera, - in drawView, - request.Route, - request.RouteIndex, - request.CellId, - request.TupleLandblockId); - } - else if (context.BuildingShells.Count > 0) - { - var buildingEntry = ( - frame.PlayerLandblockId ?? 0u, - Vector3.Zero, - Vector3.Zero, - context.BuildingShells, - (IReadOnlyDictionary?)null); - _entities.Draw( - frame.Camera, - new[] { buildingEntry }, - frame.Frustum, - neverCullLandblockId: frame.PlayerLandblockId, - visibleCellIds: null, - animatedEntityIds: frame.AnimatedEntityIds); - } - - if (scissor) - _surface.EndScissor(); - _entities.ClearClipRouting(); DisableClipDistances(); } @@ -567,13 +504,10 @@ internal sealed class RetailPViewPassExecutor : public void DrawLookInPortalPunch( RetailPViewFrameInput frame, - RetailPViewCellSliceContext context, - int portalIndex) => - DrawPortalDepthWrite(context, frame, forceFarZ: true, portalIndex); + RetailPViewCellSliceContext context) => + DrawPortalDepthWrite(context, frame, forceFarZ: true); - public void DrawUnattachedSceneParticles( - RetailPViewFrameInput frame, - ClipViewSlice slice) + public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) { if (_particles is null || _particleRenderer is null) return; @@ -584,8 +518,7 @@ internal sealed class RetailPViewPassExecutor : frame.CameraWorldPosition, ParticleRenderPass.Scene, _noSceneParticleEntityIds, - includeUnattached: true, - clipSlot: (uint)slice.Slot); + includeUnattached: true); } public void FlushLandscapeAlpha() => _alpha.Flush(); @@ -612,8 +545,7 @@ internal sealed class RetailPViewPassExecutor : frame.Camera, frame.CameraWorldPosition, ParticleRenderPass.Scene, - visible, - clipSlot: (uint)context.Slice.Slot); + visible); DisableClipDistances(); } @@ -643,6 +575,7 @@ internal sealed class RetailPViewPassExecutor : RetailPViewFrameInput frame, RetailPViewFrameResult result) => _diagnostics.EmitRetailPViewDiagnostics( + RenderingDiagnostics.ProbeViewerEnabled, RenderingDiagnostics.ProbeVisibilityEnabled, RenderingDiagnostics.ProbeFlapEnabled, result, @@ -651,13 +584,13 @@ internal sealed class RetailPViewPassExecutor : frame.PlayerCellId, frame.CameraWorldPosition, frame.PlayerViewPosition, + frame.CameraView, frame.CameraCellResolution); private void DrawPortalDepthWrite( RetailPViewCellSliceContext context, RetailPViewFrameInput frame, - bool forceFarZ, - int? onlyPortalIndex = null) + bool forceFarZ) { // Retail D3DPolyRender::DrawPortalPolyInternal @ 0x0059BC90. // Main interior roots stamp true depth (seal); outdoor and look-in @@ -671,8 +604,6 @@ internal sealed class RetailPViewPassExecutor : Span world = stackalloc Vector3[32]; for (int index = 0; index < cell.Portals.Count; index++) { - if (onlyPortalIndex.HasValue && index != onlyPortalIndex.Value) - continue; if (cell.Portals[index].OtherCellId != 0xFFFF) continue; if (index >= cell.PortalPolygons.Count) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 0aa5b947..9b515852 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -50,10 +50,8 @@ public sealed class RetailPViewRenderer private readonly Stack _lookInFramePool = new(); private readonly HashSet _lookInPrepareScratch = new(); - // #131/#132: landscape scene-particle owner survivors. With building - // look-ins, static owners use the pre-building alpha barrier and the late - // phase contains only outside-stage dynamics; otherwise the late phase - // carries both sets. + // #131/#132: the late landscape phase's scene-particle owner survivors + // (statics + outside-stage dynamics passing the slice cone). private readonly HashSet _lateParticleOwnerScratch = new(); private readonly HashSet _cellParticleOwnerScratch = new(); private readonly HashSet _dynamicParticleOwnerScratch = new(); @@ -146,7 +144,6 @@ public sealed class RetailPViewRenderer var clipAssembly = passes.AssembleClipFrame( pvFrame, _clipAssemblyScratch); - passes.AppendLookInClipFrames(_lookInFrames, clipAssembly); int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2); passes.PrepareClipFrame(terrainUploadCount); @@ -416,9 +413,6 @@ public sealed class RetailPViewRenderer group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection, OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons, reuseFrame: frameScratch); - LoadedCell sourceCell = group[0]; - frame.SourceBuildingKey = sourceCell.BuildingId ?? sourceCell.CellId; - frame.SourceBuildingLandblockId = sourceCell.CellId & 0xFFFF0000u; if (frame.OrderedVisibleCells.Count > 0) _lookInFrames.Add(frame); else @@ -460,67 +454,65 @@ public sealed class RetailPViewRenderer // then draw the flooded cells' shells + statics far→near (the nested // DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is // empty by construction — PView ctor draw_landscape=0 — so no recursive - // landscape/clear/seal). Retail CEnvCell::setup_view installs every cell's - // nested portal_view before DrawEnvCell, while DrawMesh iterates that same - // PortalList for cell objects. Preserve that per-slice gate here; drawing a - // nested cell whole lets its floor, details, and emitters escape the authored - // aperture even when the outer depth choreography is otherwise correct. + // landscape/clear/seal). Anything rasterized outside an aperture is + // repainted by the root's own shells after the depth clear, so over-draw + // here is color-safe; statics draw whole (the main viewcone has no entry + // for look-in cells; over-include is the safe direction). private void DrawBuildingLookIns( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result? partition, - ViewconeCuller viewcone, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { if (_lookInFrames.Count == 0) return; - int outsideSliceCount = clipAssembly.OutsideViewSlices.Length; - int lookInRouteIndex = 0; - for (int frameIndex = 0; frameIndex < _lookInFrames.Count; frameIndex++) + foreach (var frame in _lookInFrames) { - PortalVisibilityFrame frame = _lookInFrames[frameIndex]; - - // Retail enters DrawBuilding once per building and drains every - // alpha submission accumulated by the preceding building before - // punching the next building's portals. The first building uses - // the pre-look-in barrier in DrawLandscapeThroughOutsideView. - if (frameIndex > 0) - passes.FlushLandscapeAlpha(); - // Pass 1: far-Z punch every aperture of this building. - foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals) + foreach (uint cellId in frame.OrderedVisibleCells) { - foreach (var poly in seed.View.Polygons) + if (!frame.CellViews.TryGetValue(cellId, out var view)) + continue; + foreach (var poly in view.Polygons) { var cps = ClipPlaneSet.From(poly); if (cps.IsNothingVisible) continue; passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext( - seed.CellId, + cellId, new ClipViewSlice( 0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), cps.PlaneArray), - NoParticleOwners), - seed.PortalIndex); + NoParticleOwners)); } } - // Pass 2: shells + objects, far→near, once per portal_view slice. + // Pass 2: shells + statics, far→near. + passes.UseIndoorMembershipOnlyRouting(); + + // Opaque shells batched per building into ONE Render (this building's + // aperture punches above already ran; z-buffer handles order and + // lighting is per-instance CellId-keyed) — was one heavy per-frame + // Render per cell. Per-cell entity/particle work stays in the loop. + _shellBatch.Clear(); + foreach (uint cid in frame.OrderedVisibleCells) + _shellBatch.Add(cid); + if (_shellBatch.Count > 0) + passes.DrawOpaqueCellShells(_shellBatch); + for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = frame.OrderedVisibleCells[i]; - var clipKey = new LookInClipCell(frameIndex, cellId); - if (!clipAssembly.LookInCellToViewSlices.TryGetValue( - clipKey, - out ClipViewSlice[]? cellSlices) - || cellSlices.Length == 0) - { - continue; - } + _oneCell.Clear(); + _oneCell.Add(cellId); + // Opaque shell batched above. Transparent stays per-cell (far→near) + // for correct compositing; skipped for opaque-only cells. + if (passes.CellHasTransparentShell(cellId)) + passes.DrawTransparentCellShells(_oneCell); _cellStaticScratch.Clear(); if (partition is not null @@ -536,7 +528,8 @@ public sealed class RetailPViewRenderer // post-clear they would z-fail against the root's seal anyway // (the #118 lesson). Retail draws a look-in cell's objects // inside the NESTED DrawCells (DrawObjCellForDummies, - // pc:432878+), i.e. right here in the landscape stage. + // pc:432878+), i.e. right here in the landscape stage. Drawn + // WHOLE like the statics (AP-33's documented over-include). // No double-draw: dynamics-last keeps culling them (their // cell is absent from the main cone), and their emitters ride // the DrawCellParticles call below, not DrawDynamicsParticles @@ -548,154 +541,48 @@ public sealed class RetailPViewRenderer _cellStaticScratch.Add(e); } - foreach (ClipViewSlice slice in cellSlices) + if (frameEntityPasses is not null) { - int routeIndex = lookInRouteIndex++; - passes.UseCellPortalViewRouting(cellId, slice); - _oneCell.Clear(); - _oneCell.Add(cellId); - passes.DrawOpaqueCellShells(_oneCell); - if (passes.CellHasTransparentShell(cellId)) - passes.DrawTransparentCellShells(_oneCell); + RenderFrameRouteOwnerSelector.Replace( + _cellParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LookInObject, + i, + cellId); + } + else + { + ReplaceOwnerIds( + _cellParticleOwnerScratch, + _cellStaticScratch); + } - if (frameEntityPasses is not null) - { - RenderFrameRouteOwnerSelector.Replace( - _cellParticleOwnerScratch, - in frameView, - RenderFrameCandidateRoute.LookInObject, - routeIndex, - cellId); - } - else - { - ReplaceOwnerIds( - _cellParticleOwnerScratch, - _cellStaticScratch); - } + if (frameEntityPasses is not null + || _cellStaticScratch.Count > 0) + { + _candidateObserver?.ObservePViewBucket( + CurrentRenderPViewRoute.LookInObject, + i, + cellId, + _cellStaticScratch); + DrawEntityRouteOrLegacy( + ctx, + passes, + frameEntityPasses, + in frameView, + RenderFrameCandidateRoute.LookInObject, + i, + cellId, + _cellStaticScratch, + _oneCell); - if (frameEntityPasses is not null - || _cellStaticScratch.Count > 0) - { - _candidateObserver?.ObservePViewBucket( - CurrentRenderPViewRoute.LookInObject, - routeIndex, - cellId, - _cellStaticScratch); - DrawEntityRouteOrLegacy( - ctx, - passes, - frameEntityPasses, - in frameView, - RenderFrameCandidateRoute.LookInObject, - routeIndex, - cellId, - _cellStaticScratch, - _oneCell); - - // The nested DrawCells object pass includes emitters and - // retains the exact setup_view clip until alpha playback. + // The cell-particles pass for look-in cells — retail's + // nested DrawCells draws objects WITH their emitters. + foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( cellId, slice, _cellParticleOwnerScratch)); - } } } - - // The ordinary exterior building shell is clipped by the outer - // outside_view, not by the nested cell PortalList. - passes.UseIndoorMembershipOnlyRouting(); - - // Retail's ordinary shell pass immediately follows this same - // building's portal-only pass. Pair by the shell's authored - // anchor EnvCell; never let an unrelated building repaint a - // look-in merely because both happen to be nearby. - int sliceIndex = 0; - foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices) - { - int shellRouteIndex = LookInBuildingShellRouteIndex( - frameIndex, - outsideSliceCount, - sliceIndex); - _buildingShellScratch.Clear(); - if (partition is not null) - { - foreach (WorldEntity entity in partition.OutdoorStatic) - { - if (!entity.IsBuildingShell - || FindLookInFrameIndex( - entity.BuildingShellAnchorCellId ?? 0, - _lookInFrames, - ctx.Cells) != frameIndex) - { - continue; - } - - EntitySphere(entity, out Vector3 center, out float radius); - if (viewcone.SphereVisibleInOutsideSlice( - sliceIndex, - center, - radius)) - { - _buildingShellScratch.Add(entity); - } - } - } - - _candidateObserver?.ObservePViewBucket( - CurrentRenderPViewRoute.LandscapeBuildingShell, - shellRouteIndex, - 0, - _buildingShellScratch); - bool hasPackedShell = frameEntityPasses is not null - && HasExactRoute( - in frameView, - RenderFrameCandidateRoute.LandscapeBuildingShell, - shellRouteIndex, - 0); - if (hasPackedShell || _buildingShellScratch.Count > 0) - { - RenderFrameEntityDrawRequest? shellDraw = - frameEntityPasses is null - ? null - : new RenderFrameEntityDrawRequest( - frameView, - RenderFrameCandidateRoute.LandscapeBuildingShell, - shellRouteIndex, - 0, - ctx.PlayerLandblockId ?? 0); - passes.DrawLandscapeBuildingShellSlice( - ctx, - new RetailPViewLandscapeBuildingShellSliceContext( - slice, - _buildingShellScratch) - { - EntityDraw = shellDraw, - }); - - _lateParticleOwnerScratch.Clear(); - if (frameEntityPasses is not null) - { - RenderFrameRouteOwnerSelector.Replace( - _lateParticleOwnerScratch, - in frameView, - RenderFrameCandidateRoute.LandscapeBuildingShell, - shellRouteIndex, - 0); - } - else - { - ReplaceOwnerIds( - _lateParticleOwnerScratch, - _buildingShellScratch); - } - passes.DrawLandscapeStaticParticles( - ctx, - new RetailPViewLandscapeStaticParticleContext( - slice, - _lateParticleOwnerScratch)); - } - sliceIndex++; - } } } @@ -711,13 +598,18 @@ public sealed class RetailPViewRenderer if (clipAssembly.OutsideViewSlices.Length == 0) return; - // #131/#132: retail drains the remaining landscape alpha after - // LScape::draw (DrawCells pc:432720), while each DrawBuilding is also - // an earlier alpha barrier before its portal traversal (pc:427954). - // Our dispatcher batches outdoor content, so the stage is split into: - // EARLY sky/terrain/static meshes; an optional pre-look-in static-alpha - // barrier; building look-ins; then LATE outside-stage dynamics, - // remaining particles, and weather; followed by the outer flush. + // #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha + // draws of the landscape stage and flushes them ONCE after LScape::draw + // (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent + // landscape content (portal swirl meshes, flame particles) composites + // AFTER the building look-ins. Our dispatcher draws translucency inside + // each Draw call, so the stage is split in TWO phases instead: EARLY = + // sky + terrain + outdoor STATIC meshes (the look-in punches need their + // depth to mark against, the #117 lesson); then the look-ins; then + // LATE = outside-stage dynamics' meshes + ALL scene particles + + // weather. Content drawn early and overlapped by a look-in aperture + // was otherwise overpainted by the far interior (translucents write no + // depth to protect themselves) — the portal-swirl/candle-flame class. int probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { @@ -736,14 +628,6 @@ public sealed class RetailPViewRenderer { foreach (var e in partition.OutdoorStatic) { - if (e.IsBuildingShell - && FindLookInFrameIndex( - e.BuildingShellAnchorCellId ?? 0, - _lookInFrames, - ctx.Cells) >= 0) - { - continue; - } EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInOutsideSlice( probeSliceIndex, @@ -779,81 +663,15 @@ public sealed class RetailPViewRenderer }); } - // Retail DrawBuilding flushes every alpha submission accumulated before - // the building immediately before its portal-only traversal - // (RenderDeviceD3D::DrawBuilding pc:427954-427956). That barrier is - // essential at open-air seams: foliage and static emitters encountered - // before the building must not be flushed after the look-in cell floor - // and repaint it. Our outdoor statics are one retained batch rather than - // retail's BSP-by-building walk, so use one barrier before the first - // look-in; DrawBuildingLookIns adds the corresponding barrier between - // each later building pair. Submit the early static owners' particles - // into the same alpha queue first; their mesh alpha was already - // submitted by the EARLY entity route above. - bool hasBuildingLookIns = _lookInFrames.Count > 0; - if (hasBuildingLookIns) - { - int barrierSliceIndex = 0; - foreach (var slice in clipAssembly.OutsideViewSlices) - { - // Ownerless outdoor emitters cannot ride an entity route. Retail - // draws their meshes once for every installed outside_view; - // retain that slot through deferred alpha playback. - passes.DrawUnattachedSceneParticles(ctx, slice); - - _lateParticleOwnerScratch.Clear(); - if (partition is not null) - { - foreach (var e in partition.OutdoorStatic) - { - if (e.IsBuildingShell - && FindLookInFrameIndex( - e.BuildingShellAnchorCellId ?? 0, - _lookInFrames, - ctx.Cells) >= 0) - { - continue; - } - EntitySphere(e, out var c, out float r); - if (viewcone.SphereVisibleInOutsideSlice( - barrierSliceIndex, - c, - r)) - { - _lateParticleOwnerScratch.Add(e.Id); - } - } - } - if (frameEntityPasses is not null) - { - RenderFrameRouteOwnerSelector.Replace( - _lateParticleOwnerScratch, - in frameView, - RenderFrameCandidateRoute.LandscapeOutdoorStatic, - barrierSliceIndex, - 0); - } - - passes.DrawLandscapeStaticParticles( - ctx, - new RetailPViewLandscapeStaticParticleContext( - slice, - _lateParticleOwnerScratch)); - barrierSliceIndex++; - } - passes.FlushLandscapeAlpha(); - } - // #124: far-building look-ins draw HERE — still inside the landscape // stage (their punches mark against the terrain/exterior depth just - // drawn), strictly BEFORE the outer depth clear + seals below, matching + // drawn), strictly BEFORE the depth clear + seals below, matching // retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785). DrawBuildingLookIns( ctx, passes, clipAssembly, partition, - viewcone, frameEntityPasses, in frameView); @@ -869,8 +687,8 @@ public sealed class RetailPViewRenderer passes.ClearClipRouting(); _outdoorStaticScratch.Clear(); // late: dynamics survivors - _lateParticleOwnerScratch.Clear(); // late: dynamics, plus statics without look-ins - if (!hasBuildingLookIns && partition is not null) + _lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors + if (partition is not null) { foreach (var e in partition.OutdoorStatic) { @@ -881,6 +699,15 @@ public sealed class RetailPViewRenderer r); if (ownerPass) _lateParticleOwnerScratch.Add(e.Id); + // #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids + // double as an ENTITY-id watchlist here — one line per watched + // outdoor-static owner per CHANGE of its cone verdict. + passes.EmitOutStageOwner( + e, + c, + r, + probeSliceIndex, + ownerPass); } } foreach (var e in _outsideStageDynamics) @@ -894,19 +721,12 @@ public sealed class RetailPViewRenderer } if (frameEntityPasses is not null) { - if (hasBuildingLookIns) - { - _lateParticleOwnerScratch.Clear(); - } - else - { - RenderFrameRouteOwnerSelector.Replace( - _lateParticleOwnerScratch, - in frameView, - RenderFrameCandidateRoute.LandscapeOutdoorStatic, - probeSliceIndex, - 0); - } + RenderFrameRouteOwnerSelector.Replace( + _lateParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LandscapeOutdoorStatic, + probeSliceIndex, + 0); RenderFrameRouteOwnerSelector.Union( _lateParticleOwnerScratch, in frameView, @@ -914,6 +734,10 @@ public sealed class RetailPViewRenderer probeSliceIndex, 0); } + passes.EmitOutStageRouting( + probeSliceIndex, + _outsideStageDynamics, + viewcone); _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.LandscapeOutsideDynamic, probeSliceIndex, @@ -942,19 +766,13 @@ public sealed class RetailPViewRenderer // #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls, // campfires, ground effects anchored at a position) have no owner id - // to ride any of the id-filtered particle passes. Draw once per - // installed outside_view for BOTH root kinds, matching retail's - // landscape-stage placement and preserving the slot in each deferred - // draw. The former outdoor-root post-world tail ran after building - // cells and let exterior alpha repaint the cathedral transition. - // With no look-ins they drain at the end of the landscape stage; the - // look-in path submits them at its pre-building barrier so later opaque - // cell floors can cover them. - if (!hasBuildingLookIns) - { - foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices) - passes.DrawUnattachedSceneParticles(ctx, slice); - } + // to ride any of the id-filtered particle passes. The outdoor root + // has the dedicated T3 pass for them; an INTERIOR root had NO pass + // at all. Draw them ONCE per frame (not per slice — alpha particles + // must not double-draw, the #121 lesson), at the END of the landscape + // stage: after the clear they would z-fail against the doorway seal. + if (!ctx.RootCell.IsOutdoorNode) + passes.DrawUnattachedSceneParticles(ctx); // Retail PView::DrawCells 0x005A4872 drains the landscape alpha list // immediately after LScape::draw and before the optional depth clear. @@ -974,58 +792,6 @@ public sealed class RetailPViewRenderer passes.UseIndoorMembershipOnlyRouting(); } - internal static int LookInBuildingShellRouteIndex( - int frameIndex, - int outsideSliceCount, - int sliceIndex) => - checked((frameIndex * outsideSliceCount) + sliceIndex); - - internal static int FindLookInFrameIndex( - uint buildingShellAnchorCellId, - IReadOnlyList lookInFrames, - IRetailPViewCellSource cells) - { - if (buildingShellAnchorCellId == 0) - return -1; - - LoadedCell? anchorCell = cells.Find(buildingShellAnchorCellId); - if (anchorCell is null) - return -1; - - uint buildingKey = anchorCell.BuildingId ?? anchorCell.CellId; - uint landblockId = anchorCell.CellId & 0xFFFF0000u; - for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++) - { - PortalVisibilityFrame frame = lookInFrames[frameIndex]; - if (frame.SourceBuildingKey == buildingKey - && frame.SourceBuildingLandblockId == landblockId) - { - return frameIndex; - } - } - - return -1; - } - - private static bool HasExactRoute( - in RenderFrameView view, - RenderFrameCandidateRoute route, - int routeIndex, - uint cellId) - { - foreach (RenderFrameCandidateRange range in view.RouteRanges) - { - if (range.Route == route - && range.RouteIndex == routeIndex - && range.CellId == cellId - && range.Count > 0) - { - return true; - } - } - return false; - } - private void DrawExitPortalMasks( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, @@ -1327,6 +1093,9 @@ public sealed class RetailPViewRenderer int survivors = _allCellStatics.Count - survivorsBefore; if (survivors > 0) _cellObjCells.Add(cellId); + + // BR-2 phantom-site probe (T3-updated): post-viewcone survivors. + passes.EmitPhantomObjects(cellId, survivors); } // ONE batched static-object draw for every visible cell (was N per-cell @@ -1427,7 +1196,6 @@ public sealed class RetailPViewRenderer // T3 scratch lists (render thread only; cleared per use). private readonly List _outdoorStaticScratch = new(); - private readonly List _buildingShellScratch = new(); private readonly List _cellStaticScratch = new(); private readonly List _dynamicsScratch = new(); // #118: dynamics assigned to the OUTSIDE stage this frame (interior roots @@ -1442,6 +1210,8 @@ public sealed class RetailPViewRenderer private bool LegacyPartitionDiagnosticsEnabled => _partitionObserver is not null + || AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled + || AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled || AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled || AcDream.App.Streaming.EntityVanishProbe.Enabled; @@ -1591,14 +1361,10 @@ public interface IRetailPViewPassExecutor ClipFrameAssembly AssembleClipFrame( PortalVisibilityFrame portalFrame, ClipFrameAssembly reuseAssembly); - void AppendLookInClipFrames( - IReadOnlyList lookInFrames, - ClipFrameAssembly assembly); void PrepareClipFrame(int terrainUploadCount); void SetTerrainClip(ReadOnlySpan planes); void ClearClipRouting(); void UseIndoorMembershipOnlyRouting(); - void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice); void PrepareCellBatches( RetailPViewFrameInput frame, HashSet visibleCellIds); @@ -1614,23 +1380,23 @@ public interface IRetailPViewPassExecutor ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex); + void EmitOutStageOwner( + WorldEntity entity, + Vector3 sphereCenter, + float sphereRadius, + int sliceIndex, + bool passed); + void EmitOutStageRouting( + int sliceIndex, + IReadOnlyList entities, + ViewconeCuller viewcone); + void EmitPhantomObjects(uint cellId, int survivorCount); void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context); - void DrawLandscapeStaticParticles( - RetailPViewFrameInput frame, - RetailPViewLandscapeStaticParticleContext context); - void DrawLandscapeBuildingShellSlice( - RetailPViewFrameInput frame, - RetailPViewLandscapeBuildingShellSliceContext context); void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context); void ClearInteriorDepth(); void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); - void DrawLookInPortalPunch( - RetailPViewFrameInput frame, - RetailPViewCellSliceContext context, - int portalIndex); - void DrawUnattachedSceneParticles( - RetailPViewFrameInput frame, - ClipViewSlice slice); + void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); + void DrawUnattachedSceneParticles(RetailPViewFrameInput frame); void FlushLandscapeAlpha(); void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet ownerIds); @@ -1953,27 +1719,9 @@ public readonly record struct RetailPViewLandscapeSliceContext( internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } } -/// -/// Outdoor-static emitters submitted at retail's pre-building alpha barrier. -/// Mesh alpha for the same owners is already queued by the early landscape -/// entity route. -/// -public readonly record struct RetailPViewLandscapeStaticParticleContext( - ClipViewSlice Slice, - IReadOnlySet ParticleOwnerIds); - -/// Retail DrawBuilding's ordinary exterior-shell pass, issued after -/// the same building's portal-only look-in traversal. -public readonly record struct RetailPViewLandscapeBuildingShellSliceContext( - ClipViewSlice Slice, - IReadOnlyList BuildingShells) -{ - internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } -} - /// #131/#132: the late landscape phase's per-slice payload — -/// outside-stage dynamics to mesh-draw, plus the particle owners not already -/// submitted at a pre-building barrier. +/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner +/// set (statics + dynamics cone survivors) the attached-emitter filter keys on. public readonly record struct RetailPViewLandscapeLateSliceContext( ClipViewSlice Slice, IReadOnlyList Dynamics, diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs index 506362c2..5758a9d5 100644 --- a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs +++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs @@ -30,7 +30,6 @@ internal readonly record struct CurrentRenderProjectionFingerprint( internal enum CurrentRenderPViewRoute : byte { LandscapeOutdoorStatic, - LandscapeBuildingShell, LandscapeOutsideDynamic, LookInObject, CellStatic, @@ -1024,14 +1023,7 @@ internal sealed class CurrentRenderSceneOracle : CurrentRenderProjectionFingerprint x, CurrentRenderProjectionFingerprint y) { - // enum.CompareTo(enum) binds to Enum.CompareTo(object) and boxes - // BOTH operands per comparison; ProjectionClass has 3 values so - // this first key almost always ties and the boxing executes on - // essentially every comparison of the per-frame fingerprint sort - // (#432: ~6.2 MB/frame across the resident entity set whenever - // the automation-artifact oracle is constructed). Compare the - // underlying integral value instead — same order, no boxing. - int value = ((int)x.ProjectionClass).CompareTo((int)y.ProjectionClass); + int value = x.ProjectionClass.CompareTo(y.ProjectionClass); if (value != 0) return value; value = x.LandblockId.CompareTo(y.LandblockId); if (value != 0) return value; diff --git a/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs index 39077c3d..e3e77ed8 100644 --- a/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs +++ b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs @@ -103,9 +103,6 @@ internal sealed class DirectionalShadowCasterFrame private RenderProjectionClass[] _casterClasses = []; private RenderProjectionId[] _denseIdScratch = []; private RenderProjectionRecord[] _denseRecordScratch = []; - private int[] _sortIndices = []; - private ulong[] _sortKeys = []; - private DirectionalShadowCaster[] _sortScratch = []; private readonly DirectionalShadowTransformSnapshot[] _transformChangeScratch = new DirectionalShadowTransformSnapshot[ DirectionalShadowTransformChangeJournal.Capacity]; @@ -167,34 +164,14 @@ internal sealed class DirectionalShadowCasterFrame + System.Runtime.CompilerServices.Unsafe.SizeOf< KeyValuePair>())); - /// - /// #429 owner-approved pipelining: with - /// false, a topology-stale frame - /// keeps the retained caster product and only refreshes transforms, so the - /// copy+classify cost moves off the streaming-churn frame that triggered - /// it. The deferral is best-effort: the FIRST build, a generation change, - /// and a transform journal that demands a full refresh (dense re-copy by - /// id would dereference removed scene entries) all rebuild immediately - /// regardless. While deferred, journal rows whose caster identity no - /// longer matches the retained topology are skipped instead of throwing — - /// the immediately following rebuild reconciles them. - /// - public void Build(in RenderSceneQuery query, bool allowTopologyRebuild = true) + public void Build(in RenderSceneQuery query) { ulong topologyRevision = query.DirectionalShadowTopologyRevision; - bool current = BuildSequence != 0 + if (BuildSequence != 0 && Generation == query.Generation - && _topologyRevision == topologyRevision; - bool deferStale = !allowTopologyRebuild - && !current - && BuildSequence != 0 - && Generation == query.Generation - && !RefreshRequiresFullCopy(in query); - if (current || deferStale) + && _topologyRevision == topologyRevision) { - int refreshes = RefreshChangedTransforms( - in query, - tolerateStaleTopology: deferStale); + int refreshes = RefreshChangedTransforms(in query); Stats = Stats with { IndexCopies = 0, @@ -253,7 +230,11 @@ internal sealed class DirectionalShadowCasterFrame for (int i = 0; i < dynamicCount; i++) Add(_outdoorDynamicScratch[i]); - SortCasters(); + Array.Sort( + _casters, + 0, + _casterCount, + DirectionalShadowCasterComparer.Instance); int refreshCasterCount = 0; for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++) { @@ -394,26 +375,7 @@ internal sealed class DirectionalShadowCasterFrame } } - /// - /// Pure pre-check for the deferral gate: would refreshing from the journal - /// demand the dense by-id re-copy? The journal copy is a read; the state - /// consuming it () only advances inside - /// . - /// - private bool RefreshRequiresFullCopy(in RenderSceneQuery query) - { - if (query.DirectionalShadowTransformRevision == _transformRevision) - return false; - DirectionalShadowTransformChanges changes = - query.CopyDirectionalShadowTransformChanges( - _transformRevision, - _transformChangeScratch); - return changes.RequiresFullRefresh; - } - - private int RefreshChangedTransforms( - in RenderSceneQuery query, - bool tolerateStaleTopology = false) + private int RefreshChangedTransforms(in RenderSceneQuery query) { _changedCasterPoseCount = 0; ulong latest = query.DirectionalShadowTransformRevision; @@ -435,15 +397,6 @@ internal sealed class DirectionalShadowCasterFrame _lastTransformChanges = changes; if (changes.RequiresFullRefresh) { - if (tolerateStaleTopology) - { - // Unreachable through Build's deferral gate (it pre-checks via - // RefreshRequiresFullCopy); kept as a hard stop because the - // dense by-id copy below would throw on scene entries the - // stale topology still names. - throw new InvalidOperationException( - "A stale-topology refresh cannot perform the dense full re-copy."); - } _lastBatchedProjectionCopyCalls = 1; for (int index = 0; index < _refreshCasterSlotCount; index++) { @@ -489,17 +442,6 @@ internal sealed class DirectionalShadowCasterFrame { continue; } - if (tolerateStaleTopology - && (records[index].Id != _casterIds[casterIndex] - || records[index].ProjectionClass - != _casterClasses[casterIndex])) - { - // A replaced scene entry (destroy + recreate under a new - // class) can journal against a retained slot while the - // topology rebuild is deferred; the rebuild on the next - // allowed frame reconciles it. - continue; - } _changedCasterFlags[casterIndex] = true; ValidateStablePose(in records[index], casterIndex); _changedCasterPoses[_changedCasterPoseCount++] = @@ -583,51 +525,6 @@ internal sealed class DirectionalShadowCasterFrame Array.Resize(ref values, capacity); } - /// - /// #429 residual-stall fix: same packed-key index sort as - /// DirectionalShadowPreparedDraws.SortSourceDraws. The caster - /// comparer orders by the 64-bit traversal SortKey.Value with the - /// projection id as tie-break, so the key needs no packing at all — - /// almost every pair resolves on one integer compare and the sort swaps - /// 4-byte indices instead of the multi-hundred-byte caster records. - /// Equal keys fall back to the exact comparer plus an index tie-break, - /// preserving the previous total order. - /// - private void SortCasters() - { - int count = _casterCount; - EnsureCapacity(ref _sortIndices, count); - EnsureCapacity(ref _sortKeys, count); - EnsureCapacity(ref _sortScratch, count); - for (int i = 0; i < count; i++) - { - _sortKeys[i] = _casters[i].Projection.SortKey.Value; - _sortIndices[i] = i; - } - _sortIndices.AsSpan(0, count).Sort( - new CasterIndexComparer(_sortKeys, _casters)); - for (int i = 0; i < count; i++) - _sortScratch[i] = _casters[_sortIndices[i]]; - (_casters, _sortScratch) = (_sortScratch, _casters); - } - - private readonly struct CasterIndexComparer( - ulong[] keys, - DirectionalShadowCaster[] casters) : IComparer - { - public int Compare(int x, int y) - { - ulong left = keys[x]; - ulong right = keys[y]; - if (left != right) - return left < right ? -1 : 1; - int order = DirectionalShadowCasterComparer.Instance.Compare( - casters[x], - casters[y]); - return order != 0 ? order : x.CompareTo(y); - } - } - private sealed class DirectionalShadowCasterComparer : IComparer { diff --git a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs index f867a395..db928b46 100644 --- a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs @@ -13,7 +13,6 @@ internal enum RenderFrameBlendClass : byte internal enum RenderFrameCandidateRoute : byte { LandscapeOutdoorStatic, - LandscapeBuildingShell, LandscapeOutsideDynamic, LookInObject, CellStatic, diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index 61f2d3e7..bbd368bd 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -992,8 +992,6 @@ internal sealed class RenderScenePViewFrameProductController : { CurrentRenderPViewRoute.LandscapeOutdoorStatic => RenderFrameCandidateRoute.LandscapeOutdoorStatic, - CurrentRenderPViewRoute.LandscapeBuildingShell => - RenderFrameCandidateRoute.LandscapeBuildingShell, CurrentRenderPViewRoute.LandscapeOutsideDynamic => RenderFrameCandidateRoute.LandscapeOutsideDynamic, CurrentRenderPViewRoute.LookInObject => @@ -1159,21 +1157,7 @@ internal sealed class RenderScenePViewFrameBuilder LoadSceneIndices(input.Scene); BuildOutdoorRoutes(writer, in input); - int lookInRouteIndex = 0; - for (int frameIndex = 0; - frameIndex < input.LookInFrames.Count; - frameIndex++) - { - BuildLookInRoutes( - writer, - in input, - frameIndex, - ref lookInRouteIndex); - BuildLookInBuildingShellRoutes( - writer, - in input, - frameIndex); - } + BuildLookInRoutes(writer, in input); BuildOutsideDynamicRoutes(writer, in input); BuildCellStaticRoute(writer, in input); BuildDynamicLastRoute(writer, in input); @@ -1295,14 +1279,6 @@ internal sealed class RenderScenePViewFrameBuilder for (int i = 0; i < _outdoorCount; i++) { RenderProjectionRecord record = _outdoor[i]; - if (record.EntityPayload.IsBuildingShell - && RetailPViewRenderer.FindLookInFrameIndex( - record.Source.BuildingShellAnchorCellId, - input.LookInFrames, - input.Cells) >= 0) - { - continue; - } Sphere(in record, out Vector3 center, out float radius); if (!input.Viewcone.SphereVisibleInOutsideSlice( sliceIndex, @@ -1328,84 +1304,22 @@ internal sealed class RenderScenePViewFrameBuilder } } - private void BuildLookInBuildingShellRoutes( - RenderFrameWriter writer, - in RenderScenePViewBuildInput input, - int frameIndex) - { - int sliceCount = input.ClipAssembly.OutsideViewSlices.Length; - for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++) - { - int count = 0; - EnsureCapacity(ref _survivors, _outdoorCount); - for (int i = 0; i < _outdoorCount; i++) - { - RenderProjectionRecord record = _outdoor[i]; - if (!record.EntityPayload.IsBuildingShell - || RetailPViewRenderer.FindLookInFrameIndex( - record.Source.BuildingShellAnchorCellId, - input.LookInFrames, - input.Cells) != frameIndex) - { - continue; - } - - Sphere(in record, out Vector3 center, out float radius); - if (!input.Viewcone.SphereVisibleInOutsideSlice( - sliceIndex, - in center, - radius)) - { - continue; - } - - _survivors[count++] = record; - writer.AddOutdoor(in record); - AddProjection( - writer, - in record, - input.AnimatedEntityIds); - } - - int routeIndex = - RetailPViewRenderer.LookInBuildingShellRouteIndex( - frameIndex, - sliceCount, - sliceIndex); - writer.AddRouteRange( - RenderFrameCandidateRoute.LandscapeBuildingShell, - routeIndex, - 0, - _survivors.AsSpan(0, count)); - } - } - private void BuildLookInRoutes( RenderFrameWriter writer, - in RenderScenePViewBuildInput input, - int frameIndex, - ref int routeIndex) + in RenderScenePViewBuildInput input) { - PortalVisibilityFrame frame = input.LookInFrames[frameIndex]; - for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) + for (int frameIndex = 0; + frameIndex < input.LookInFrames.Count; + frameIndex++) { - uint cellId = frame.OrderedVisibleCells[i]; - var clipKey = new LookInClipCell(frameIndex, cellId); - if (!input.ClipAssembly.LookInCellToViewSlices.TryGetValue( - clipKey, - out ClipViewSlice[]? slices) - || slices.Length == 0) + PortalVisibilityFrame frame = input.LookInFrames[frameIndex]; + for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) { - continue; - } - - int count = LoadCell( - input.Scene, - cellId, - includeDynamics: true); - for (int sliceIndex = 0; sliceIndex < slices.Length; sliceIndex++) - { - int currentRouteIndex = routeIndex++; + uint cellId = frame.OrderedVisibleCells[i]; + int count = LoadCell( + input.Scene, + cellId, + includeDynamics: true); if (count == 0) continue; @@ -1416,7 +1330,7 @@ internal sealed class RenderScenePViewFrameBuilder input.AnimatedEntityIds); writer.AddRouteRange( RenderFrameCandidateRoute.LookInObject, - currentRouteIndex, + i, cellId, _cell.AsSpan(0, count)); } diff --git a/src/AcDream.App/Rendering/Shaders/particle.vert b/src/AcDream.App/Rendering/Shaders/particle.vert index 7df885dd..3da9560f 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.vert +++ b/src/AcDream.App/Rendering/Shaders/particle.vert @@ -14,23 +14,6 @@ layout(location = 5) in vec4 aColor; // (ACDREAM_TEXTURE_HANDLE, injected by // tools/ShaderCompiler/VulkanGlslPreamble.cs). layout(location = 6) in uint aTextureIndex; -layout(location = 7) in uint aClipSlot; - -struct CellClip { - uint count; - uint _p0; - uint _p1; - uint _p2; - vec4 planes[8]; -}; -layout(std430, binding = 2) readonly buffer ClipRegionBuf { - CellClip clipRegions[]; -}; - -out gl_PerVertex { - vec4 gl_Position; - float gl_ClipDistance[8]; -}; uniform mat4 uViewProjection; @@ -52,9 +35,4 @@ void main() { // stage, which is the only form Vulkan can express. vTextureIndex = aTextureIndex; gl_Position = uViewProjection * vec4(world, 1.0); - CellClip clip = clipRegions[aClipSlot]; - for (uint i = 0u; i < clip.count; ++i) - gl_ClipDistance[i] = dot(clip.planes[i], gl_Position); - for (uint i = clip.count; i < 8u; ++i) - gl_ClipDistance[i] = 1.0; } diff --git a/src/AcDream.App/Rendering/Shaders/particle_mesh.vert b/src/AcDream.App/Rendering/Shaders/particle_mesh.vert index 6d6542f1..812b2af8 100644 --- a/src/AcDream.App/Rendering/Shaders/particle_mesh.vert +++ b/src/AcDream.App/Rendering/Shaders/particle_mesh.vert @@ -5,23 +5,6 @@ layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aTexCoord; layout(location = 3) in mat4 aModel; layout(location = 7) in vec4 aColor; -layout(location = 8) in uint aClipSlot; - -struct CellClip { - uint count; - uint _p0; - uint _p1; - uint _p2; - vec4 planes[8]; -}; -layout(std430, binding = 2) readonly buffer ClipRegionBuf { - CellClip clipRegions[]; -}; - -out gl_PerVertex { - vec4 gl_Position; - float gl_ClipDistance[8]; -}; uniform mat4 uViewProjection; @@ -32,9 +15,4 @@ void main() { vTexCoord = aTexCoord; vColor = aColor; gl_Position = uViewProjection * aModel * vec4(aPosition, 1.0); - CellClip clip = clipRegions[aClipSlot]; - for (uint i = 0u; i < clip.count; ++i) - gl_ClipDistance[i] = dot(clip.planes[i], gl_Position); - for (uint i = clip.count; i < 8u; ++i) - gl_ClipDistance[i] = 1.0; } diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv index 9a52c2bf..26fcf5de 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv index ec6aec3e..3b32357f 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 3792e997..66434e36 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -263,7 +263,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "2bdd7114223164916a87e7d2e6df8a21fdcbd1cad082fafb3102b8a0fd4a2a8b", + "sourceSha256": "921c32617708b3931a6304b4697ee96d728077d96f489d6c09eea4c5fe225b63", "compiled": true }, { @@ -279,7 +279,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "582e9be4bca8bd1807d5a2152b211d1d4dd1ed54a6efe4240f52d402e628be75", + "sourceSha256": "19db8757c1a61a2fbec8e56ce89d56b6dd6d66a123cedcdae40915af07d58c0e", "compiled": true }, { diff --git a/src/AcDream.App/Rendering/TeleportViewPlaneController.cs b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs index f3958091..01c50711 100644 --- a/src/AcDream.App/Rendering/TeleportViewPlaneController.cs +++ b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs @@ -17,7 +17,6 @@ public sealed class TeleportViewPlaneController public const float TransitionViewPlaneDistance = 0.001f; private float _gameViewPlaneDistance = 1f; - private TeleportAnimState _state = TeleportAnimState.Off; private readonly ProjectionOverrideCamera _projectionCamera = new(); public bool Enabled { get; private set; } @@ -37,7 +36,6 @@ public sealed class TeleportViewPlaneController _gameViewPlaneDistance = distance; CurrentViewPlaneDistance = distance; - _state = TeleportAnimState.Off; Enabled = false; } @@ -50,7 +48,6 @@ public sealed class TeleportViewPlaneController /// public void Update(TeleportAnimSnapshot snapshot) { - _state = snapshot.State; switch (snapshot.State) { case TeleportAnimState.WorldFadeOut: @@ -83,7 +80,6 @@ public sealed class TeleportViewPlaneController public void Reset() { - _state = TeleportAnimState.Off; Enabled = false; CurrentViewPlaneDistance = _gameViewPlaneDistance; } @@ -114,35 +110,13 @@ public sealed class TeleportViewPlaneController float distance = MathF.Max(CurrentViewPlaneDistance, TransitionViewPlaneDistance); float fov = 2f * MathF.Atan(1f / distance); - float near = WorldTransitionNearPlane(distance); + float near = MathF.Max(0.1f, distance * 0.25f); if (near >= far) near = MathF.Min(0.1f, far * 0.5f); return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far); } - /// - /// Retail's set_vdst keeps znear at 0.1 m below a view-plane - /// distance of 0.4. The legacy landscape path still supplied a complete - /// projected screen at the singular teleport endpoint. Vulkan clips the - /// finite resident terrain geometrically: at distance 0.001, every lower- - /// viewport ground ray hits the terrain before 0.1 m and the authored sky - /// is exposed underneath it. Scale the near plane with the transition only - /// while the world viewport owns the frame. This preserves the exact retail - /// X/Y warp, the tunnel projection, and the ordinary game projection while - /// giving the modern terrain path the coverage retail visibly produced. - /// - private float WorldTransitionNearPlane(float distance) - { - if (_state is TeleportAnimState.WorldFadeOut or TeleportAnimState.WorldFadeIn - && distance < 0.4f) - { - return MathF.Max(0.0001f, distance * 0.25f); - } - - return MathF.Max(0.1f, distance * 0.25f); - } - /// /// Decorate the active camera with the same projection returned by /// . Retail's Render::set_vdst is diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 271e00fe..a83b7895 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -193,6 +193,61 @@ public sealed partial class EnvCellRenderer : return (poolTotal, hwm); } + /// + /// Phase A8 audit probe (2026-05-28 visual-gate-#1 follow-up). + /// One-shot per (cellId, gfxObjId) pair: dumps batch counts + CullModes + + /// transparency flags + bindless-handle-non-zero status, so the operator + /// can read offline and identify why specific polys (e.g., floors) aren't + /// rendering. Set ACDREAM_A8_AUDIT=1 to enable. + /// Returns a deduplicated audit-line list per Render snapshot + /// (one entry per (cellId, gfxObjId) seen in BatchedByCell). The caller + /// (GameWindow EmitEnvCellProbe) prints these and tracks which pairs + /// have already been logged. + /// + public IReadOnlyList CollectCellAuditLines(HashSet<(uint cellId, ulong gfxObjId)> alreadyLogged) + { + var lines = new List(); + lock (_renderLock) + { + var snap = _activeSnapshot; + foreach (var (cellId, gfxDict) in snap.BatchedByCell) + { + foreach (var (gfxObjId, transforms) in gfxDict) + { + var key = (cellId, gfxObjId); + if (alreadyLogged.Contains(key)) continue; + alreadyLogged.Add(key); + + var rd = _meshManager.TryGetRenderData(gfxObjId); + if (rd is null) + { + lines.Add($"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} renderData=null"); + continue; + } + int totalIdx = 0; + var cullModes = new HashSet(); + int translucent = 0; + int additive = 0; + int zeroHandle = 0; + foreach (var b in rd.Batches) + { + totalIdx += b.IndexCount; + cullModes.Add(b.CullMode); + if (b.IsTransparent) translucent++; + if (b.IsAdditive) additive++; + if (!b.TextureSlot.IsAssigned) zeroHandle++; + } + var cullList = string.Join(",", cullModes); + lines.Add( + $"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} " + + $"isSetup={rd.IsSetup} batches={rd.Batches.Count} totalIdx={totalIdx} " + + $"cull=[{cullList}] translucent={translucent} additive={additive} zeroHandle={zeroHandle}"); + } + } + } + return lines; + } + // --------------------------------------------------------------------------- // Constructor // Campaign V slice V11: the raw-GL constructor + Initialize(Shader) two-step @@ -993,6 +1048,48 @@ public sealed partial class EnvCellRenderer : _lastFrameStats.TrianglesDrawn += (dc.renderData.Batches.Count > 0 ? dc.renderData.Batches[0].IndexCount / 3 : 0) * dc.count; + + // Issue #78 (2026-05-31) [shell] probe (ACDREAM_PROBE_SHELL) — THROWAWAY. + // Per opaque-pass call: totals + per visible (filtered) cell whether it is + // present in the prepared snapshot, and its geometry/flags. Answers why the + // interior walls/ceiling don't appear: NOSNAP / gfx=0 ⇒ no shell geometry + // prepared for the cell; idx>0 + zh>0 ⇒ prepared but missing bindless texture + // (invisible); idx>0 + zh=0 + tr=0 ⇒ opaque geometry drawn (fault is depth/ + // occlusion or the geometry isn't the wall). Opaque pass only (halves noise). + if (renderPass == WbRenderPass.Opaque + && AcDream.Core.Rendering.RenderingDiagnostics.ProbeShellEnabled) + { + var sb = new System.Text.StringBuilder(256); + sb.Append("[shell] filter=").Append(filter?.Count ?? -1) + .Append(" drawCalls=").Append(drawCalls.Count) + .Append(" inst=").Append(allInstances.Count) + .Append(" tris=").Append(_lastFrameStats.TrianglesDrawn); + if (filter != null) + { + foreach (var cellId in filter) + { + if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) + { + sb.Append(" [0x").Append(cellId.ToString("X8")).Append(":NOSNAP]"); + continue; + } + int gfxN = 0, tf = 0, batch = 0, idx = 0, tr = 0, zh = 0; + foreach (var (gfxObjId, transforms) in gfxDict) + { + gfxN++; tf += transforms.Count; + var rd = _meshManager.TryGetRenderData(gfxObjId); + if (rd != null) + foreach (var b in rd.Batches) + { batch++; idx += b.IndexCount; if (b.IsTransparent) tr++; if (!b.TextureSlot.IsAssigned) zh++; } + } + sb.Append(" [0x").Append(cellId.ToString("X8")) + .Append(":gfx=").Append(gfxN).Append(" tf=").Append(tf) + .Append(" batch=").Append(batch).Append(" idx=").Append(idx) + .Append(" tr=").Append(tr).Append(" zh=").Append(zh).Append(']'); + } + } + System.Console.WriteLine(sb.ToString()); + } } } diff --git a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs index 34811e7e..9211cb68 100644 --- a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs +++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs @@ -286,15 +286,9 @@ public sealed class GlobalMeshBuffer : IDisposable } } - /// - /// #429: index batches arrive as (offset, count) segments of one shared - /// index array — the caller's retained CPU pick copy — instead of one - /// managed array per batch. The bytes staged per batch are unchanged. - /// internal GlobalMeshAllocation UploadMesh( VertexPositionNormalTexture[] vertices, - ushort[] indices, - ReadOnlySpan<(int Offset, int Count)> indexBatches) + IReadOnlyList indexBatches) { ObjectDisposedException.ThrowIf(_disposed, this); _retirementLedger.RetryPendingPublications(); @@ -302,21 +296,16 @@ public sealed class GlobalMeshBuffer : IDisposable if (_migration is not null) throw new InvalidOperationException("A mesh upload cannot mutate the arena while a backing-buffer migration is in progress."); ArgumentNullException.ThrowIfNull(vertices); - ArgumentNullException.ThrowIfNull(indices); + ArgumentNullException.ThrowIfNull(indexBatches); if (vertices.Length == 0) throw new ArgumentException("A global mesh allocation requires vertices.", nameof(vertices)); int totalIndices = 0; - for (int i = 0; i < indexBatches.Length; i++) + for (int i = 0; i < indexBatches.Count; i++) { - (int offset, int count) = indexBatches[i]; - if (offset < 0 || count <= 0 || (long)offset + count > indices.Length) - { - throw new ArgumentException( - $"Index batch {i} ({offset}, {count}) is outside the shared index array ({indices.Length}).", - nameof(indexBatches)); - } - totalIndices = checked(totalIndices + count); + ushort[] batch = indexBatches[i] + ?? throw new ArgumentException("Index batches cannot contain null.", nameof(indexBatches)); + totalIndices = checked(totalIndices + batch.Length); } if (totalIndices == 0) throw new ArgumentException("A global mesh allocation requires indices.", nameof(indexBatches)); @@ -333,7 +322,7 @@ public sealed class GlobalMeshBuffer : IDisposable throw; } - var firstIndices = new int[indexBatches.Length]; + var firstIndices = new int[indexBatches.Count]; try { // IGpuBuffer.Upload stages through a neutral binding point of the @@ -348,15 +337,18 @@ public sealed class GlobalMeshBuffer : IDisposable IGpuBuffer indexStore = RequireStore(_indexBuffer); int indexOffset = indexRange.Offset; - for (int i = 0; i < indexBatches.Length; i++) + for (int i = 0; i < indexBatches.Count; i++) { - (int offset, int count) = indexBatches[i]; + ushort[] batch = indexBatches[i]; firstIndices[i] = indexOffset; - long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort)); - indexStore.Upload( - indexOffsetBytes, - MemoryMarshal.AsBytes(new ReadOnlySpan(indices, offset, count))); - indexOffset = checked(indexOffset + count); + if (batch.Length > 0) + { + long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort)); + indexStore.Upload( + indexOffsetBytes, + MemoryMarshal.AsBytes(new ReadOnlySpan(batch))); + indexOffset = checked(indexOffset + batch.Length); + } } } catch diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index f8030062..25a6bc42 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -2045,47 +2045,13 @@ namespace AcDream.App.Rendering.Wb { if (meshData.Vertices.Length == 0) return null; - // #429: allocation-exact conversion. The retained pick copies - // (CPUPositions/CPUIndices) are the only geometry-proportional - // allocations this method makes; the per-batch segments handed to - // the shared arena are (offset, count) views into the same - // retained CPUIndices array. The former LINQ chain materialized - // every batch twice (a per-batch ToArray plus an unsized - // SelectMany growth) — megabytes of transient garbage per mesh on - // the render thread. - int totalIndexCount = 0; - int nonEmptyBatchCount = 0; - foreach (List formatBatches in meshData.TextureBatches.Values) - { - foreach (TextureBatchData formatBatch in formatBatches) - { - if (formatBatch.Indices.Count == 0) continue; - totalIndexCount = checked(totalIndexCount + formatBatch.Indices.Count); - nonEmptyBatchCount++; - } - } - - var cpuIndices = new ushort[totalIndexCount]; - var indexSegments = new (int Offset, int Count)[nonEmptyBatchCount]; - { - int fillOffset = 0; - int segmentIndex = 0; - foreach (List formatBatches in meshData.TextureBatches.Values) - { - foreach (TextureBatchData formatBatch in formatBatches) - { - int count = formatBatch.Indices.Count; - if (count == 0) continue; - CollectionsMarshal.AsSpan(formatBatch.Indices) - .CopyTo(cpuIndices.AsSpan(fillOffset, count)); - indexSegments[segmentIndex++] = (fillOffset, count); - fillOffset = checked(fillOffset + count); - } - } - } - + var modernIndexBatches = meshData.TextureBatches.Values + .SelectMany(batches => batches) + .Where(batch => batch.Indices.Count != 0) + .Select(batch => batch.Indices.ToArray()) + .ToArray(); GlobalMeshAllocation? globalAllocation = null; - var renderBatches = new List(nonEmptyBatchCount); + var renderBatches = new List(); var acquiredTextures = new List<(TextureAtlasManager Atlas, TextureKey Key)>(); try @@ -2102,13 +2068,8 @@ namespace AcDream.App.Rendering.Wb // or vertex/index buffer to build here — Vulkan bakes vertex input // into the pipeline, and the shared arena's stores are bound once // per pass (see WbDrawDispatcher.Rhi.cs's BindPipelineWithMesh). - if (GlobalBuffer is not null && nonEmptyBatchCount != 0) - { - globalAllocation = GlobalBuffer.UploadMesh( - meshData.Vertices, - cpuIndices, - indexSegments); - } + if (GlobalBuffer is not null && modernIndexBatches.Length != 0) + globalAllocation = GlobalBuffer.UploadMesh(meshData.Vertices, modernIndexBatches); foreach (var (format, batches) in meshData.TextureBatches) { @@ -2132,25 +2093,8 @@ namespace AcDream.App.Rendering.Wb // family. Choosing an earlier reclaimed slot first // duplicates a layer already resident in a later array // every time portal churn revisits that texture. - for (int i = 0; i < atlasList.Count; i++) - { - if (atlasList[i].HasTexture(batch.Key)) - { - atlasManager = atlasList[i]; - break; - } - } - if (atlasManager is null) - { - for (int i = 0; i < atlasList.Count; i++) - { - if (atlasList[i].AvailableSlots > 0) - { - atlasManager = atlasList[i]; - break; - } - } - } + atlasManager = atlasList.FirstOrDefault(a => a.HasTexture(batch.Key)) + ?? atlasList.FirstOrDefault(a => a.AvailableSlots > 0); if (atlasManager == null) { atlasManager = new TextureAtlasManager( @@ -2236,34 +2180,21 @@ namespace AcDream.App.Rendering.Wb } } - // Every renderBatches entry is one non-empty texture batch, so - // their index counts sum to exactly totalIndexCount. long geometryBytes = checked( (long)meshData.Vertices.Length * VertexPositionNormalTexture.Size - + (long)totalIndexCount * sizeof(ushort)); - bool hasCutoutSubset = false; - for (int i = 0; i < renderBatches.Count; i++) - { - if (renderBatches[i].Translucency - == AcDream.Core.Meshing.TranslucencyKind.ClipMap) - { - hasCutoutSubset = true; - break; - } - } - var cpuPositions = new Vector3[meshData.Vertices.Length]; - for (int i = 0; i < cpuPositions.Length; i++) - cpuPositions[i] = meshData.Vertices[i].Position; + + renderBatches.Sum(b => (long)b.IndexCount * sizeof(ushort))); var renderData = new ObjectRenderData { VertexCount = meshData.Vertices.Length, Batches = renderBatches, - HasCutoutSubset = hasCutoutSubset, + HasCutoutSubset = renderBatches.Any( + static batch => batch.Translucency + == AcDream.Core.Meshing.TranslucencyKind.ClipMap), GlobalAllocation = globalAllocation, ParticleEmitters = meshData.ParticleEmitters, DIDDegrade = meshData.DIDDegrade, - CPUPositions = cpuPositions, - CPUIndices = cpuIndices, + CPUPositions = meshData.Vertices.Select(v => v.Position).ToArray(), + CPUIndices = meshData.TextureBatches.Values.SelectMany(l => l).SelectMany(b => b.Indices).ToArray(), CPUEdgeLines = meshData.EdgeLines, MemorySize = geometryBytes, NonArenaGpuBytes = CalculateNonArenaGeometryBytes( diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs index 44a497e2..350c73ec 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs @@ -89,15 +89,6 @@ internal sealed class DirectionalShadowPreparedDraws private DrawElementsIndirectCommand[] _commands = []; private DirectionalShadowPreparedBatch[] _batches = []; private DirectionalShadowPreparedRun[] _runs = []; - private int[] _drawNextInGroup = []; - private int[] _groupHead = []; - private int[] _groupTail = []; - private int[] _groupCountByGroup = []; - private ulong[] _groupKeyHi = []; - private ulong[] _groupKeyLo = []; - private int[] _groupFirstDraw = []; - private int[] _groupOrder = []; - private readonly Dictionary _groupByKey = []; private int _sourceCount; private int _commandCount; private int _runCount; @@ -192,18 +183,7 @@ internal sealed class DirectionalShadowPreparedDraws + _mappedCasterIdentityPresent.Length + (long)_commands.Length * Unsafe.SizeOf() + (long)_batches.Length * Unsafe.SizeOf() - + (long)_runs.Length * Unsafe.SizeOf() - + (long)_drawNextInGroup.Length * sizeof(int) - + (long)_groupHead.Length * sizeof(int) - + (long)_groupTail.Length * sizeof(int) - + (long)_groupCountByGroup.Length * sizeof(int) - + (long)_groupKeyHi.Length * sizeof(ulong) - + (long)_groupKeyLo.Length * sizeof(ulong) - + (long)_groupFirstDraw.Length * sizeof(int) - + (long)_groupOrder.Length * sizeof(int) - + (long)_groupByKey.EnsureCapacity(0) - * (sizeof(int) - + Unsafe.SizeOf>())); + + (long)_runs.Length * Unsafe.SizeOf()); /// /// Returns false when this exact resident-caster build was already @@ -378,63 +358,11 @@ internal sealed class DirectionalShadowPreparedDraws if (casterBuildSequence == 0) throw new ArgumentOutOfRangeException(nameof(casterBuildSequence)); - // #429 residual-stall fix, round 2. The full draw sort existed only to - // make equal keys contiguous for the grouping walk below — but the - // draw list is ~100k entries while the DISTINCT keys number in the - // low thousands. Hash-group the draws in one O(n) pass (chained - // per-group index lists over retained arrays), then order the GROUPS - // by their packed keys (an O(g log g) sort of small integers). The - // emitted product is identical to the former stable full sort in - // every reachable state: group membership keys on exact DrawKey - // equality via the dictionary; group order follows the same packed - // material | cull | firstIndex | baseVertex (hi) and indexCount | - // slot | layer | foliage (lo) chain with first-appearance as the - // final tie-break; instances within a group keep insertion order, - // exactly as the former index tie-break produced. - int groupCount = 0; - _groupByKey.Clear(); - EnsureCapacity(ref _drawNextInGroup, _sourceCount); - EnsureCapacity(ref _groupHead, _sourceCount); - EnsureCapacity(ref _groupTail, _sourceCount); - EnsureCapacity(ref _groupCountByGroup, _sourceCount); - EnsureCapacity(ref _groupKeyHi, _sourceCount); - EnsureCapacity(ref _groupKeyLo, _sourceCount); - EnsureCapacity(ref _groupFirstDraw, _sourceCount); - EnsureCapacity(ref _groupOrder, _sourceCount); - for (int i = 0; i < _sourceCount; i++) - { - DirectionalShadowDrawKey key = _source[i].Key; - if (!_groupByKey.TryGetValue(key, out int group)) - { - group = groupCount++; - _groupByKey.Add(key, group); - _groupHead[group] = i; - _groupTail[group] = i; - _groupCountByGroup[group] = 0; - _groupFirstDraw[group] = i; - _groupKeyHi[group] = - ((ulong)(byte)key.Material << 62) - | ((ulong)((uint)key.CullMode & 0x3u) << 60) - | ((ulong)key.FirstIndex << 28) - | ((ulong)(uint)key.BaseVertex & 0x0FFF_FFFFul); - _groupKeyLo[group] = - ((ulong)Math.Min((uint)key.IndexCount, 0xF_FFFFu) << 44) - | ((ulong)key.TextureSlot.Index << 12) - | ((ulong)Math.Min(key.TextureLayer, 0x3FFu) << 2) - | (key.FoliageFlags & 0x3u); - } - else - { - _drawNextInGroup[_groupTail[group]] = i; - _groupTail[group] = i; - } - _drawNextInGroup[i] = -1; - _groupCountByGroup[group]++; - } - for (int g = 0; g < groupCount; g++) - _groupOrder[g] = g; - _groupOrder.AsSpan(0, groupCount).Sort( - new GroupOrderComparer(_groupKeyHi, _groupKeyLo, _groupFirstDraw)); + Array.Sort( + _source, + 0, + _sourceCount, + DirectionalShadowSourceDrawComparer.Instance); EnsureCapacity(ref _transforms, _sourceCount); EnsureCapacity(ref _transformSources, _sourceCount); EnsureCapacity(ref _dynamicTransformSlots, _sourceCount); @@ -467,24 +395,24 @@ internal sealed class DirectionalShadowPreparedDraws _mappedCasterCount); } + int sourceIndex = 0; int transformIndex = 0; int commandIndex = 0; int opaqueCommands = 0; - for (int orderIndex = 0; orderIndex < groupCount; orderIndex++) + while (sourceIndex < _sourceCount) { - int group = _groupOrder[orderIndex]; - DirectionalShadowDrawKey key = _source[_groupFirstDraw[group]].Key; - int instanceCount = _groupCountByGroup[group]; - for (int draw = _groupHead[group]; draw >= 0; draw = _drawNextInGroup[draw]) + DirectionalShadowDrawKey key = _source[sourceIndex].Key; + int groupStart = sourceIndex; + do { - _transforms[transformIndex++] = _source[draw].Transform; + _transforms[transformIndex++] = _source[sourceIndex].Transform; _transformSources[transformIndex - 1] = - _source[draw].TransformSource; - if (_source[draw].TransformSource.Refreshable) + _source[sourceIndex].TransformSource; + if (_source[sourceIndex].TransformSource.Refreshable) { int dynamicTransformIndex = transformIndex - 1; DirectionalShadowTransformSource transformSource = - _source[draw].TransformSource; + _source[sourceIndex].TransformSource; if (transformSource.CasterIndex < 0) { throw new InvalidOperationException( @@ -497,8 +425,11 @@ internal sealed class DirectionalShadowPreparedDraws _firstDynamicTransformByCaster[transformSource.CasterIndex] = dynamicTransformIndex; } + sourceIndex++; } + while (sourceIndex < _sourceCount && _source[sourceIndex].Key == key); + int instanceCount = sourceIndex - groupStart; _commands[commandIndex] = new DrawElementsIndirectCommand { Count = checked((uint)key.IndexCount), @@ -932,35 +863,6 @@ internal sealed class DirectionalShadowPreparedDraws Array.Resize(ref values, capacity); } - /// - /// Orders the hash-built groups for emission: the packed hi key carries - /// Material (2) | CullMode (2) | FirstIndex (32) | BaseVertex low 28, the - /// lo key IndexCount (20, clamped) | TextureSlot (32) | TextureLayer (10, - /// clamped) | FoliageFlags (2) — the exact field chain the former full - /// draw sort compared — with first-appearance as the final deterministic - /// tie-break. Clamp collisions (unreachable with the configured arena and - /// atlas maxima) can only reorder whole groups inside one material+cull - /// run; group membership itself keys on exact DrawKey equality. - /// - private readonly struct GroupOrderComparer( - ulong[] keyHi, - ulong[] keyLo, - int[] firstDraw) : IComparer - { - public int Compare(int x, int y) - { - ulong left = keyHi[x]; - ulong right = keyHi[y]; - if (left != right) - return left < right ? -1 : 1; - left = keyLo[x]; - right = keyLo[y]; - if (left != right) - return left < right ? -1 : 1; - return firstDraw[x].CompareTo(firstDraw[y]); - } - } - private readonly record struct DirectionalShadowDrawKey( uint FirstIndex, int BaseVertex, @@ -979,15 +881,42 @@ internal sealed class DirectionalShadowPreparedDraws Matrix4x4 Transform, DirectionalShadowTransformSource TransformSource); - // The former full-draw sort comparer is gone with the sort itself. - // #429 postmortem, preserved here because the lesson is easy to lose: - // its original `x.Material.CompareTo(y.Material)` bound to - // Enum.CompareTo(object) and boxed BOTH operands on every comparison — - // measured at 38.9 MB of garbage per topology rebuild (~4M boxes across - // the N·log N sort), rebuilt on every streaming-churn frame while the - // player moves. Compare enums through their underlying integers, or - // better, do not sort 100k draws when hash-grouping plus a small - // group-key sort produces the identical product (see Complete). + private sealed class DirectionalShadowSourceDrawComparer + : IComparer + { + public static DirectionalShadowSourceDrawComparer Instance { get; } = new(); + + public int Compare( + DirectionalShadowSourceDraw left, + DirectionalShadowSourceDraw right) + { + DirectionalShadowDrawKey x = left.Key; + DirectionalShadowDrawKey y = right.Key; + int order = x.Material.CompareTo(y.Material); + if (order != 0) return order; + order = x.CullMode.CompareTo(y.CullMode); + if (order != 0) return order; + order = x.FirstIndex.CompareTo(y.FirstIndex); + if (order != 0) return order; + order = x.BaseVertex.CompareTo(y.BaseVertex); + if (order != 0) return order; + order = x.IndexCount.CompareTo(y.IndexCount); + if (order != 0) return order; + order = x.TextureSlot.Index.CompareTo(y.TextureSlot.Index); + if (order != 0) return order; + order = x.TextureLayer.CompareTo(y.TextureLayer); + // Campaign VM VM6: tie-break on FoliageFlags so entries sharing + // every other key field but differing only in classification + // (the rare case a mesh subset is reachable from both a + // procedural-scenery and a non-scenery placement) still sort + // into one contiguous, exact-key-matched run instead of an + // unstable-sort-dependent scatter. The grouping loop below keys + // on exact DirectionalShadowDrawKey equality regardless. + return order != 0 + ? order + : x.FoliageFlags.CompareTo(y.FoliageFlags); + } + } } public sealed partial class WbDrawDispatcher @@ -1010,17 +939,8 @@ public sealed partial class WbDrawDispatcher /// returned owner is renderer-retained and remains valid until the next /// distinct caster build is prepared. /// - /// - /// #429: the version the shadow pipelining policy observes — the same - /// counter keys its topology - /// gate on. - /// - internal long DirectionalShadowAvailabilityVersion => - _meshAdapter.MeshManager?.RenderDataAvailabilityVersion ?? 0L; - internal DirectionalShadowPreparedDraws PrepareDirectionalShadowDraws( - DirectionalShadowCasterFrame casters, - bool allowTopologyRebuild = true) + DirectionalShadowCasterFrame casters) { ArgumentNullException.ThrowIfNull(casters); ReadOnlySpan source = casters.Casters; @@ -1036,19 +956,6 @@ public sealed partial class WbDrawDispatcher _directionalShadowDraws.RefreshDynamicTransforms(casters); return _directionalShadowDraws; } - // #429 owner-approved pipelining: a deferred frame keeps the retained - // prepared draws and only refreshes transforms — valid ONLY while the - // product was built from this exact caster frame; a caster rebuild or - // generation change invalidates the caster-slot mapping the transform - // refresh indexes by, so those rebuild immediately regardless. - if (!allowTopologyRebuild - && _directionalShadowDraws.SourceCasterBuildSequence - == casters.BuildSequence - && _directionalShadowDraws.SourceGeneration == casters.Generation) - { - _directionalShadowDraws.RefreshDynamicTransforms(casters); - return _directionalShadowDraws; - } int estimatedInstances = 0; for (int i = 0; i < source.Length; i++) diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs index d3df7aef..e701d3fc 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs @@ -473,7 +473,6 @@ public sealed unsafe partial class WbDrawDispatcher } float opacity = PackedPartOpacity( - entity.ServerGuid, entity.LocalEntityId, (uint)setupPartIndex); if (opacity < 1f) @@ -528,7 +527,6 @@ public sealed unsafe partial class WbDrawDispatcher // one-part assumption and kept the Bind Stone's four // hook-hidden shard parts visible. float opacity = PackedPartOpacity( - entity.ServerGuid, entity.LocalEntityId, (uint)partIndex); if (opacity < 1f) @@ -587,24 +585,20 @@ public sealed unsafe partial class WbDrawDispatcher anyVao != 0 && alphaQueueCollecting; private float PackedPartOpacity( - uint serverGuid, uint localEntityId, uint setupPartIndex) { - float opacity = EntityOpacity(serverGuid); - if (opacity <= 0f) - return 0f; if (!_translucencyFades.TryGetCurrentValue( localEntityId, setupPartIndex, out float translucency)) { - return opacity; + return 1f; } return translucency >= 1f ? 0f - : opacity * (1f - translucency); + : 1f - translucency; } private bool ClassifyPackedBatches( diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs index 30b22946..25d3dc17 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs @@ -176,8 +176,7 @@ public sealed unsafe partial class WbDrawDispatcher RetailAlphaQueue? alphaQueue = null, long? alphaScratchBudgetBytes = null, TerrainAtlas.RetailDetailTextureBinding buildingDetail = default, - Func? buildingDetailEnabled = null, - Func? hierarchicalTranslucency = null) + Func? buildingDetailEnabled = null) { _device = device ?? throw new ArgumentNullException(nameof(device)); _frames = frames ?? throw new ArgumentNullException(nameof(frames)); @@ -193,7 +192,6 @@ public sealed unsafe partial class WbDrawDispatcher _selectionSink = selectionSink; _selectionLighting = selectionSink as IRetailSelectionLightingSource; _alphaQueue = alphaQueue; - _hierarchicalTranslucency = hierarchicalTranslucency; _alphaSource = new AlphaDrawSource(this); _buildingDetail = buildingDetail; _buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 3ea377bb..f9fdbdd2 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -91,7 +91,6 @@ public sealed partial class WbDrawDispatcher : IDisposable private readonly IRetailSelectionRenderSink? _selectionSink; private readonly IRetailSelectionLightingSource? _selectionLighting; private readonly RetailAlphaQueue? _alphaQueue; - private readonly Func? _hierarchicalTranslucency; private readonly AlphaDrawSource _alphaSource; private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy; private int _scratchPeakUnits; @@ -781,6 +780,21 @@ public sealed partial class WbDrawDispatcher : IDisposable private readonly HashSet _missRequested = new(); private readonly HashSet _missLogged = new(); + // #119 decisive probe (2026-06-11): ACDREAM_DUMP_ENTITY one-shot entity + // dump. Keyed by entity Id; the stored signature re-emits the header line + // whenever (MeshRefs count, cache batch count, zero-translation count, + // culled) changes — e.g. the Tier-1 populate landing one frame after the + // first slow-path draw. The full per-part listing prints only on first + // sight. Inert (one Count==0 check per new entity) when the env var is + // unset. Render-thread only. + private readonly Dictionary _entityDumpSig = new(); + + // Rate limiter for [dump-entity] WALK-REJECT lines: a rejected entity + // re-tests every frame; emit the first rejection per entity then every + // 300th (~5 s at 60 fps). Static because WalkEntitiesInto is static; + // render-thread only like the walk itself. + private static readonly Dictionary _walkRejectCounts = new(); + // CPU + GPU timing for [WB-DIAG] under ACDREAM_WB_DIAG=1. The GPU samples // are written by the RHI arm's SampleRhiTimers (WbDrawDispatcher.Rhi.cs) // from the device's own timer pool; the raw-GL query-object ring that used @@ -1186,6 +1200,7 @@ public sealed partial class WbDrawDispatcher : IDisposable if (!cellInVis) { if (shellScoped) result.BuildingShellAnchorReject++; + MaybeEmitWalkRejectDump(entity, "visibleCellIds-miss"); if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled && indoorProbeState!.ShouldEmit(cellProbeId)) { @@ -1211,6 +1226,7 @@ public sealed partial class WbDrawDispatcher : IDisposable if (!aabbVisible) { + MaybeEmitWalkRejectDump(entity, "frustum"); if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled && indoorProbeState!.ShouldEmit(cellProbeId)) { @@ -1308,6 +1324,117 @@ public sealed partial class WbDrawDispatcher : IDisposable } } + /// + /// #119 decisive probe: rate-limited [dump-entity] WALK-REJECT line + /// for an ACDREAM_DUMP_ENTITY-targeted entity that the walk filtered + /// out (visibleCellIds gate / per-entity frustum). Absence of any DRAW dump + /// plus presence of these lines attributes "entity exists but never reaches + /// the draw loop" to the specific gate. Inert when the target set is empty. + /// + private static void MaybeEmitWalkRejectDump(WorldEntity entity, string reason) + { + var targets = RenderingDiagnostics.DumpEntitySourceIds; + if (targets.Count == 0 || !targets.Contains(entity.SourceGfxObjOrSetupId)) return; + _walkRejectCounts.TryGetValue(entity.Id, out int n); + _walkRejectCounts[entity.Id] = n + 1; + if (n % 300 != 0) return; + Console.WriteLine( + $"[dump-entity] WALK-REJECT id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " + + $"reason={reason} parentCell=0x{(entity.ParentCellId ?? 0u):X8} " + + $"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) n={n + 1}"); + } + + /// + /// #119 decisive probe: per-entity state dump at draw time for + /// ACDREAM_DUMP_ENTITY-targeted entities. First sight prints a + /// header + every MeshRef's GfxObj id, part-transform translation, and + /// loaded flag; afterwards a compact header re-emits only when the + /// (meshRefs, cacheBatches, zeroTranslations, culled) signature changes. + /// Discriminates H-A (hydration-time MeshRef corruption: translations + /// collapsed to ~zero / missing parts) from H-B (Tier-1 cache holding a + /// partial or stale batch set) from H-C (both healthy ⇒ draw-side compose). + /// + private void MaybeEmitEntityDump( + in RenderInstanceCandidate entity, + uint landblockId, + bool culled, + IReadOnlyList tuples) + { + var targets = RenderingDiagnostics.DumpEntitySourceIds; + if (targets.Count == 0 || !targets.Contains(entity.SourceId)) + return; + + int zeroT = 0; + int refsCount = 0; + float tzMin = float.MaxValue, tzMax = float.MinValue; + for (int i = 0; i < tuples.Count; i++) + { + RenderInstanceTuple tuple = tuples[i]; + if (tuple.Candidate.LocalEntityId != entity.LocalEntityId + || tuple.Candidate.TupleLandblockId + != entity.TupleLandblockId) + { + continue; + } + + refsCount++; + Vector3 t = tuple.MeshRef.PartTransform.Translation; + if (t.LengthSquared() < 1e-9f) zeroT++; + if (t.Z < tzMin) tzMin = t.Z; + if (t.Z > tzMax) tzMax = t.Z; + } + + int cacheBatches = -1; + int restZero = 0; + float rzMin = float.MaxValue, rzMax = float.MinValue; + if (_cache.TryGet(entity.Id, landblockId, out var cacheEntry)) + { + cacheBatches = cacheEntry!.Batches.Length; + foreach (var b in cacheEntry.Batches) + { + var t = b.RestPose.Translation; + if (t.LengthSquared() < 1e-9f) restZero++; + if (t.Z < rzMin) rzMin = t.Z; + if (t.Z > rzMax) rzMax = t.Z; + } + } + + var sig = (refsCount, cacheBatches, zeroT, culled); + bool first = !_entityDumpSig.TryGetValue(entity.Id, out var prev); + if (!first && prev == sig) return; + _entityDumpSig[entity.Id] = sig; + + string cacheStr = cacheBatches < 0 + ? (_tier1CacheDisabled ? "disabled" : "miss") + : $"hit:{cacheBatches} restZero={restZero} restZ=[{rzMin:F2}..{rzMax:F2}]"; + Console.WriteLine( + $"[dump-entity] DRAW{(first ? "" : "-CHANGED")} id=0x{entity.Id:X8} src=0x{entity.SourceId:X8} " + + $"lb=0x{landblockId:X8} cell=0x{entity.ParentCellId:X8} " + + $"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) scale={entity.Scale:F2} " + + $"meshRefs={refsCount} tZero={zeroT} tZ=[{tzMin:F2}..{tzMax:F2}] cache={cacheStr} culled={culled}"); + + if (first) + { + for (int i = 0; i < tuples.Count; i++) + { + RenderInstanceTuple tuple = tuples[i]; + if (tuple.Candidate.LocalEntityId + != entity.LocalEntityId + || tuple.Candidate.TupleLandblockId + != entity.TupleLandblockId) + { + continue; + } + + MeshRef mr = tuple.MeshRef; + var t = mr.PartTransform.Translation; + bool loaded = _meshAdapter.TryGetRenderData(mr.GfxObjId) is not null; + Console.WriteLine( + $"[dump-entity] part[{tuple.MeshRefIndex:D2}] gfx=0x{mr.GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3}) loaded={loaded}"); + } + } + } + public void Draw( ICamera camera, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, @@ -1524,6 +1651,15 @@ public sealed partial class WbDrawDispatcher : IDisposable ? new Vector2(lighting.Luminosity, lighting.Diffuse) : new Vector2(0f, 1f); + // #119 decisive probe: one-shot dump (+ change re-emission) for + // ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue + // so a routed-out entity still reports its state. + MaybeEmitEntityDump( + in entity, + cacheLb, + _currentEntityCulled, + _candidateTupleScratch); + // #176 seam-draw probe: any entity parented to a target cell reports // its position + light set (a floor-coincident static/plate would be // the z-fight's second draw; the player entity is the positive @@ -1785,12 +1921,11 @@ public sealed partial class WbDrawDispatcher : IDisposable // sets draw_state|=1 and skips the whole part outright — not a // blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees // t=1 commits the bitwise-exact value so this check is safe. - float opacityMultiplier = EntityOpacity(entity.ServerGuid); - if (opacityMultiplier <= 0f) continue; + float opacityMultiplier = 1.0f; if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue)) { if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely - opacityMultiplier *= 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0 + opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0 } if (!ClassifyBatches(partData, model, entity, meshRef, paletteIdentity, restPose, opacityMultiplier, collector, entityHasCutoutSubset)) @@ -1820,14 +1955,12 @@ public sealed partial class WbDrawDispatcher : IDisposable // entity — the Bind Stone's idle cycle hides its four authored // shard parts (3-6) with TransparentPartHook start=end=1.0 // every loop, and they stayed visible. - float opacityMultiplier = EntityOpacity(entity.ServerGuid); + float opacityMultiplier = 1.0f; bool fullyInvisible = false; - if (opacityMultiplier <= 0f) - fullyInvisible = true; if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)partIdx, out float translucencyValue)) { if (translucencyValue >= 1.0f) fullyInvisible = true; - else opacityMultiplier *= 1f - translucencyValue; + else opacityMultiplier = 1f - translucencyValue; } if (!fullyInvisible) @@ -1905,32 +2038,6 @@ public sealed partial class WbDrawDispatcher : IDisposable observeCurrentPath: true); } - /// - /// Readiness barrier for a private creature viewport. Unlike the world - /// reveal queue this has no retained scan state: the caller owns a tiny, - /// exact entity list and retries it each frame. A completed result means - /// both mesh render data and every palette/original-texture composite can - /// be classified without clearing the private target to an empty frame. - /// - internal bool PreparePrivateEntityResources( - IReadOnlyList entities) - { - ArgumentNullException.ThrowIfNull(entities); - bool complete = true; - for (int i = 0; i < entities.Count; i++) - { - if (PrepareCompositeEntity(entities[i]) != CompositeWarmupResult.Complete) - complete = false; - } - return complete; - } - - private float EntityOpacity(uint serverGuid) - { - float translucency = _hierarchicalTranslucency?.Invoke(serverGuid) ?? 0f; - return 1f - Math.Clamp(translucency, 0f, 1f); - } - /// /// Whether there is a mesh source to draw from. The encoder arm has no /// vertex array of its own — the pipeline owns one shaped by diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 0a866362..e8f7351c 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -589,6 +589,11 @@ public sealed class WbMeshAdapter meshManager.RequeueStagedMeshData(m); meshManager.SetArenaBackpressure(arenaBackpressured); + bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled; + var pendingBefore = texProbe + ? meshManager.GetPendingTextureUpdateStats() + : default; + // #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES // immutable decoded payloads in ManagedGLTextureArray._pendingUpdates — the // actual TexSubImage3D copies + mipmap regeneration happen in @@ -638,6 +643,9 @@ public sealed class WbMeshAdapter LastBufferAllocationBytes = uploadBudget.BufferAllocationBytes; LastBufferCopyBytes = uploadBudget.BufferCopyBytes; LastNewBufferCount = uploadBudget.NewBufferCount; + + if (texProbe) + EmitTexFlushProbe(pendingBefore); } private static void RejectUnsupportedHead( @@ -655,6 +663,32 @@ public sealed class WbMeshAdapter meshManager.RejectUnsupportedStagedUpload(rejected, error); } + // #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled. + private int _lastTexFlushBefore = -1; + private int _texFlushHeartbeat; + + /// + /// #105 apparatus: one [tex-flush] line on change of the staged-texture + /// pending picture (plus a ~10 s heartbeat while anything is stuck). A healthy + /// frame ends with after=0; before==after>0 persisting at + /// standstill is the white-walls mechanism live (staged uploads never applied). + /// + private void EmitTexFlushProbe((int PendingUpdates, int ArraysWithPending, int TotalArrays) before) + { + var after = _meshManager!.GetPendingTextureUpdateStats(); + bool changed = before.PendingUpdates != _lastTexFlushBefore; + bool flushed = after.PendingUpdates != before.PendingUpdates; + bool heartbeat = after.PendingUpdates > 0 && ++_texFlushHeartbeat >= 600; + if (!changed && !flushed && !heartbeat) return; + + _texFlushHeartbeat = 0; + _lastTexFlushBefore = before.PendingUpdates; + Console.WriteLine( + $"[tex-flush] before={before.PendingUpdates} after={after.PendingUpdates}" + + $" arrays={after.ArraysWithPending}/{after.TotalArrays}" + + $" (arraysBefore={before.ArraysWithPending})"); + } + /// public void Dispose() { diff --git a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs index dede5851..1ae0191b 100644 --- a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs +++ b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs @@ -51,11 +51,15 @@ internal sealed class WorldRenderDiagnostics { private readonly IRenderGlStateReader _gl; private readonly IRenderFrameDiagnosticLog _log; + private readonly HashSet _lastViewerFloodCells = []; + private readonly HashSet _outStageUnmatched = []; + private readonly HashSet _outStageMatched = []; private readonly Stopwatch _terrainStopwatch = new(); private readonly RollingTimingSampleWindow _terrainSamples = new(256); private string? _lastRenderSignature; private int _renderSignatureFrame; private int _renderSignatureStableFrames; + private string? _lastViewerSignature; private string? _lastGlStateSignature; private long _glStateFrame; private long _glStateStableFrames; @@ -64,6 +68,10 @@ internal sealed class WorldRenderDiagnostics private long _postWorldGlStateStableFrames; private string? _lastScissorSignature; private long _scissorSequence; + private string? _lastOutStageSignature; + private string? _lastOutStageRoutingSignature; + private readonly Dictionary _outStageOwnerVerdicts = []; + private readonly Dictionary _phantomObjectSignatures = []; private string? _lastClipRouteSignature; private long _clipRouteSequence; private readonly List _clipRouteCellKeys = []; @@ -250,6 +258,75 @@ internal sealed class WorldRenderDiagnostics _log.WriteLine($"[clip-route] n={_clipRouteSequence} {signature}"); } + public void EmitOutStageOwner( + bool enabled, + IReadOnlySet watchedEntityIds, + WorldEntity entity, + Vector3 sphereCenter, + float sphereRadius, + int sliceIndex, + bool passed) + { + if (!enabled + || !watchedEntityIds.Contains(entity.Id) + || (_outStageOwnerVerdicts.TryGetValue(entity.Id, out bool previous) + && previous == passed)) + { + return; + } + + _outStageOwnerVerdicts[entity.Id] = passed; + _log.WriteLine(FormattableString.Invariant( + $"[outstage-own] id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) c=({sphereCenter.X:F1},{sphereCenter.Y:F1},{sphereCenter.Z:F1}) r={sphereRadius:F1} slice={sliceIndex} {(passed ? "PASS" : "CULL")}")); + } + + public void EmitOutStageRouting( + bool enabled, + int sliceIndex, + IReadOnlyList entities, + ViewconeCuller viewcone) + { + if (!enabled) + return; + + var text = new StringBuilder(192); + text.Append("slice=").Append(sliceIndex) + .Append(" outStage=").Append(entities.Count).Append(" ["); + for (int i = 0; i < entities.Count; i++) + { + WorldEntity entity = entities[i]; + EntitySphere(entity, out Vector3 center, out float radius); + bool passed = viewcone.SphereVisibleInOutsideSlice( + sliceIndex, + center, + radius); + if (i > 0) + text.Append(' '); + text.Append(FormattableString.Invariant( + $"0x{(entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id):X8}(s{entity.SourceGfxObjOrSetupId:X8}):{(passed ? "PASS" : "CULL")}:r={radius:F1}")); + } + text.Append(']'); + string signature = text.ToString(); + if (signature == _lastOutStageRoutingSignature) + return; + _lastOutStageRoutingSignature = signature; + _log.WriteLine("[outstage] " + signature); + } + + public void EmitPhantomObjects(bool enabled, uint cellId, int survivorCount) + { + if (!enabled + || (_phantomObjectSignatures.TryGetValue(cellId, out int previous) + && previous == survivorCount)) + { + return; + } + + _phantomObjectSignatures[cellId] = survivorCount; + _log.WriteLine( + $"[phantom-objs] cell=0x{cellId:X8} entities={survivorCount} (drawn unclipped, no viewcone)"); + } + public void EmitSeamMask( bool enabled, IReadOnlySet targetCells, @@ -297,7 +374,57 @@ internal sealed class WorldRenderDiagnostics $"[pv-input] outRoot={root} flood={portalFrame.OrderedVisibleCells.Count} eye=({eye.X:F6},{eye.Y:F6},{eye.Z:F6}) player=({player.X:F6},{player.Y:F6},{player.Z:F6}) rawPlayer=({rawPlayer.X:F6},{rawPlayer.Y:F6},{rawPlayer.Z:F6}) yaw={yaw:F8} {terrain} vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]")); } + public void EmitOutStageParticles( + bool enabled, + ParticleSystem? particles, + IReadOnlySet ownerIds) + { + if (!enabled || particles is null) + return; + + int matched = 0; + int attached = 0; + int unattached = 0; + _outStageUnmatched.Clear(); + _outStageMatched.Clear(); + foreach (var (emitter, _) in particles.EnumerateLive()) + { + if (emitter.AttachedObjectId == 0) + { + unattached++; + continue; + } + + attached++; + if (ownerIds.Contains(emitter.AttachedObjectId)) + { + matched++; + if (_outStageMatched.Count < 48) + _outStageMatched.Add(emitter.AttachedObjectId); + } + else if (_outStageUnmatched.Count < 12) + { + _outStageUnmatched.Add(emitter.AttachedObjectId); + } + } + + var unmatched = new StringBuilder(96); + foreach (uint id in _outStageUnmatched) + unmatched.Append(FormattableString.Invariant($" 0x{id:X8}")); + var matchedIds = new StringBuilder(192); + foreach (uint id in _outStageMatched) + matchedIds.Append(FormattableString.Invariant($" 0x{id:X8}")); + string signature = FormattableString.Invariant( + $"ids={ownerIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{matchedIds}] unmatchedIds=[{unmatched}]"); + if (signature == _lastOutStageSignature) + return; + + _lastOutStageSignature = signature; + _log.WriteLine("[outstage-pt] " + signature); + } + public void EmitRetailPViewDiagnostics( + bool viewerEnabled, bool visibilityEnabled, bool flapEnabled, RetailPViewFrameResult result, @@ -306,8 +433,22 @@ internal sealed class WorldRenderDiagnostics uint playerCellId, Vector3 cameraPosition, Vector3 playerPosition, + Matrix4x4 cameraView, CameraCellResolution cameraCellResolution) { + if (viewerEnabled) + { + string signature = FormattableString.Invariant( + $"root=0x{clipRoot.CellId:X8}{(clipRoot.IsOutdoorNode ? "(OUT)" : string.Empty)} flood={result.PortalFrame.OrderedVisibleCells.Count} outPolys={result.PortalFrame.OutsideView.Polygons.Count} pCell=0x{playerCellId:X8}"); + if (signature != _lastViewerSignature) + { + _lastViewerSignature = signature; + _log.WriteLine(FormattableString.Invariant( + $"[viewer] {signature} eye=({cameraPosition.X:F3},{cameraPosition.Y:F3},{cameraPosition.Z:F3}) fwd=({-cameraView.M13:F4},{-cameraView.M23:F4},{-cameraView.M33:F4}) viewerCell=0x{viewerCellId:X8}")); + EmitViewerDiff(result.PortalFrame.OrderedVisibleCells); + } + } + if (visibilityEnabled) { AcDream.Core.Rendering.RenderingDiagnostics.EmitVis( @@ -478,6 +619,49 @@ internal sealed class WorldRenderDiagnostics + $"stencil={(state.Stencil ? 1 : 0)} " + $"clip=0x{state.ClipBits:X2} err=0x{state.Error:X}"; + private void EmitViewerDiff(IReadOnlyList current) + { + var text = new StringBuilder(96); + text.Append("[viewer-diff] added=["); + bool first = true; + foreach (uint cell in current) + { + if (_lastViewerFloodCells.Contains(cell)) + continue; + if (!first) + text.Append(','); + text.Append("0x").Append(cell.ToString("X8")); + first = false; + } + + text.Append("] removed=["); + first = true; + foreach (uint cell in _lastViewerFloodCells) + { + bool present = false; + for (int index = 0; index < current.Count; index++) + { + if (current[index] == cell) + { + present = true; + break; + } + } + if (present) + continue; + if (!first) + text.Append(','); + text.Append("0x").Append(cell.ToString("X8")); + first = false; + } + text.Append(']'); + _log.WriteLine(text.ToString()); + + _lastViewerFloodCells.Clear(); + foreach (uint cell in current) + _lastViewerFloodCells.Add(cell); + } + private static string FormatVector(Vector3 value) { static float Quantize(float component) => MathF.Round(component * 20f) / 20f; @@ -564,4 +748,15 @@ internal sealed class WorldRenderDiagnostics .Append(" live=").Append(partition.Dynamics.Count) .ToString(); } + + private static void EntitySphere( + WorldEntity entity, + out Vector3 center, + out float radius) + { + if (entity.AabbDirty) + entity.RefreshAabb(); + center = (entity.AabbMin + entity.AabbMax) * 0.5f; + radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f; + } } diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 5426231e..86be4d0a 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -43,21 +43,6 @@ internal readonly record struct WorldRootFrame( bool PlayerIndoorGate) { public bool RenderSky => ViewerRoot is null || RootSeenOutside; - - /// - /// The camera has an EnvCell root whose authored visibility does not reach - /// outdoors. SeenOutside cells are open-air presentation cells, not an - /// indoor environment gate merely because they have portal geometry. - /// - public bool CameraInsideEnclosedCell => CameraInsideCell && !RootSeenOutside; - - /// Environment gate shared by directional shadows and other - /// outdoor-only render-pack effects. - public bool PlayerOrCameraInsideEnclosedCell => - PlayerInsideCell || CameraInsideEnclosedCell; - - public bool IsAtmosphericallyOutdoor => - RenderSky && !CameraInsideEnclosedCell; } /// Borrowed building scratch, valid only until the next build. @@ -465,6 +450,10 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation private readonly HashSet _visibleCells = []; private bool _visibleCellsValid; + /// TEMPORARY #429 — see . + private readonly PlayerPresentationProbe? _playerPresentProbe = + PlayerPresentationProbe.CreateFromEnvironment(); + public RuntimeWorldFrameEnvironmentPreparation( RuntimeOptions options, WorldTimeService worldTime, @@ -500,6 +489,10 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation activeDayGroup, camera.Position); + // TEMPORARY #429 apparatus — one CSV row per frame; off unless + // ACDREAM_PROBE_PLAYER_PRESENT names a file. Strip with the fix. + _playerPresentProbe?.Observe(camera.Position, roots.PlayerViewPosition); + UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell); _lighting.UpdateViewerLight(roots.PlayerViewPosition); _lighting.Tick(camera.Position); @@ -528,6 +521,17 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation // (150..1500 m) fog and hid the fact that the sky pass was being // fogged at all (see SkyRenderer / sky.frag). _lightingUbo?.Upload(ubo); + + RenderingDiagnostics.EmitLight( + insideCell: roots.PlayerInsideCell, + ambientR: _lighting.CurrentAmbient.AmbientColor.X, + ambientG: _lighting.CurrentAmbient.AmbientColor.Y, + ambientB: _lighting.CurrentAmbient.AmbientColor.Z, + sunIntensity: _lighting.Sun?.Intensity ?? 0f, + registeredLights: _lighting.RegisteredCount, + activeLights: (int)ubo.CellAmbient.W, + playerCellId: roots.PlayerRoot?.CellId ?? 0u, + lights: _lighting); } public void ObserveDrawableCells(IReadOnlySet drawableCells) diff --git a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs index e36f8a0c..e3dbb540 100644 --- a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs @@ -64,6 +64,10 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics private readonly DebugVmRenderFactsPublisher _debugVm; private readonly bool _debugVmConsumerActive; private int _debugDrawLogCount; + // #337 (TEMPORARY): built on first use so the ordinary overlay path and + // every headless/no-window host pay nothing for it. + private CollisionMeshWireframe? _meshWireframe; + private CollisionMeshWireframeStats _lastMeshStats = new(-1, -1, -1, -1, false); public WorldSceneDiagnosticsController( WorldRenderDiagnostics diagnostics, @@ -215,6 +219,18 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics if (!_state.CollisionWireframesVisible || _lines is null) return; + // #337 (2026-08-06 — TEMPORARY): ACDREAM_WIRE_MESH=1 replaces the + // broadphase-proxy overlay below with the objects' real physics-BSP + // polygon edges beside their visual mesh boxes and the terrain surface + // — see CollisionMeshWireframe for why the proxy cannot answer the + // question this mode exists for. Default off; F2 keeps its old + // behaviour otherwise. + if (RenderingDiagnostics.CollisionMeshWireframeEnabled) + { + DrawCollisionMesh(in camera); + return; + } + _lines.Begin(); int drawn = 0; foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug()) @@ -255,6 +271,50 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics _lines.Flush(camera.Camera.View, camera.Projection); } + /// + /// #337 (2026-08-06 — TEMPORARY, strip with the probe family). Centres on + /// the player when there is one, else on the camera, so the fly-camera + /// mode can inspect geometry too. + /// + private void DrawCollisionMesh(in WorldCameraFrame camera) + { + Vector3 centre = _mode.IsPlayerMode && _player.Controller is { } controller + ? controller.Position + : camera.Position; + + _meshWireframe ??= new CollisionMeshWireframe(_physics); + + _lines!.Begin(); + CollisionMeshWireframeStats stats = _meshWireframe.Draw(_lines, centre); + + if (_mode.IsPlayerMode && _player.Controller is { } player) + { + _lines.AddCylinder( + player.Position, + DebugVmRenderFactsPublisher.PlayerCollisionRadius, + 1.8f, + new Vector3(1f, 0f, 0f)); + } + + _lines.Flush(camera.Camera.View, camera.Projection); + + // A capped frame is showing PARTIAL geometry, which would otherwise be + // indistinguishable from an object that has none — exactly the + // misreading this overlay exists to prevent. Say so, throttled, rather + // than letting the picture lie. + if (stats != _lastMeshStats) + { + _lastMeshStats = stats; + Console.WriteLine(string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "[wire-mesh] centre=({0:F2},{1:F2},{2:F2}) objects={3} polys={4} " + + "noPhysicsGeometry={5} lines={6} capped={7}", + centre.X, centre.Y, centre.Z, + stats.ObjectsConsidered, stats.PolygonsDrawn, + stats.ObjectsWithoutPhysicsGeometry, stats.LinesDrawn, stats.Capped)); + } + } + private void LogNearbyCollisionObjects(Vector3 playerPosition, int drawn) { if (_debugDrawLogCount >= 5) diff --git a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs index fe14a5fc..af85081e 100644 --- a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs +++ b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs @@ -221,10 +221,17 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor return AppendSignature(currentSignature, "global"); } - // Every PView root, including the outdoor sentinel, now submits scene - // particles inside LScape::draw. Replaying them here is both a duplicate - // and too late: it occurs after nested building cells, allowing exterior - // waterfall/foliage alpha to repaint an indoor/outdoor transition. + if (clipRoot.IsOutdoorNode) + { + _particleRenderer.DrawForOwners( + camera.Camera, + camera.Position, + ParticleRenderPass.Scene, + outdoorOwnerIds, + includeUnattached: true); + return AppendSignature(currentSignature, "unattached"); + } + return currentSignature; } diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index e12fd765..76ed43e5 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -2,7 +2,6 @@ using AcDream.App.Interaction; using AcDream.App.Net; using AcDream.Core.CharGen; using AcDream.Runtime; -using AcDream.Runtime.Chat; using AcDream.Runtime.Session; using AcDream.Runtime.World; using AcDream.UI.Abstractions; @@ -22,7 +21,6 @@ internal sealed class CurrentGameRuntimeAdapter { private readonly GameRuntime _runtime; private readonly CurrentGameRuntimeCommandAdapter _commands; - private readonly ICommandBus _commandBus; private readonly CharacterSelectionProjection _characterSelection; private readonly CharacterCreationProjection _characterCreation; private readonly IDisposable _hostLease; @@ -41,7 +39,6 @@ internal sealed class CurrentGameRuntimeAdapter ArgumentNullException.ThrowIfNull(commands); ArgumentNullException.ThrowIfNull(selection); - _commandBus = commands; _hostLease = runtime.AcquireHostLease( "graphical game-runtime command adapter"); try @@ -140,24 +137,6 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimeAllegianceCommands AllegianceCommands => _commands; IRuntimeAllegianceCommands IGameRuntimeCommands.Allegiance => _commands; - /// - /// Automation entrance to the same parser used by the retail chat field. - /// Plugins see this only through the BCL-only IPluginChat contract. - /// - internal bool SubmitChatText(string text) - { - if (!IsActive || string.IsNullOrWhiteSpace(text)) - return false; - SubmitOutcome outcome = ChatCommandRouter.Submit( - text, - new RuntimeChatCommandFeedback(_runtime.CommunicationOwner), - _commandBus, - ChatChannelKind.Say); - return outcome is not (SubmitOutcome.Empty - or SubmitOutcome.UnknownCommand - or SubmitOutcome.Dropped); - } - public RuntimeStateCheckpoint CaptureCheckpoint() { RuntimeStateCheckpoint checkpoint = _runtime.CaptureCheckpoint(); diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index 2e15bffe..7ec92c68 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -317,28 +317,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter return Result(status); } - public RuntimeCommandResult ExecuteMotion( - RuntimeGenerationToken expectedGeneration, - uint motionCommand) - { - RuntimeCommandStatus gate = Validate( - expectedGeneration, - requireWorld: true); - if (gate != RuntimeCommandStatus.Accepted) - return Result(gate); - - RuntimeCommandStatus status = _movement.ExecuteMotion(motionCommand) - ? RuntimeCommandStatus.Accepted - : RuntimeCommandStatus.Unsupported; - - _events.EmitCommand( - RuntimeCommandDomain.Movement, - operation: 0x102, - status, - motionCommand); - return Result(status, motionCommand); - } - public RuntimeCommandResult SetIntent( RuntimeGenerationToken expectedGeneration, in MovementInput input) @@ -740,36 +718,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus.Accepted); } - public RuntimeCommandResult SetTitle( - RuntimeGenerationToken expectedGeneration, - uint titleId) - { - RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); - if (gate != RuntimeCommandStatus.Accepted) - return Result(gate); - // F3 (CT2 fix round, 2026-08-24): NO id-0 guard here — retail's own - // send path (Event_SetDisplayCharacterTitle @0x006a5720) packs - // whatever id it is handed, and ACE accepts id 0 - // (CharacterTitle.Invalid is a defined enum value). Retail's - // protection against sending an unearned/invalid id is the UI - // ghost-when-current gate (CT3's job), not a send-side rejection — - // a client-side guard here blocks a state the server honors. - // CT2: NO optimistic local mutation — matches - // DirectGameRuntimeCommandAdapter.SetTitle; RuntimeCharacterState. - // Titles updates only from the server's own echo. - _commands.Publish(new SetTitleRuntimeCmd(titleId)); - // A6 (CT2 fix round): titleId rides the EmitResult objectId slot — - // same in-class precedent as Advance's command.StatId above (see - // the S4 history comment on SaveOptions/EmitResult, which - // established this field as a typed domain-payload-id slot, not - // always an object guid). - return EmitResult( - RuntimeCommandDomain.Character, - operation: 6, - RuntimeCommandStatus.Accepted, - titleId); - } - public RuntimeCommandResult Execute( RuntimeGenerationToken expectedGeneration, in RuntimeFriendCommand command) diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 41790ed5..569b551b 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -42,16 +42,14 @@ public sealed record RuntimeOptions( string? LivePass, bool DevTools, bool UncappedRendering, - /// #435 kept deliberately: NOT a spent probe. The canonical - /// nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on the - /// `move-truth OUT` records this emits — it is the automated proof that - /// production input produced outbound movement traffic. bool DumpMoveTruth, bool DumpSky, bool NoAudio, int HidePartIndex, bool RetailCloseDegrades, bool DumpSceneryZ, + bool DumpLiveSpawns, + bool DumpClothing, int? LegacyStreamRadius, bool RetailUi, /// Campaign CC slice CC4: interim env/test-only seam that opens @@ -116,17 +114,6 @@ public sealed record RuntimeOptions( /// , milliseconds. int LoginCommandDelayMs) { - public string? PreparedAssetOverlayPath { get; init; } - - public uint? PreparedAssetBaseRecipeVersion { get; init; } - - public uint? PreparedAssetEffectiveRecipeVersion { get; init; } - - /// Optional machine-local peer tags advertised to other plugin - /// instances. Parsed once here so the live automation surface never reads - /// process configuration directly. - public IReadOnlyList PluginTags { get; init; } = []; - /// /// Build options from the process environment. Used by /// Program.cs at startup. @@ -170,6 +157,8 @@ public sealed record RuntimeOptions( // only for before/after diagnostic comparisons. RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal), DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")), + DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")), + DumpClothing: IsExactlyOne(env("ACDREAM_DUMP_CLOTHING")), // 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")), @@ -226,12 +215,11 @@ public sealed record RuntimeOptions( // can be exercised on hardware that actually supports everything. VulkanForcedUnsupportedFeature: NullIfEmpty(env("ACDREAM_VULKAN_FORCE_UNSUPPORTED")), - // Campaign V slice V6h: run the V5/V6c bring-up harness — - // capability gate plus the synthetic verification scenes — instead - // of the real composition host. A diagnostic for "does this machine - // pass the Vulkan gate, and does the backend draw?". (The former - // ACDREAM_RENDER_BACKEND=vulkan co-requisite died with the OpenGL - // backend at Campaign V; this flag alone gates the harness.) + // Campaign V slice V6h: with ACDREAM_RENDER_BACKEND=vulkan, run the + // V5/V6c bring-up harness — capability gate plus the synthetic + // verification scenes — instead of the real composition host. A + // diagnostic for "does this machine pass the Vulkan gate, and does + // the backend draw?"; ignored on OpenGL. VulkanCapabilityProbe: IsExactlyOne(env("ACDREAM_VULKAN_PROBE")), // Campaign V slice V9: bound the probe harness to a frame budget so @@ -252,10 +240,7 @@ public sealed record RuntimeOptions( StatusFilePath: null, Plugins: null, LoginCommands: [], - LoginCommandDelayMs: 500) - { - PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")), - }; + LoginCommandDelayMs: 500); } /// @@ -286,12 +271,6 @@ public sealed record RuntimeOptions( { PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath) ?? baseOptions.PreparedAssetPath, - PreparedAssetOverlayPath = - NullIfEmpty(content?.PreparedAssetOverlayPath), - PreparedAssetBaseRecipeVersion = - content?.PreparedAssetBaseRecipeVersion, - 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. @@ -375,15 +354,6 @@ public sealed record RuntimeOptions( private static string? NullIfEmpty(string? s) => string.IsNullOrEmpty(s) ? null : s; - private static IReadOnlyList ParsePluginTags(string? value) => - (value ?? string.Empty) - .Split(',', StringSplitOptions.RemoveEmptyEntries - | StringSplitOptions.TrimEntries) - .Where(static tag => tag.Length <= 128) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(128) - .ToArray(); - private static int? TryParseInt(string? s) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null; diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs index 5654ebb8..5e1e04cc 100644 --- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs +++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs @@ -557,6 +557,15 @@ public sealed class LandblockBuildFactory // Phase 2d: static objects inside the EnvCell. foreach (var stab in envCell.StaticObjects) { + // #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY- + // targeted stabs. This is the MOMENT MeshRefs are constructed — + // a degraded dat read here (setup null / placement frames short / + // part GfxObj null) permanently corrupts the entity (H-A), and + // nothing downstream ever rebuilds it. Inert when the set is empty. + bool dumpStab = AcDream.Core.Rendering.RenderingDiagnostics + .DumpEntitySourceIds.Contains(stab.Id); + int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0; + // #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to // nothing (GfxObj id 0) at any runtime distance, so retail's distance-based // degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the @@ -587,6 +596,10 @@ public sealed class LandblockBuildFactory if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); } + else if (dumpStab) + { + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} GFXOBJ-NULL -> entity dropped"); + } } else if ((stab.Id & 0xFF000000u) == 0x02000000u) { @@ -595,6 +608,12 @@ public sealed class LandblockBuildFactory { stabLightCount = setup.Lights.Count; var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + if (dumpStab) + { + dumpSetupParts = setup.Parts.Count; + dumpPlacementFrames = setup.PlacementFrames.Count; + dumpFlattened = flat.Count; + } foreach (var mr in flat) { // #136: skip an editor-only marker PART (retail hides it at runtime @@ -606,6 +625,9 @@ public sealed class LandblockBuildFactory var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) { + dumpDropped++; + if (dumpStab) + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped"); continue; } var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); @@ -613,12 +635,20 @@ public sealed class LandblockBuildFactory meshRefs.Add(mr); } } + else if (dumpStab) + { + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} SETUP-NULL -> entity dropped"); + } } if (!AcDream.Core.Meshing.EntityHydrationRules.ShouldKeepEntity(meshRefs.Count, stabLightCount)) { + if (dumpStab) + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights=0 -> entity dropped"); continue; } + if (meshRefs.Count == 0 && dumpStab) + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights={stabLightCount} -> KEPT as mesh-less light carrier"); // Stabs inside EnvCells are already in landblock-local coordinates // (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would @@ -641,6 +671,20 @@ public sealed class LandblockBuildFactory if (interiorBounds.TryGet(out var ibMin, out var ibMax)) hydrated.SetLocalBounds(ibMin, ibMax); + if (dumpStab) + { + Console.WriteLine( + $"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} entId=0x{hydrated.Id:X8} " + + $"setupParts={dumpSetupParts} placementFrames={dumpPlacementFrames} flattened={dumpFlattened} " + + $"built={meshRefs.Count} dropped={dumpDropped} " + + $"pos=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2})"); + for (int i = 0; i < meshRefs.Count; i++) + { + var t = meshRefs[i].PartTransform.Translation; + Console.WriteLine($"[dump-entity] hyd-part[{i:D2}] gfx=0x{meshRefs[i].GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3})"); + } + } + result.Add(hydrated); } } diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 22e87446..1e0554a9 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -262,12 +262,11 @@ internal sealed class RuntimeLocalPlayerLogoutOperations { get { - var bitfield = (AcDream.Core.Items.PublicWeenieFlags)(_objects.Get(_identity.ServerGuid) - ?.PublicWeenieBitfield ?? 0u); + uint bitfield = _objects.Get(_identity.ServerGuid) + ?.PublicWeenieBitfield ?? 0u; // IsPlayerKiller @ 0x0058C910: (bitfield & 0x20) | (bitfield & // 0x2000000) — the PK and PKLite PWD bits. - return (bitfield & AcDream.Core.Items.PublicWeenieFlags.PlayerKiller) != 0 - || (bitfield & AcDream.Core.Items.PublicWeenieFlags.PlayerKillerLite) != 0; + return (bitfield & 0x20u) != 0u || (bitfield & 0x2000000u) != 0u; } } @@ -555,20 +554,7 @@ internal sealed class LocalPlayerTeleportPresentation var (snapshot, events) = _animation.Tick( deltaSeconds, worldReady, - CurrentTunnelFrame, - holdInTunnel: StreamingDiagnostics.TunnelFreezeFrame.HasValue); - - // Retail hides portal space before publishing the WorldFadeIn view - // plane (gmSmartBoxUI::UseTime @ 0x004D73D3). Keep that viewport - // swap inside the presentation update: the controller's exit event - // performs host work, and allowing the terminal projection to escape - // while the tunnel remains visible exposes the finite portal mesh as - // a one-frame faceted disk. Hiding first is also safe if a render - // boundary lands between these two writes: the destination world is - // already materialized and receives the old outgoing projection. - if (!snapshot.ShowTunnel && _tunnel.IsVisible) - _tunnel.Exit(); - + CurrentTunnelFrame); _viewPlane.Update(snapshot); return (snapshot, events); } @@ -736,16 +722,6 @@ internal sealed class LocalPlayerTeleportController /// private readonly ILocalPlayerLogoutOperations _logout; - /// - /// The confirmed-logoff pump must let the old streaming window finish - /// its asynchronous session retirement before Runtime exposes the next - /// character generation. The reset transaction immediately calls back - /// into ; this latch transfers - /// that already-completed retirement across the synchronous callback so - /// it is consumed once instead of starting a second old-window pass. - /// - private bool _logoutStreamingRetirementPrepared; - public LocalPlayerTeleportController( ILocalPlayerTeleportAuthority authority, ILocalPlayerTeleportInputLifetime input, @@ -994,8 +970,6 @@ internal sealed class LocalPlayerTeleportController return false; } - _logoutStreamingRetirementPrepared = false; - if (!_transit.TryBeginLogoutRequest(_logout.IsLocalPlayerKiller)) return false; @@ -1100,23 +1074,8 @@ internal sealed class LocalPlayerTeleportController private void CompleteLogoutHandoff(long generation) { - // Reset(sessionEnding: true) is a retained, frame-budgeted old-world - // retirement. Do not let CompleteCharacterLogOff expose the fresh - // Runtime generation until that barrier has converged; otherwise a - // quick re-entry can inherit the origin-recenter gate and remain in - // portal space with no landblocks admitted (lb 0/0). - if (!_streaming.ResetRecenter(sessionEnding: true) - || _lifetimeGeneration != generation) - { - return; - } - - _logoutStreamingRetirementPrepared = true; if (!_transit.CompleteLogout() || _lifetimeGeneration != generation) - { - _logoutStreamingRetirementPrepared = false; return; - } Console.WriteLine( "live: logout confirmed — returning to character select"); @@ -1129,8 +1088,6 @@ internal sealed class LocalPlayerTeleportController return; } - _logoutStreamingRetirementPrepared = false; - // The transaction refused or degraded to a full stop. If a reset // reached this controller the lifetime moved and everything is // already clean; otherwise retire the presentation here so a @@ -1270,19 +1227,16 @@ internal sealed class LocalPlayerTeleportController return; break; case TeleportAnimEvent.PlayExitSound: - // Retail first hides portal space, then shows the world, - // and only afterwards plays Sound_UI_ExitPortal - // (gmSmartBoxUI::UseTime @ 0x004D73D3..0x004D7405). - // ExitTunnel is idempotent: the production presentation - // normally retired it before publishing this snapshot, - // while this call enforces the same order for every host. - _presentation.ExitTunnel(); - if (!IsCurrentLifetime(generation, sequence)) - return; + // gmSmartBoxUI::UseTime @ 0x004D6E30 releases destination + // cell blocking at the exact portal/world viewport swap. + // LoginComplete remains one WorldFadeIn second later. _worldReveal.RevealWorldViewport(); if (!IsCurrentLifetime(generation, sequence)) return; _presentation.PlayExitCue(); + if (!IsCurrentLifetime(generation, sequence)) + return; + _presentation.ExitTunnel(); if (!IsCurrentLifetime(generation, sequence)) return; break; @@ -1752,16 +1706,17 @@ internal sealed class LocalPlayerTeleportController return; break; case TeleportAnimEvent.PlayExitSound: - // Identical retail viewport order to the teleport pump: - // hide portal, show/release the destination world, then - // play Sound_UI_ExitPortal @ 0x004D7405. - _presentation.ExitTunnel(); - if (!IsCurrentLoginLifetime(generation, revealGeneration)) - return; + // Release destination cell blocking at the exact + // portal/world viewport swap — same edge as the teleport + // pump (gmSmartBoxUI::UseTime @ 0x004D6E30), with + // Sound_UI_ExitPortal @ 0x004D7405. _worldReveal.RevealWorldViewport(); if (!IsCurrentLoginLifetime(generation, revealGeneration)) return; _presentation.PlayExitCue(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _presentation.ExitTunnel(); if (!IsCurrentLoginLifetime(generation, revealGeneration)) return; break; @@ -1956,11 +1911,6 @@ internal sealed class LocalPlayerTeleportController bool clearSession, bool resetCanonicalTransit = false) { - bool streamingRetirementPrepared = clearSession - && _logoutStreamingRetirementPrepared; - if (clearSession) - _logoutStreamingRetirementPrepared = false; - long generation = checked(++_lifetimeGeneration); _pendingCell = 0u; @@ -1985,8 +1935,7 @@ internal sealed class LocalPlayerTeleportController if (clearSession) _loginPlacementCompleted = false; - if (!streamingRetirementPrepared) - _streaming.ResetRecenter(clearSession); + _streaming.ResetRecenter(clearSession); if (_lifetimeGeneration != generation) return generation; diff --git a/src/AcDream.App/Streaming/RevealTimingProbe.cs b/src/AcDream.App/Streaming/RevealTimingProbe.cs index 97f1f051..3dd942bb 100644 --- a/src/AcDream.App/Streaming/RevealTimingProbe.cs +++ b/src/AcDream.App/Streaming/RevealTimingProbe.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using AcDream.App.Rendering; using AcDream.Runtime; namespace AcDream.App.Streaming; @@ -21,9 +20,7 @@ namespace AcDream.App.Streaming; /// shape. /// 1 Hz progress — elapsed, the three flags, and the resident /// landblock count, so a budget-paced linear drip is visually obvious in the -/// log. A paired [reveal-resource] snapshot attributes the cold render -/// barrier to mesh preparation/upload, arena growth, texture composites, or -/// process/GPU residency without sampling those owners every frame. +/// log. /// SUMMARY once at the viewport reveal — the per-edge /// timeline on one line. /// @@ -34,7 +31,6 @@ namespace AcDream.App.Streaming; internal sealed class RevealTimingProbe { private readonly Func? _loadedLandblockCount; - private readonly IRenderFrameResourceDiagnosticsSource? _renderResources; private readonly Stopwatch _clock = new(); private long _generation; private string _kind = ""; @@ -53,13 +49,8 @@ internal sealed class RevealTimingProbe private long _lastProgressMs; private int _framesSinceProgress; - public RevealTimingProbe( - Func? loadedLandblockCount, - IRenderFrameResourceDiagnosticsSource? renderResources = null) - { + public RevealTimingProbe(Func? loadedLandblockCount) => _loadedLandblockCount = loadedLandblockCount; - _renderResources = renderResources; - } public void Begin( string kind, @@ -89,7 +80,6 @@ internal sealed class RevealTimingProbe + $"cell=0x{destinationCell:X8} window={window.NearRadius}/" + $"{window.FarRadius} landblocks={_windowLandblocks} " + $"loaded={_loadedLandblockCount?.Invoke() ?? -1}"); - EmitResourceSnapshot("begin", elapsedMilliseconds: 0); } public void Observe( @@ -101,56 +91,37 @@ internal sealed class RevealTimingProbe _framesSinceProgress++; long elapsed = _clock.ElapsedMilliseconds; - string? resourceCheckpoint = null; if (!_render && readiness.IsRenderNeighborhoodReady) { _render = true; _renderMs = elapsed; Edge("render-ready", elapsed); - resourceCheckpoint = AppendCheckpoint( - resourceCheckpoint, - "render-ready"); } if (!_composites && readiness.AreCompositeTexturesReady) { _composites = true; _compositesMs = elapsed; Edge("composites-ready", elapsed); - resourceCheckpoint = AppendCheckpoint( - resourceCheckpoint, - "composites-ready"); } if (!_collision && readiness.IsCollisionReady) { _collision = true; _collisionMs = elapsed; Edge("collision-ready", elapsed); - resourceCheckpoint = AppendCheckpoint( - resourceCheckpoint, - "collision-ready"); } if (!_gateReady && readiness.IsReady) { _gateReady = true; _gateReadyMs = elapsed; Edge("gate-ready", elapsed); - resourceCheckpoint = AppendCheckpoint( - resourceCheckpoint, - "gate-ready"); } if (!_materialized && portal.Materialized) { _materialized = true; _materializedMs = elapsed; Edge("materialized", elapsed); - resourceCheckpoint = AppendCheckpoint( - resourceCheckpoint, - "materialized"); } - if (resourceCheckpoint is not null) - EmitResourceSnapshot(resourceCheckpoint, elapsed); - if (!_summarized && portal.WorldViewportObserved) { _summarized = true; @@ -161,7 +132,6 @@ internal sealed class RevealTimingProbe + $"gateReadyMs={_gateReadyMs} " + $"materializedMs={_materializedMs} " + $"landblocks={_windowLandblocks}"); - EmitResourceSnapshot("summary", elapsed); return; } @@ -181,7 +151,6 @@ internal sealed class RevealTimingProbe + $"loaded={_loadedLandblockCount?.Invoke() ?? -1}" + $"/{_windowLandblocks} " + $"frames={_framesSinceProgress}"); - EmitResourceSnapshot("progress", elapsed); PublicationTimingProbe.EmitStreamingTickWindow(); _framesSinceProgress = 0; } @@ -193,77 +162,4 @@ internal sealed class RevealTimingProbe + $"elapsedMs={elapsed} " + $"loaded={_loadedLandblockCount?.Invoke() ?? -1}" + $"/{_windowLandblocks}"); - - private void EmitResourceSnapshot(string checkpoint, long elapsedMilliseconds) - { - if (_renderResources is null) - return; - - RenderFrameResourceDiagnosticsSnapshot snapshot = - _renderResources.Capture(); - Console.WriteLine(FormatResourceLine( - checkpoint, - _kind, - _generation, - elapsedMilliseconds, - snapshot)); - } - - private static string AppendCheckpoint(string? current, string next) => - current is null ? next : current + "+" + next; - - internal static string FormatResourceLine( - string checkpoint, - string kind, - long generation, - long elapsedMilliseconds, - in RenderFrameResourceDiagnosticsSnapshot snapshot) - { - MeshStreamResourceDiagnostics mesh = snapshot.Mesh; - TextureStreamResourceDiagnostics textures = snapshot.Textures; - ProcessResourceDiagnostics process = snapshot.Process; - return $"[reveal-resource] checkpoint={checkpoint} kind={kind} " - + $"gen={generation} elapsedMs={elapsedMilliseconds} " - + $"meshData={mesh.RenderData} meshAtlases={mesh.AtlasArrays} " - + $"meshUnusedLru={mesh.UnusedLru} meshBytes={mesh.EstimatedBytes} " - + $"globalUploads={mesh.GlobalUploadCount} " - + $"globalUploadBytes={mesh.GlobalUploadedBytes} " - + $"frameUploads={mesh.FrameUploadCount} " - + $"frameUploadBytes={mesh.FrameUploadBytes} " - + $"frameArrayBytes={mesh.FrameArrayAllocationBytes} " - + $"frameMipmapBytes={mesh.FrameMipmapBytes} " - + $"frameBufferUploadBytes={mesh.FrameBufferUploadBytes} " - + $"frameBufferAllocationBytes={mesh.FrameBufferAllocationBytes} " - + $"frameBufferCopyBytes={mesh.FrameBufferCopyBytes} " - + $"frameNewArrays={mesh.FrameNewArrayCount} " - + $"frameNewBuffers={mesh.FrameNewBufferCount} " - + $"frameStaleDiscards={mesh.FrameStaleDiscardCount} " - + $"frameMipmapArrays={mesh.FrameMipmapArrayCount} " - + $"staged={mesh.StagedUploadBacklog} " - + $"stagedBytes={mesh.StagedUploadBytes} " - + $"stagingHighWater={(mesh.StagingAtHighWater ? 1 : 0)} " - + $"cpuMeshCache={mesh.CpuMeshCacheCount} " - + $"cpuMeshCacheBytes={mesh.CpuMeshCacheBytes} " - + $"arenaCapacityBytes={mesh.GlobalCapacityBytes} " - + $"arenaPhysicalBytes={mesh.GlobalPhysicalCapacityBytes} " - + $"arenaMigrating={(mesh.GlobalMigrationInProgress ? 1 : 0)} " - + $"prepared={mesh.PreparedProbes}/{mesh.PreparedReads}/" - + $"{mesh.PreparedLoaded}/{mesh.PreparedMissing}/" - + $"{mesh.PreparedCorrupt} " - + $"ownedTextures={textures.OwnedBindlessTextures} " - + $"textureOwners={textures.TextureOwners} " - + $"composites={textures.CachedCompositeTextures} " - + $"unownedComposites={textures.CachedUnownedComposites} " - + $"unownedCompositeBytes={textures.CachedUnownedCompositeBytes} " - + $"compositeAtlases={textures.CompositeAtlases} " - + $"compositeAtlasBytes={textures.CompositeAtlasBytes} " - + $"compositePending={textures.CompositeWarmupPending} " - + $"frameCompositeUploads={textures.FrameCompositeUploadCount} " - + $"frameCompositeUploadBytes={textures.FrameCompositeUploadBytes} " - + $"managedBytes={process.ManagedBytes} " - + $"managedCommittedBytes={process.ManagedCommittedBytes} " - + $"trackedGpuBytes={process.TrackedGpuBytes} " - + $"trackedGpuBuffers={process.TrackedGpuBuffers} " - + $"trackedGpuTextures={process.TrackedGpuTextures}"; - } } diff --git a/src/AcDream.App/Streaming/StreamingDiagnostics.cs b/src/AcDream.App/Streaming/StreamingDiagnostics.cs index f1b9dc00..25f09017 100644 --- a/src/AcDream.App/Streaming/StreamingDiagnostics.cs +++ b/src/AcDream.App/Streaming/StreamingDiagnostics.cs @@ -1,6 +1,5 @@ using System.Globalization; using System; -using AcDream.Core.World; namespace AcDream.App.Streaming; @@ -12,8 +11,6 @@ namespace AcDream.App.Streaming; /// internal static class StreamingDiagnostics { - internal const int DefaultTunnelFreezeFrame = 72; - /// /// #280 A/B measurement probe. When set, the outdoor reveal gate uses this /// landblock radius instead of the derived streaming window, so the same @@ -68,20 +65,6 @@ internal static class StreamingDiagnostics public static bool ProbeRevealTiming { get; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_REVEAL_TIMING") == "1"; - /// - /// #419 RenderDoc apparatus. When present, the teleport sequencer remains - /// in retail's stable Tunnel state even after the destination is - /// ready, and the portal-space animation stops once it reaches this frame. - /// ACDREAM_PROBE_TUNNEL_FREEZE=1 selects the representative frame - /// ; an explicit frame in the range - /// 2.. may be supplied - /// instead. This intentionally prevents placement and viewport reveal - /// until the transition is cancelled or the process exits. It is - /// inspection apparatus, not a user setting. - /// - public static int? TunnelFreezeFrame { get; } = ParseTunnelFreezeFrame( - Environment.GetEnvironmentVariable("ACDREAM_PROBE_TUNNEL_FREEZE")); - /// /// The floor is 1, not 0. An outdoor destination's acknowledgement must /// carry RequiredRenderRadius >= 1 or @@ -95,23 +78,4 @@ internal static class StreamingDiagnostics && value >= 1 ? value : null; - - internal static int? ParseTunnelFreezeFrame(string? raw) - { - if (string.Equals(raw, "1", StringComparison.Ordinal) - || string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) - { - return DefaultTunnelFreezeFrame; - } - - return int.TryParse( - raw, - NumberStyles.Integer, - CultureInfo.InvariantCulture, - out int frame) - && frame >= 2 - && frame <= TeleportAnimSequencer.TunnelEndFrame - ? frame - : null; - } } diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs index 689825b5..8e74ab1e 100644 --- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs +++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs @@ -1,4 +1,3 @@ -using AcDream.App.Rendering; using AcDream.Runtime; using AcDream.Runtime.World; @@ -86,8 +85,7 @@ internal sealed class WorldRevealCoordinator WorldGenerationQuiescence? quiescence = null, IWorldRevealStreamingScheduler? streaming = null, IWorldRevealRenderResourceScheduler? renderResources = null, - Func? loadedLandblockCount = null, - IRenderFrameResourceDiagnosticsSource? renderResourceDiagnostics = null) + Func? loadedLandblockCount = null) { _transit = transit ?? throw new ArgumentNullException(nameof(transit)); _readiness = new WorldRevealReadinessBarrier( @@ -103,11 +101,7 @@ internal sealed class WorldRevealCoordinator _streaming = streaming; _renderResources = renderResources; if (StreamingDiagnostics.ProbeRevealTiming) - { - _timing = new RevealTimingProbe( - loadedLandblockCount, - renderResourceDiagnostics); - } + _timing = new RevealTimingProbe(loadedLandblockCount); } public RuntimePortalSnapshot Snapshot => _transit.Snapshot; diff --git a/src/AcDream.App/UI/AutoWieldController.cs b/src/AcDream.App/UI/AutoWieldController.cs index ea21a67a..e0cd8d66 100644 --- a/src/AcDream.App/UI/AutoWieldController.cs +++ b/src/AcDream.App/UI/AutoWieldController.cs @@ -62,10 +62,10 @@ internal sealed class AutoWieldController : IDisposable private readonly Func _playerGuid; private readonly Action? _sendWield; private readonly Action? _sendPutItemInContainer; + private readonly Action? _toast; private readonly Action? _systemMessage; private readonly CombatState? _combatState; private readonly Action? _sendChangeCombatMode; - private readonly InventoryTransactionState? _transactions; private PendingSwitch? _pendingSwitch; private PendingCombatSettlement? _pendingCombatSettlement; @@ -79,19 +79,19 @@ internal sealed class AutoWieldController : IDisposable Func playerGuid, Action? sendWield, Action? sendPutItemInContainer, + Action? toast, Action? systemMessage = null, CombatState? combatState = null, - Action? sendChangeCombatMode = null, - InventoryTransactionState? transactions = null) + Action? sendChangeCombatMode = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _sendWield = sendWield; _sendPutItemInContainer = sendPutItemInContainer; + _toast = toast; _systemMessage = systemMessage; _combatState = combatState; _sendChangeCombatMode = sendChangeCombatMode; - _transactions = transactions; _objects.ObjectMoved += OnObjectMoved; _objects.ObjectRemoved += OnObjectRemoved; @@ -238,20 +238,8 @@ internal sealed class AutoWieldController : IDisposable : BestAvailableEquipMask(item); if (mask == EquipMask.None) { - // UsingItem calls retail AutoWield with its automatic-unblock flag. - // When every compatible slot is occupied, retail chooses the first - // compatible slot, moves that blocker to the backpack, and retries - // only after RecvNotice_ServerSaysMoveItem confirms the move. - // CPlayerSystem::AutoWield @ 0x0056173D-0x0056186E. - mask = FirstCompatibleEquipMask(item); - ClientObject? blocker = GetEquippedObjectAtLocation( - mask, priority: 0, item.ObjectId); - return blocker is not null - && BeginWeaponReplacement( - item.ObjectId, - blocker, - mask, - combatModeAfterWield: null); + _toast?.Invoke("That slot is already in use"); + return false; } return SendWield(item, mask, combatModeAfterWield: null); @@ -264,7 +252,10 @@ internal sealed class AutoWieldController : IDisposable CombatMode? combatModeAfterWield) { if (_sendPutItemInContainer is null) + { + _toast?.Invoke("That slot is already in use"); return false; + } uint player = _playerGuid(); if (player == 0) @@ -281,17 +272,8 @@ internal sealed class AutoWieldController : IDisposable // is the transaction boundary and preserves its stance-specific motion. _systemMessage?.Invoke( $"Moving {blockingItem.GetAppropriateName()} to your backpack"); - bool dispatched = DispatchInventoryRequest( - InventoryRequestKind.PutInContainer, - blockingItem.ObjectId, - () => - { - _sendPutItemInContainer(blockingItem.ObjectId, player, 0); - return true; - }); - if (!dispatched) - _pendingSwitch = null; - return dispatched; + _sendPutItemInContainer(blockingItem.ObjectId, player, 0); + return true; } private bool SendWield( @@ -306,25 +288,16 @@ internal sealed class AutoWieldController : IDisposable BlockingItemId: 0, RequestedMask: mask, CombatModeAfterWield: combatModeAfterWield); - // Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590. - bool dispatched = DispatchInventoryRequest( - InventoryRequestKind.Wield, - item.ObjectId, - () => - { - _sendWield(item.ObjectId, (uint)mask); - return true; - }); - if (!dispatched) + if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask)) + { _pendingSwitch = null; - return dispatched; - } + return false; + } - private bool DispatchInventoryRequest( - InventoryRequestKind kind, - uint itemId, - Func dispatch) - => _transactions?.TryDispatch(kind, itemId, dispatch) ?? dispatch(); + // Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590. + _sendWield(item.ObjectId, (uint)mask); + return true; + } private void OnObjectMoved(ClientObjectMove move) { @@ -481,14 +454,6 @@ internal sealed class AutoWieldController : IDisposable return EquipMask.None; } - private static EquipMask FirstCompatibleEquipMask(ClientObject item) - { - foreach (EquipMask mask in AutoEquipOrder) - if ((item.ValidLocations & mask) != EquipMask.None) - return mask; - return EquipMask.None; - } - private bool AutoWearIsLegal( ClientObject item, out ClientObject? blocker) diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index d27c6ecb..5a327efb 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -43,7 +43,6 @@ public sealed class ItemInteractionController : IDisposable private readonly Action? _sendSplitToWorld; private readonly Action? _sendPutItemInContainer; private readonly Action? _sendSplitToContainer; - private readonly Action? _sendStackableMerge; private readonly Action? _sendGive; private readonly Action? _toast; private readonly Func _readyForInventoryRequest; @@ -75,7 +74,6 @@ public sealed class ItemInteractionController : IDisposable // dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell. private readonly Func, uint, bool>? _sendBuyAll; private readonly Func, bool>? _sendSell; - private readonly Func, bool>? _sendSalvage; private readonly RuntimeInteractionTransactionState _runtimeTransactions; private readonly InventoryTransactionState _transactions; @@ -120,9 +118,7 @@ public sealed class ItemInteractionController : IDisposable Func? sendBuy = null, Func, uint, bool>? sendBuyAll = null, Func, bool>? sendSell = null, - Action? interfaceText = null, - Action? sendStackableMerge = null, - Func, bool>? sendSalvage = null) + Action? interfaceText = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -134,7 +130,6 @@ public sealed class ItemInteractionController : IDisposable _sendSplitToWorld = sendSplitToWorld; _sendPutItemInContainer = sendPutItemInContainer; _sendSplitToContainer = sendSplitToContainer; - _sendStackableMerge = sendStackableMerge; _sendGive = sendGive; _nowMs = nowMs ?? (() => Environment.TickCount64); _toast = toast; @@ -157,7 +152,6 @@ public sealed class ItemInteractionController : IDisposable _sendBuy = sendBuy; _sendBuyAll = sendBuyAll; _sendSell = sendSell; - _sendSalvage = sendSalvage; _interactionState = interactionState ?? throw new ArgumentNullException(nameof(interactionState)); _runtimeTransactions = runtimeTransactions @@ -174,10 +168,10 @@ public sealed class ItemInteractionController : IDisposable _playerGuid, _sendWield, sendPutItemInContainer, + _toast, _systemMessage, combatState, - sendChangeCombatMode, - _transactions); + sendChangeCombatMode); _interactionState.Changed += OnInteractionModeChanged; _transactions.StateChanged += OnTransactionStateChanged; _transactions.RequestCompleted += OnInventoryRequestCompleted; @@ -188,12 +182,6 @@ public sealed class ItemInteractionController : IDisposable public event Action? StateChanged; - /// - /// Retail ItemHolder::AttemptMerge immediately selects the target - /// stack and publishes the toolbar merge-attempt notice after dispatch. - /// - public event Action? MergeAttempted; - /// /// Retail's two secure-trade open paths surface here for the trade UI: /// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player @@ -469,231 +457,6 @@ public sealed class ItemInteractionController : IDisposable public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending) => _transactions.TryGetPending(out pending); - /// - /// Retail ACCWeenieObject::UIAttemptSplitToContainer: split an - /// exact partial quantity into a container through the canonical - /// one-request inventory gate. The source remains in place until the - /// authoritative stack update and newly-created split object arrive. - /// - public bool TrySplitToContainer( - uint itemId, - uint containerId, - uint placement, - uint amount) - { - if (itemId == 0u - || containerId == 0u - || _sendSplitToContainer is null - || _objects.Get(itemId) is not { } item) - { - return false; - } - - uint fullStack = (uint)Math.Max(1, item.StackSize); - if (amount == 0u || amount >= fullStack) - return false; - - return TryDispatchInventoryRequest( - InventoryRequestKind.SplitToContainer, - itemId, - () => - { - _sendSplitToContainer(itemId, containerId, placement, amount); - return true; - }); - } - - /// - /// Plugin-facing form of retail's put/split-to-container attempts. It - /// borrows this controller's exact transaction gate and wire delegates; - /// plugins supply policy, never a second optimistic inventory model. - /// - public bool TryMoveItemForAutomation( - uint itemId, - uint containerId, - uint amount = 0u, - int placement = 0) - { - if (itemId == 0u - || containerId == 0u - || _sendPutItemInContainer is null - || _objects.Get(itemId) is not { } item - || !IsOwnedByPlayer(itemId) - || (containerId != _playerGuid() && !IsOwnedByPlayer(containerId))) - { - return false; - } - - uint fullStack = (uint)Math.Max(1, item.StackSize); - uint requested = amount == 0u ? fullStack : amount; - if (requested == 0u || requested > fullStack) - return false; - if (requested < fullStack) - { - return TrySplitToContainer( - itemId, - containerId, - (uint)Math.Max(0, placement), - requested); - } - - return TryDispatchInventoryRequest( - InventoryRequestKind.PutInContainer, - itemId, - () => - { - _sendPutItemInContainer(itemId, containerId, placement); - return true; - }); - } - - /// - /// Plugin-facing retail stack merge. The shared planner performs the same - /// WCID, maximum-size, staged-trade, and transfer-size checks as a drag. - /// - public bool TryMergeItemsForAutomation( - uint sourceItemId, - uint targetItemId, - uint amount = 0u) - { - if (_sendStackableMerge is null - || !IsOwnedByPlayer(sourceItemId) - || !IsOwnedByPlayer(targetItemId) - || _objects.Get(sourceItemId) is not { } source - || _objects.Get(targetItemId) is not { } target) - { - return false; - } - - int requested = amount > int.MaxValue ? int.MaxValue : (int)amount; - StackMergePlan? plan = StackMergePlanner.Plan( - ToStackMergeItem(source), - ToStackMergeItem(target), - CanMakeInventoryRequest, - requested); - if (plan is not { } merge) - return false; - - return TryDispatchInventoryRequest( - InventoryRequestKind.Merge, - sourceItemId, - () => - { - _sendStackableMerge( - merge.SourceObjectId, - merge.TargetObjectId, - merge.Amount); - MergeAttempted?.Invoke( - merge.SourceObjectId, - merge.TargetObjectId); - return true; - }); - } - - /// Plugin-facing retail full-stack drop or split-to-world. - public bool TryDropItemForAutomation(uint itemId, uint amount = 0u) - { - if (!IsOwnedByPlayer(itemId) || _objects.Get(itemId) is not { } item) - return false; - - uint fullStack = (uint)Math.Max(1, item.StackSize); - uint requested = amount == 0u ? fullStack : amount; - if (requested == 0u || requested > fullStack) - return false; - InventoryRequestKind kind = requested < fullStack - ? InventoryRequestKind.SplitToWorld - : InventoryRequestKind.DropToWorld; - return TryDispatchInventoryRequest( - kind, - itemId, - () => - { - if (requested < fullStack) - { - if (_sendSplitToWorld is null) - return false; - _sendSplitToWorld(itemId, requested); - } - else - { - if (_sendDrop is null) - return false; - _sendDrop(itemId); - } - return true; - }); - } - - /// Plugin-facing retail Give attempt with an exact stack amount. - public bool TryGiveItemForAutomation( - uint itemId, - uint targetId, - uint amount = 0u) - { - if (_sendGive is null - || targetId == 0u - || targetId == _playerGuid() - || _objects.Get(targetId) is null - || !IsOwnedByPlayer(itemId) - || _objects.Get(itemId) is not { } item) - { - return false; - } - - uint fullStack = (uint)Math.Max(1, item.StackSize); - uint requested = amount == 0u ? fullStack : amount; - if (requested == 0u || requested > fullStack) - return false; - return TryDispatchInventoryRequest( - InventoryRequestKind.Give, - itemId, - () => - { - _sendGive(targetId, itemId, requested); - return true; - }); - } - - /// - /// Plugin-facing form of gmSalvageUI::Salvage. Retail validates an owned - /// tinkering tool and a non-empty ordered list of suitable owned source - /// items, then sends 0x027D without entering the ordinary one-item move - /// transaction. The server owns the final material and option checks. - /// - public bool TrySalvageItemsForAutomation( - uint toolId, - IReadOnlyList itemIds) - { - if (_sendSalvage is null - || toolId == 0u - || itemIds is null - || itemIds.Count == 0 - || !CanMakeInventoryRequest - || !IsOwnedByPlayer(toolId) - || _objects.Get(toolId) is not { } tool - || (tool.Type & ItemType.TinkeringTool) == 0) - { - return false; - } - - var distinct = new HashSet(); - foreach (uint itemId in itemIds) - { - if (itemId == 0u - || itemId == toolId - || !distinct.Add(itemId) - || !IsOwnedByPlayer(itemId) - || _objects.Get(itemId) is not { } item - || item.MaterialType is null or 0u - || item.Structure >= 100 - || ((item.PublicWeenieBitfield ?? 0u) & 0xFF000000u) != 0u) - { - return false; - } - } - return _sendSalvage(toolId, itemIds); - } - /// /// Increments retail's shared ClientUISystem busy reference after a /// request issued by another retained controller has been sent. The @@ -723,29 +486,6 @@ public sealed class ItemInteractionController : IDisposable public bool IsPendingSource(uint itemGuid) => itemGuid != 0 && itemGuid == PendingSourceItem; - /// - /// True while retail's global inventory request latch owns this physical - /// item. Retained item lists use it for the waiting/ghosted source visual; - /// canonical placement remains unchanged until the server response. - /// - public bool IsPendingInventorySource(uint itemGuid) - => itemGuid != 0 - && _transactions.TryGetPending(out PendingInventoryRequest pending) - && pending.ItemId == itemGuid; - - /// Route a literal local refusal to retail's SpewBox channel. - public void ReportClientLocal(string message) - { - if (string.IsNullOrWhiteSpace(message)) - return; - if (_interfaceText is not null) - _interfaceText(message, RetailLogTextType.ClientLocal); - else if (_systemMessage is not null) - _systemMessage(message); - else - _toast?.Invoke(message); - } - /// /// Retail ACCWeenieObject::IsOwnedByPlayer projection shared with /// toolbar shortcut creation. @@ -785,6 +525,11 @@ public sealed class ItemInteractionController : IDisposable switch (mode) { case InteractionModeKind.Use: + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] route=target-mode-click OfferPrimaryClick(Use) guid=0x{targetGuid:X8}"); + } ClearTargetMode(); accepted = ActivateItem(targetGuid); break; @@ -817,6 +562,11 @@ public sealed class ItemInteractionController : IDisposable /// public bool UseSelectedOrEnterMode(uint selectedObjectId) { + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] route=toolbar-or-queued-use UseSelectedOrEnterMode guid=0x{selectedObjectId:X8}"); + } if (selectedObjectId != 0) return ActivateItem(selectedObjectId); return _interactionState.EnterUse(); @@ -849,21 +599,6 @@ public sealed class ItemInteractionController : IDisposable _runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine); } - /// - /// Plugin/automation appraisal through the one retail appraisal owner. - /// It does not mutate selection or open/raise the examination window. - /// - public bool TryAppraiseForAutomation(uint objectId) - { - if (objectId == 0u - || _sendExamine is null - || _objects.Get(objectId) is null) - { - return false; - } - return _runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine); - } - /// /// Accepts only the pending or current appraisal, matching /// gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0. @@ -931,6 +666,11 @@ public sealed class ItemInteractionController : IDisposable public bool ActivateItem(uint itemGuid) { + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] entry:ActivateItem guid=0x{itemGuid:X8} isTargetModeActive={IsTargetModeActive}"); + } if (itemGuid == 0) return false; if (IsTargetModeActive) @@ -959,121 +699,20 @@ public sealed class ItemInteractionController : IDisposable return ExecuteUseActions(decision.Actions); } - /// - /// Plugin/automation entry for an ordinary item request. Unlike interactive - /// activation, it never turns a use request into wielding, sorting, or a - /// modal target cursor, and returns true only when a wire Use was issued. - /// - public bool TryUseItemForAutomation(uint itemGuid) - { - if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item) - return false; - if (ItemUseability.IsTargeted(item.Useability ?? ItemUseability.Undef)) - return false; - if (!ConsumeUseThrottle()) - return false; - if (!EnsureInventoryRequestReady()) - return false; - - var input = new ItemUsePolicyInput( - Snapshot(item), - _playerGuid(), - _groundObjectId(), - CanMakeInventoryRequest, - _activeVendorId(), - BypassClassification: true, - UseCurrentSelection: false, - SelectedTarget: null, - ConfirmVolatileRareUses: true, - InNonCombatMode: _inNonCombatMode()); - ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input); - bool sends = decision.Actions.Any(static action => - action.Kind == ItemPolicyActionKind.SendUse); - return sends && ExecuteUseActions(decision.Actions); - } - - /// - /// Plugin/automation entry for a targeted item action. It follows the same - /// retail compatibility, throttle, busy-reference and UseDone ownership as - /// choosing a target through the interactive target cursor, without - /// installing a modal cursor state that automation cannot safely own. - /// - public bool TryApplyItem(uint itemGuid, uint targetGuid) - { - if (itemGuid == 0u || targetGuid == 0u) - return false; - if (_objects.Get(itemGuid) is not { } item - || _objects.Get(targetGuid) is not { } target) - { - return false; - } - if (!ConsumeUseThrottle()) - return true; - if (!EnsureInventoryRequestReady()) - return false; - - var input = new ItemUsePolicyInput( - Snapshot(item), - _playerGuid(), - _groundObjectId(), - CanMakeInventoryRequest, - _activeVendorId(), - BypassClassification: true, - UseCurrentSelection: true, - SelectedTarget: Snapshot(target), - ConfirmVolatileRareUses: true, - InNonCombatMode: _inNonCombatMode()); - ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input); - bool sends = decision.Actions.Any(static action => - action.Kind == ItemPolicyActionKind.SendUseWithTarget); - return sends && ExecuteUseActions(decision.Actions); - } - /// /// Retail keyboard pickup entry point. CPlayerSystem::PlaceInBackpack /// publishes the waiting destination slot before issuing the move request, /// exactly like double-click pickup through ItemHolder. /// - public bool PlaceWorldItemInBackpack(uint itemGuid, bool mainPack = false) + public bool PlaceWorldItemInBackpack(uint itemGuid) { if (itemGuid == 0u || _placeInBackpack is null) return false; - uint containerId = mainPack ? _playerGuid() : _backpackContainerId(); + uint containerId = _backpackContainerId(); if (containerId == 0u) containerId = _playerGuid(); const int placement = 0; - - // CPlayerSystem::PlaceInBackpack passes autoMerge=true to - // ItemHolder::AttemptToPlaceInContainer. Retail searches the player's - // exhaustive carried inventory first and only merges when one target - // can accept the complete selected split quantity. - if (TryPlanAutoMerge(itemGuid) is { } merge) - { - if (!TryDispatchPendingBackpackPlacement( - itemGuid, - containerId, - placement, - InventoryRequestKind.Merge, - () => - { - _sendStackableMerge!( - merge.SourceObjectId, - merge.TargetObjectId, - merge.Amount); - MergeAttempted?.Invoke( - merge.SourceObjectId, - merge.TargetObjectId); - return true; - })) - { - // As with ordinary pickup, retail consumes the key while the - // shared inventory-request gate is busy. - return true; - } - return true; - } - if (!TryBeginPendingBackpackPlacement( itemGuid, containerId, @@ -1092,67 +731,6 @@ public sealed class ItemInteractionController : IDisposable return true; } - private StackMergePlan? TryPlanAutoMerge(uint sourceId) - { - if (_sendStackableMerge is null - || _objects.Get(sourceId) is not { } source - || source.StackSizeMax <= 1) - { - return null; - } - - uint requested = _stackSplitQuantity?.GetObjectSplitSize( - sourceId, - _selectedObjectId(), - (uint)Math.Max(1, source.StackSize)) - ?? (uint)Math.Max(1, source.StackSize); - int requestedAmount = (int)Math.Min(requested, int.MaxValue); - var sourceMerge = ToStackMergeItem(source); - uint player = _playerGuid(); - if (player == 0u) - return null; - - var visitedContainers = new HashSet(); - foreach (uint targetId in ExhaustiveContents(player, visitedContainers)) - { - if (_objects.Get(targetId) is not { } target) - continue; - StackMergePlan? plan = StackMergePlanner.Plan( - sourceMerge, - ToStackMergeItem(target), - CanMakeInventoryRequest, - requestedAmount); - // AttemptAutoMerge rejects a partial fit and keeps searching. - if (plan is { } complete && complete.Amount == requested) - return complete; - } - return null; - } - - private IEnumerable ExhaustiveContents( - uint containerId, - HashSet visitedContainers) - { - if (!visitedContainers.Add(containerId)) - yield break; - - foreach (uint itemId in _objects.GetContents(containerId)) - { - yield return itemId; - if (_objects.GetContents(itemId).Count == 0) - continue; - foreach (uint nested in ExhaustiveContents(itemId, visitedContainers)) - yield return nested; - } - } - - private static StackMergeItem ToStackMergeItem(ClientObject item) => new( - item.ObjectId, - item.WeenieClassId, - item.StackSize, - item.StackSizeMax, - item.TradeState); - public bool TryBeginPendingBackpackPlacement( uint itemGuid, uint containerId, @@ -1374,24 +952,6 @@ public sealed class ItemInteractionController : IDisposable return _autoWield.TryWield(item, targetMask); } - /// - /// Plugin/automation entry into the exact same AutoWield transaction used - /// by inventory activation and paperdoll drops. - /// - public bool TryWieldItem(uint itemGuid, EquipMask requestedMask = EquipMask.None) - { - if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item) - return false; - if (!EnsureInventoryRequestReady()) - return false; - return requestedMask == EquipMask.None - ? _autoWield.TryWield(item) - : _autoWield.TryWield(item, requestedMask); - } - - public bool IsAutoWieldBusy => - _autoWield.IsBusy || !_transactions.CanBeginRequest; - /// User combat-mode input supersedes AutoWield's retained mode. public void NotifyExplicitCombatModeRequest() => _autoWield.NotifyExplicitCombatModeRequest(); @@ -1437,6 +997,11 @@ public sealed class ItemInteractionController : IDisposable /// public bool ExecuteConfirmedUse(uint objectId) { + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] route=confirmed-use ExecuteConfirmedUse guid=0x{objectId:X8}"); + } if (objectId == 0u || (_requestUse is null && _sendUse is null)) return false; if (!EnsureInventoryRequestReady()) @@ -1498,14 +1063,6 @@ public sealed class ItemInteractionController : IDisposable public bool DropToWorld(ItemDragPayload payload) => PlaceIn3D(payload, targetGuid: 0u); - /// - /// Keyboard equivalent of dropping the selected inventory item into the - /// 3-D view. Retail routes Give Selected and Drop Selected through the - /// same ItemHolder::AttemptPlaceIn3D @ 0x00588600 policy as a drag. - /// - public bool PlaceSelectedIn3D(uint itemGuid, uint targetGuid) - => PlaceIn3D(itemGuid, ItemDragSource.Inventory, targetGuid); - /// /// Retail inventory drag released into SmartBox. The release target is the /// world object under the cursor, or zero for empty ground. This is the live @@ -1515,17 +1072,9 @@ public sealed class ItemInteractionController : IDisposable { ArgumentNullException.ThrowIfNull(payload); - return PlaceIn3D(payload.ObjId, payload.SourceKind, targetGuid); - } - - private bool PlaceIn3D( - uint itemGuid, - ItemDragSource sourceKind, - uint targetGuid) - { - if (sourceKind == ItemDragSource.ShortcutBar) + if (payload.SourceKind == ItemDragSource.ShortcutBar) return false; - if (itemGuid == 0 || _objects.Get(itemGuid) is not { } item) + if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item) return false; if (!EnsureInventoryRequestReady()) return false; @@ -1625,7 +1174,7 @@ public sealed class ItemInteractionController : IDisposable break; case ItemPolicyActionKind.Reject: if (!string.IsNullOrWhiteSpace(action.Message)) - ReportClientLocal(action.Message); + _toast?.Invoke(action.Message); break; case ItemPolicyActionKind.OpenSecureTrade: // Use-on-player (ItemHolder::DetermineUseResult @@ -1640,9 +1189,8 @@ public sealed class ItemInteractionController : IDisposable PolicyActionRequested?.Invoke(action); bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null; if (!handled) - ReportClientLocal(PolicyActionMessage(action)); - acted |= handled || _interfaceText is not null - || _systemMessage is not null || _toast is not null; + _toast?.Invoke(PolicyActionMessage(action)); + acted |= handled || _toast is not null; break; } } @@ -1671,8 +1219,14 @@ public sealed class ItemInteractionController : IDisposable action.ObjectId, () => { - if (_sendDrop is null) + if (_sendDrop is null + || !_objects.MoveItemOptimistic( + action.ObjectId, + newContainerId: 0u, + newSlot: -1)) + { return false; + } _sendDrop(action.ObjectId); return true; }); @@ -1756,13 +1310,13 @@ public sealed class ItemInteractionController : IDisposable } case ItemPolicyActionKind.Reject: if (!string.IsNullOrWhiteSpace(action.Message)) - ReportClientLocal(action.Message); + _toast?.Invoke(action.Message); break; default: _auxiliaryAction?.Invoke(action); PolicyActionRequested?.Invoke(action); if (_auxiliaryAction is null && PolicyActionRequested is null) - ReportClientLocal(PolicyActionMessage(action)); + _toast?.Invoke(PolicyActionMessage(action)); break; } } @@ -1774,7 +1328,7 @@ public sealed class ItemInteractionController : IDisposable _interactionState.EnterUseItemOnTarget(sourceGuid); var name = _objects.Get(sourceGuid)?.Name; if (!string.IsNullOrWhiteSpace(name)) - ReportClientLocal($"Choose a target for the {name}"); + _toast?.Invoke($"Choose a target for the {name}"); } private void ClearTargetMode() @@ -1853,6 +1407,8 @@ public sealed class ItemInteractionController : IDisposable PendingInventoryRequest request, uint weenieError) { + if (_interfaceText is null) + return; ClientObject? item = request.ItemIdentity ?? _objects.Get(request.ItemId); if (item is null) return; @@ -1871,7 +1427,7 @@ public sealed class ItemInteractionController : IDisposable if (InventoryFailureMessages.Compose(request.Kind, name, weenieError) is { } text) { - ReportClientLocal(text); + _interfaceText(text, RetailLogTextType.ClientLocal); } } @@ -1907,7 +1463,6 @@ public sealed class ItemInteractionController : IDisposable _transactions.RequestCompleted -= OnInventoryRequestCompleted; _transactions.StateChanged -= OnTransactionStateChanged; WorldDropDispatched = null; - MergeAttempted = null; _autoWield.Dispose(); } @@ -2040,8 +1595,7 @@ public sealed class ItemInteractionController : IDisposable stackSize, stackSize, IsIn3DView: item.ContainerId == 0 && item.WielderId == 0 - && item.ObjectId != _playerGuid(), - Name: item.GetAppropriateName()); + && item.ObjectId != _playerGuid()); } /// diff --git a/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs b/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs deleted file mode 100644 index ad3c2c18..00000000 --- a/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs +++ /dev/null @@ -1,454 +0,0 @@ -namespace AcDream.App.UI.Layout; - -/// -/// Campaign AS slice AS5 (2026-08-25, narrows register row AP-109 — its FormatXp sliver survives): the -/// retail allegiance rank-title table and its name-composition wrapper. -/// -/// Dispatch — AllegianceSystem::GetTitle @0x005B8DD0. -/// Retail tests gender first (arg3: 1 = male, 2 = female — anything -/// else falls through to "no title"), then an UNSIGNED heritage-group range -/// check (arg2 - 1) <= 0xa (heritage ids 1..11; this is what -/// excludes Olthoi/OlthoiAcid, ids 12/13, from ever getting a title), then -/// switches on heritage to one of 17 Get*Title functions. The dispatch -/// was READ DIRECTLY from the decomp (not estimated) for AP-109's -/// 2026-08-25 correction: Gearknight (heritage 6) and Tumerok (heritage 7) -/// author only a MALE function, reused verbatim for the FEMALE dispatch -/// branch too; Lugian (heritage 8) authors only a FEMALE function, reused -/// for the MALE branch; heritage id 0xA (Penumbraen) aliases to the -/// Shadowbound (heritage 5) functions on BOTH gender branches — 11 -/// heritages, 17 functions, not 22. -/// -/// Per-function bounds — every one of the 17 functions. Each -/// does its OWN unsigned bounds check, identical across all 17: -/// (arg1 - 1) > 9 → return 0 (no title). So a valid rank is -/// exactly 1..10 inclusive; rank 0 (unsigned wraps to a huge value) and any -/// rank > 10 both resolve to "no title" — there is no separate -/// "too high" clamp to the rank-10 title, contrary to what a naive port -/// might assume. -/// -/// Composition — AllegianceData::GetFullName @0x005B6950. -/// title = GetTitle(this->_rank, this->_hg, this->_gender); -/// when GetTitle returns 0 (no title resolved), the output is the -/// plain name, unmodified. When it resolves, the output is -/// title + " " + name — a single ASCII space, PE-byte-recovered at -/// data_794098 (bytes 20 00 00 00), the SAME separator -/// 's class remarks already cite for this -/// exact literal. Two call sites verified independently against the -/// decomp, both confirming (0x1E) / -/// (0xBC) / -/// (0x71) as the three -/// inputs: -/// -/// CharExamineUI::SetAppraiseInfo @0x004B45F0 (examination -/// title bar, `@0x004b4c8c`-`@0x004b4cec`): reads -/// AppraisalProfile::InqInt(arg2, 0x1e, ...) for rank immediately -/// before the GetFullName call; heritage/gender were read earlier in -/// the SAME function (InqInt(arg2, 0x71, ...) / -/// InqInt(arg2, 0xbc, ...)) for the heritage-line composition. The -/// local AllegianceData object (BN name var_a8, constructed via -/// CAllegianceData::CAllegianceData(&var_a8) and destructed via -/// AllegianceData::~AllegianceData(&var_a8) — the ctor/dtor pair -/// is the only proof BN's flattened locals belong to one struct) never shows -/// an explicit field WRITE for _rank/_hg/_gender in the -/// decompile — a known Binary Ninja struct-flattening artifact -/// (feedback_bn_decomp_field_names.md): the InqInt OUTPUT -/// pointers target those stack fields directly, and BN labels the pointed-to -/// slot with a synthetic local name instead of recognizing it as a struct -/// member. The name half of the struct IS visible as a normal assignment: -/// var_a8 = ACCWeenieObject::GetObjectName(cur_weenobj, NAME_APPROPRIATE, -/// 0) — the assessed object's own live "appropriate name", same -/// resolver -/// already serves AppraisalUiController.BuildTitle's fallback. The -/// result overwrites the window's m_displayedNameText -/// UNCONDITIONALLY (only a null-widget guard, no rank gate at the call -/// site — the "plain name when rankless" behavior lives entirely inside -/// GetFullName/GetTitle). -/// gmStatManagementUI::UpdateCharacterInfo @0x004F0770 -/// (character panel name line, `@0x004f0807`-`@0x004f0895`): this one shows -/// the three CBaseQualities::InqInt reads (0x71, 0xbc, 0x1e) as -/// plain, unambiguous locals with no flattening ambiguity — direct -/// confirmation of the same three property ids, this time straight off the -/// CACQualities qualities bundle rather than an -/// AppraisalProfile. The name appended is the LOCAL PLAYER's own -/// singular name (ACCWeenieObject::GetObjectName(SmartBox::player_id, -/// NAME_SINGULAR, 0)) — this window always shows your own sheet, never -/// another player's. -/// -/// -/// -internal static class AllegianceRankTitleTable -{ - /// PropertyInt 0x1E = AllegianceRank — the value - /// GetTitle's arg1 reads, per both verified call sites - /// above. Ruling R8 (AS1 ground truth): read this LIVE off the - /// appraisal/qualities bundle, never RuntimeAllegianceState (that - /// state exists for a DIFFERENT UI, the local player's own allegiance - /// page, and has no bearing on another player's rank). - public const uint AllegianceRankPropertyId = 0x1Eu; - - /// - /// Port of AllegianceData::GetFullName @0x005B6950: prefixes - /// with the resolved rank title + a single space - /// when resolves one; returns - /// unmodified otherwise (retail's own - /// if (GetTitle(...) == 0) { *out = name; return 0; } branch). - /// - public static string ComposeFullName(int rank, int heritageGroup, int gender, string name) - { - string? title = GetTitle(rank, heritageGroup, gender); - return string.IsNullOrEmpty(title) ? name : $"{title} {name}"; - } - - /// - /// Port of AllegianceSystem::GetTitle @0x005B8DD0's dispatch. - /// Returns null where retail returns 0 (no title: unrecognized gender, - /// heritage outside 1..11, or the resolved Get*Title function - /// itself rejects the rank). - /// - public static string? GetTitle(int rank, int heritageGroup, int gender) - { - if (gender == 1) - { - if (!IsHeritageInRange(heritageGroup)) return null; - return heritageGroup switch - { - 1 => GetAluvianMaleTitle(rank), - 2 => GetGharundimMaleTitle(rank), - 3 => GetShoMaleTitle(rank), - 4 => GetViamontianMaleTitle(rank), - 5 or 0xA => GetShadowboundMaleTitle(rank), // 0xA = Penumbraen alias - 6 => GetGearknightMaleTitle(rank), - 7 => GetTumerokMaleTitle(rank), - 8 => GetLugianFemaleTitle(rank), // Lugian authors FEMALE only; reused here - 9 => GetEmpyreanMaleTitle(rank), - 0xB => GetUndeadMaleTitle(rank), - _ => null, - }; - } - - if (gender == 2) - { - if (!IsHeritageInRange(heritageGroup)) return null; - return heritageGroup switch - { - 1 => GetAluvianFemaleTitle(rank), - 2 => GetGharundimFemaleTitle(rank), - 3 => GetShoFemaleTitle(rank), - 4 => GetViamontianFemaleTitle(rank), - 5 or 0xA => GetShadowboundFemaleTitle(rank), // 0xA = Penumbraen alias - 6 => GetGearknightMaleTitle(rank), // Gearknight authors MALE only; reused here - 7 => GetTumerokMaleTitle(rank), // Tumerok authors MALE only; reused here - 8 => GetLugianFemaleTitle(rank), - 9 => GetEmpyreanFemaleTitle(rank), - 0xB => GetUndeadFemaleTitle(rank), - _ => null, - }; - } - - return null; - } - - /// Retail's unsigned (heritage - 1) <= 0xa range test - /// — heritage ids 1..11 (0xA = Penumbraen, 0xB = Undead); excludes 0, - /// negative, and Olthoi/OlthoiAcid (12/13). - private static bool IsHeritageInRange(int heritageGroup) - => unchecked((uint)(heritageGroup - 1)) <= 0xAu; - - // ── The 17 Get*Title functions, verbatim from the decomp. ────────────── - // Every function shares the identical bounds test `(rank - 1) > 9` -> - // return 0 (ported here as the switch's default arm returning null, - // since rank values outside 1..10 have no case in any of the 17 retail - // switches either). - - /// AllegianceSystem::GetAluvianMaleTitle @0x005B7BC0 - private static string? GetAluvianMaleTitle(int rank) => rank switch - { - 1 => "Yeoman", - 2 => "Baronet", - 3 => "Baron", - 4 => "Reeve", - 5 => "Thane", - 6 => "Ealdor", - 7 => "Duke", - 8 => "Aetheling", - 9 => "King", - 10 => "High King", - _ => null, - }; - - /// AllegianceSystem::GetAluvianFemaleTitle @0x005B7CD0 - private static string? GetAluvianFemaleTitle(int rank) => rank switch - { - 1 => "Yeoman", - 2 => "Baronet", - 3 => "Baroness", - 4 => "Reeve", - 5 => "Thane", - 6 => "Ealdor", - 7 => "Duchess", - 8 => "Aetheling", - 9 => "Queen", - 10 => "High Queen", - _ => null, - }; - - /// AllegianceSystem::GetGharundimMaleTitle @0x005B7DE0 - private static string? GetGharundimMaleTitle(int rank) => rank switch - { - 1 => "Sayyid", - 2 => "Shayk", - 3 => "Maulan", - 4 => "Mu'allim", - 5 => "Naquib", - 6 => "Qadi", - 7 => "Mushir", - 8 => "Amir", - 9 => "Malik", - 10 => "Sultan", - _ => null, - }; - - /// AllegianceSystem::GetGharundimFemaleTitle @0x005B7EF0 - private static string? GetGharundimFemaleTitle(int rank) => rank switch - { - 1 => "Sayyida", - 2 => "Shayka", - 3 => "Maulana", - 4 => "Mu'allima", - 5 => "Naquiba", - 6 => "Qadiya", - 7 => "Mushira", - 8 => "Amira", - 9 => "Malika", - 10 => "Sultana", - _ => null, - }; - - /// AllegianceSystem::GetShoMaleTitle @0x005B8000. Ranks 7 and 9 - /// resolve through data-literal indirections (&data_7e6ef8 / - /// &data_7e6eec) rather than inline C string literals — PE - /// byte-read as "Kou" (4b 6f 75 00) and "Ou" (4f 75 00 00) - /// respectively, matching published AC Sho lore exactly. - private static string? GetShoMaleTitle(int rank) => rank switch - { - 1 => "Jinin", - 2 => "Jo-chueh", - 3 => "Nan-chueh", - 4 => "Shi-chueh", - 5 => "Ta-chueh", - 6 => "Kun-chueh", - 7 => "Kou", - 8 => "Taikou", - 9 => "Ou", - 10 => "Koutei", - _ => null, - }; - - /// AllegianceSystem::GetShoFemaleTitle @0x005B8110. Rank 7 shares - /// the same &data_7e6ef8 ("Kou") indirection as the male - /// table; rank 9 is a plain inline literal here ("Jo-ou"), unlike the - /// male table's rank 9. - private static string? GetShoFemaleTitle(int rank) => rank switch - { - 1 => "Jinin", - 2 => "Jo-chueh", - 3 => "Nan-chueh", - 4 => "Shi-chueh", - 5 => "Ta-chueh", - 6 => "Kun-chueh", - 7 => "Kou", - 8 => "Taikou", - 9 => "Jo-ou", - 10 => "Koutei", - _ => null, - }; - - /// AllegianceSystem::GetViamontianMaleTitle @0x005B8220 - private static string? GetViamontianMaleTitle(int rank) => rank switch - { - 1 => "Squire", - 2 => "Banner", - 3 => "Baron", - 4 => "Viscount", - 5 => "Count", - 6 => "Marquis", - 7 => "Duke", - 8 => "Grand Duke", - 9 => "King", - 10 => "High King", - _ => null, - }; - - /// AllegianceSystem::GetViamontianFemaleTitle @0x005B8330 - private static string? GetViamontianFemaleTitle(int rank) => rank switch - { - 1 => "Dame", - 2 => "Banner", - 3 => "Baroness", - 4 => "Viscountess", - 5 => "Countess", - 6 => "Marquise", - 7 => "Duchess", - 8 => "Grand Duchess", - 9 => "Queen", - 10 => "High Queen", - _ => null, - }; - - /// AllegianceSystem::GetShadowboundMaleTitle @0x005B8440. Serves - /// heritage ids 5 (Shadowbound) AND 0xA (Penumbraen alias). - private static string? GetShadowboundMaleTitle(int rank) => rank switch - { - 1 => "Tenebrous", - 2 => "Shade", - 3 => "Squire", - 4 => "Knight", - 5 => "Void Knight", - 6 => "Void Lord", - 7 => "Duke", - 8 => "Archduke", - 9 => "Highborn", - 10 => "King", - _ => null, - }; - - /// AllegianceSystem::GetShadowboundFemaleTitle @0x005B8550. Serves - /// heritage ids 5 (Shadowbound) AND 0xA (Penumbraen alias). - private static string? GetShadowboundFemaleTitle(int rank) => rank switch - { - 1 => "Tenebrous", - 2 => "Shade", - 3 => "Squire", - 4 => "Knight", - 5 => "Void Knight", - 6 => "Void Lady", - 7 => "Duchess", - 8 => "Archduchess", - 9 => "Highborn", - 10 => "Queen", - _ => null, - }; - - /// AllegianceSystem::GetGearknightMaleTitle @0x005B8660. Gearknight - /// authors ONLY this function — dispatches BOTH - /// gender branches for heritage 6 here. Rank 8 resolves through a - /// data-literal indirection (&data_7e7034), PE byte-read as - /// "Dux" (44 75 78 00). - private static string? GetGearknightMaleTitle(int rank) => rank switch - { - 1 => "Tribunus", - 2 => "Praefectus", - 3 => "Optio", - 4 => "Centurion", - 5 => "Principes", - 6 => "Legatus", - 7 => "Consul", - 8 => "Dux", - 9 => "Secondus", - 10 => "Primus", - _ => null, - }; - - /// AllegianceSystem::GetTumerokMaleTitle @0x005B8770. Tumerok - /// authors ONLY this function — dispatches BOTH - /// gender branches for heritage 7 here. Ranks 3/6/10 resolve through - /// data-literal indirections, PE byte-read as "Ona" (4f 6e 61 00), - /// "Rea" (52 65 61 00), and "Tah" (54 61 68 00) - /// respectively. - private static string? GetTumerokMaleTitle(int rank) => rank switch - { - 1 => "Xutua", - 2 => "Tuona", - 3 => "Ona", - 4 => "Nuona", - 5 => "Turea", - 6 => "Rea", - 7 => "Nurea", - 8 => "Kauh", - 9 => "Sutah", - 10 => "Tah", - _ => null, - }; - - /// AllegianceSystem::GetLugianFemaleTitle @0x005B8880. Lugian - /// authors ONLY this function — dispatches BOTH - /// gender branches for heritage 8 here. - private static string? GetLugianFemaleTitle(int rank) => rank switch - { - 1 => "Laigus", - 2 => "Raigus", - 3 => "Amploth", - 4 => "Arintoth", - 5 => "Obeloth", - 6 => "Lithos", - 7 => "Kantos", - 8 => "Gigas", - 9 => "Extas", - 10 => "Tiatus", - _ => null, - }; - - /// AllegianceSystem::GetEmpyreanMaleTitle @0x005B8990 - private static string? GetEmpyreanMaleTitle(int rank) => rank switch - { - 1 => "Ensign", - 2 => "Corporal", - 3 => "Lieutenant", - 4 => "Commander", - 5 => "Captain", - 6 => "Commodore", - 7 => "Admiral", - 8 => "Warlord", - 9 => "Ipharsin", - 10 => "Aulin", - _ => null, - }; - - /// AllegianceSystem::GetEmpyreanFemaleTitle @0x005B8AA0 - private static string? GetEmpyreanFemaleTitle(int rank) => rank switch - { - 1 => "Ensign", - 2 => "Corporal", - 3 => "Lieutenant", - 4 => "Commander", - 5 => "Captain", - 6 => "Commodore", - 7 => "Admiral", - 8 => "Warlord", - 9 => "Ipharsia", - 10 => "Aulia", - _ => null, - }; - - /// AllegianceSystem::GetUndeadMaleTitle @0x005B8BB0 - private static string? GetUndeadMaleTitle(int rank) => rank switch - { - 1 => "Neophyte", - 2 => "Acolyte", - 3 => "Adept", - 4 => "Esquire", - 5 => "Squire", - 6 => "Knight", - 7 => "Count", - 8 => "Viscount", - 9 => "Highness", - 10 => "Annointed", - _ => null, - }; - - /// AllegianceSystem::GetUndeadFemaleTitle @0x005B8CC0 - private static string? GetUndeadFemaleTitle(int rank) => rank switch - { - 1 => "Neophyte", - 2 => "Acolyte", - 3 => "Adept", - 4 => "Esquire", - 5 => "Squire", - 6 => "Knight", - 7 => "Countess", - 8 => "Viscountess", - 9 => "Highness", - 10 => "Annointed", - _ => null, - }; -} diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index c3da98be..b5b9a109 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -43,12 +43,6 @@ public sealed class AppraisalUiController : IRetainedPanelController public const uint SpellFormulaListId = 0x1000032Du; private const uint TemplateStringProperty = 5u; - /// - /// PropertyInt 0x105 (261 decimal) — CharacterTitleId. Doubles as the - /// Character-subview dispatch marker () and, as - /// of AS2, the source id for 's - /// current-display-title line (element 0x10000151). - /// private const uint CharacterMarkerIntProperty = 0x105u; private const uint DisplayedNameStringProperty = 0x34u; private const double CreatureRefreshSeconds = 0.75; @@ -83,26 +77,6 @@ public sealed class AppraisalUiController : IRetainedPanelController private readonly Func _resolveComponentIcon; private readonly Func> _spellComponents; private readonly Func _magicSkill; - /// - /// AS2: element 0x10000151's current-display-title chain - /// (CharacterTitleResolver.Resolve under the composition layer's - /// DatLock — resolved PER CALL, never captured once at mount time, per - /// the secure-trade deferred-Func lesson). Defaults to "never resolves" - /// so a caller that supplies nothing simply falls back to String 5 - /// Template every time, matching retail's own fallback branch. - /// - private readonly Func _resolveCharacterTitle; - /// - /// AS4 (gap G6): the LOCAL player's own Faction1Bits (PropertyInt 281), - /// resolved PER CALL under the same deferred-Func discipline as - /// — never captured once at mount - /// time. Feeds 's Society - /// row color rule (ground truth §2b row 1). Defaults to 0 (factionless) - /// so a caller that supplies nothing renders the Society row exactly as - /// if the local player belonged to no society, matching retail's own - /// "local has no bits" branch. - /// - private readonly Func _localFactionBits; private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates; private readonly UiText _spellSchool; private readonly UiText _spellMana; @@ -158,9 +132,7 @@ public sealed class AppraisalUiController : IRetainedPanelController Func? resolveComponentIcon, Func>? spellComponents, Func? magicSkill, - SpellExamineComponentTemplateFactory? spellComponentTemplates, - Func? resolveCharacterTitle, - Func? localFactionBits) + SpellExamineComponentTemplateFactory? spellComponentTemplates) { _layout = layout; _objects = objects; @@ -187,8 +159,6 @@ public sealed class AppraisalUiController : IRetainedPanelController _resolveComponentIcon = resolveComponentIcon ?? (_ => 0u); _spellComponents = spellComponents ?? (_ => []); _magicSkill = magicSkill ?? (_ => 0u); - _resolveCharacterTitle = resolveCharacterTitle ?? (_ => null); - _localFactionBits = localFactionBits ?? (() => 0); _spellComponentTemplates = spellComponentTemplates; _spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!; _spellMana = (UiText)layout.FindElement(SpellManaTextId)!; @@ -317,9 +287,7 @@ public sealed class AppraisalUiController : IRetainedPanelController Func? resolveComponentIcon = null, Func>? spellComponents = null, Func? magicSkill = null, - SpellExamineComponentTemplateFactory? spellComponentTemplates = null, - Func? resolveCharacterTitle = null, - Func? localFactionBits = null) + SpellExamineComponentTemplateFactory? spellComponentTemplates = null) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(objects); @@ -371,9 +339,7 @@ public sealed class AppraisalUiController : IRetainedPanelController resolveComponentIcon, spellComponents, magicSkill, - spellComponentTemplates, - resolveCharacterTitle, - localFactionBits); + spellComponentTemplates); } /// @@ -721,11 +687,9 @@ public sealed class AppraisalUiController : IRetainedPanelController if (character) { - SetText(0x10000150u, BuildCharacterHeritageDisplay(p)); - SetText(0x10000151u, BuildCharacterTitleDisplay(p)); - SetText(0x10000152u, BuildPlayerKillerDisplay(obj)); - SetText(0x1000053Au, BuildAllegianceDisplay(p)); - _titleValue = BuildCharacterTitleBarName(obj, p); + SetText(0x10000150u, GetString(p, 4u)); + SetText(0x10000151u, GetString(p, 47u)); + SetText(0x10000152u, GetString(p, 11u)); } else { @@ -734,147 +698,16 @@ public sealed class AppraisalUiController : IRetainedPanelController _creatureNames.Resolve(GetInt(p, 2u))); } - RebuildCreatureStats(appraisal, character); + RebuildCreatureStats(appraisal); + SetText(0x1000053Au, appraisal.Success + ? string.Empty + : "Assessment incomplete"); if (newlySelected) ResetCreatureScroll(); } - /// - /// Composes examination element 0x10000150 (Heritage) for a - /// player target. Ground truth: docs/research/2026-08-25-campaign-as- - /// ground-truth.md §2a. Gap G1. - /// - private string BuildCharacterHeritageDisplay(PropertyBundle p) - { - int gender = GetInt(p, CharacterIdentityText.GenderPropertyId); - int heritageGroup = GetInt(p, CharacterIdentityText.HeritageGroupPropertyId); - string creatureFallback = heritageGroup == 0 - ? _creatureNames.Resolve(GetInt(p, 2u)) - : string.Empty; - return CharacterIdentityText.GenderHeritageDisplay( - gender, heritageGroup, creatureFallback); - } - - /// - /// Composes examination element 0x10000151 (Profession/title) for - /// a player target: the current display title from Int 261 - /// CharacterTitleId (, resolved - /// through CharacterTitleResolver's CharacterTitleTable:: - /// GetCharacterTitleFromID @0x005C6ED0 chain), falling back to - /// String 5 Template verbatim when the id is absent or unresolvable. - /// Ground truth §2a, gap G2. - /// (0x105 == 261 decimal) is the SAME property - /// already keys the Character-subview dispatch on. - /// Deliberate divergence (register row AD-115): when neither - /// source resolves, has already blanked - /// this element and this method returns empty, so it stays cleared. - /// Retail never clears 0x10000151 in this situation — it leaves - /// the PREVIOUS target's stale title on screen. Do not "fix" this back - /// toward retail's stale-text behavior without revisiting AD-115. - /// - private string BuildCharacterTitleDisplay(PropertyBundle p) - { - if (p.Ints.TryGetValue(CharacterMarkerIntProperty, out int titleId) - && titleId != 0 - && _resolveCharacterTitle(unchecked((uint)titleId)) is { Length: > 0 } resolved) - { - return resolved; - } - return GetString(p, TemplateStringProperty); - } - - /// - /// Composes examination element 0x10000152 (PlayerKiller) for a - /// player target. Ruling R7: retail reads the LOCAL weenie's PWD bits — - /// never the appraisal payload's PropertyInt 134 (AP-109's CT4 lesson). - /// is the SAME - /// already resolves names from — the assessed - /// object located before ApplyCreature ever - /// runs. Retail's own guard, if (cur_weenobj != 0) - /// @0x004b482f, is scoped to exactly this line — it skips ONLY the PK - /// line when the weenie is gone, while heritage/title/allegiance/level - /// still update from whatever the response carries. When the local - /// lookup fails, bails out before reaching this - /// method (a wider, pre-existing drop of the whole response, out of - /// scope here); for the PK line alone, that cleared result matches - /// retail's guarded skip. Ground truth §2a, gap G3. - /// - private static string BuildPlayerKillerDisplay(ClientObject obj) - { - var bitfield = (PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u); - if ((bitfield & PublicWeenieFlags.PlayerKiller) != 0) - return "Player Killer"; - if ((bitfield & PublicWeenieFlags.PlayerKillerLite) != 0) - return "Player Killer Lite"; - return "Non-Player Killer"; - } - - /// - /// Composes examination element 0x1000053A (AllegianceName). - /// Retail clears the element first ( - /// already does this for every ApplyCreature call), then sets it - /// to String 47 AllegianceName ONLY inside the Int 30 AllegianceRank - /// >= 1 gate — no gate, no allegiance line. Deletes the former - /// invented "Assessment incomplete" literal (zero retail provenance; - /// was the only occurrence in the repo). Ground truth §2a, gap G5. - /// Retail's CreatureExamineUI (monsters) never binds this - /// element at all — only CharExamineUI (players) does — so this - /// composer is scoped to the character branch, matching that - /// per-class split. - /// - private static string BuildAllegianceDisplay(PropertyBundle p) - => GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty; - - /// - /// Campaign AS slice AS5 (closes AP-109's title-bar residual): the char - /// path's title-bar OVERWRITE, AllegianceData::GetFullName - /// @0x005B6950 called from CharExamineUI::SetAppraiseInfo - /// (`@0x004b4c8c`-`@0x004b4cec`, AFTER 's String - /// 52/stack-count composition already ran in ). - /// Unconditional at the call site — retail's null check there guards only - /// the destination WIDGET, not the rank; the "plain name when rankless" - /// behavior lives inside GetFullName/GetTitle themselves. - /// Rank/heritage/gender read straight off the APPRAISAL bundle (ruling - /// R8: PropertyInt - /// (0x1E), never RuntimeAllegianceState, which carries a DIFFERENT - /// UI's own rank). The appended name reuses - /// — the - /// SAME "appropriate name" resolver retail's - /// ACCWeenieObject::GetObjectName(cur_weenobj, NAME_APPROPRIATE, 0) - /// call resolves at that exact call site; String 52 (ruling R1, absent - /// for players) is deliberately bypassed here exactly as retail bypasses - /// it — the overwrite recomputes the name independently of - /// 's own String-52-then-fallback chain. - /// - private string BuildCharacterTitleBarName(ClientObject obj, PropertyBundle p) - { - int rank = GetInt(p, AllegianceRankTitleTable.AllegianceRankPropertyId); - int heritageGroup = GetInt(p, CharacterIdentityText.HeritageGroupPropertyId); - int gender = GetInt(p, CharacterIdentityText.GenderPropertyId); - string name = _itemNames.ResolveAppropriateName(obj); - return AllegianceRankTitleTable.ComposeFullName(rank, heritageGroup, gender, name); - } - - /// - /// Rebuilds both authored appraisal lists from a freshly parsed response. - /// selects between the CharExamineUI - /// and CreatureExamineUI extras composition (armor-level trio + - /// unenchantable legend are CHAR-ONLY; see - /// ). appraisal - /// already carries ArmorLevels straight from the freshly parsed - /// wire response (AppraiseInfoParser parses it unconditionally - /// when the flag is set) — the 0.75 s combat refresh - /// (RefreshCurrentAppraisal) round-trips a - /// brand-new response through , so no separate cache - /// is needed for a refresh to keep rendering the same armor-level rows. - /// AS4: is only invoked on the character - /// path — the monster path never renders a Society row and has no use - /// for it. - /// - private void RebuildCreatureStats( - AppraiseInfoParser.Parsed appraisal, - bool character) + private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal) { if (_creatureStats is null || _creatureRowTemplates is null) return; @@ -884,11 +717,7 @@ public sealed class AppraisalUiController : IRetainedPanelController ? CreatureAppraisalRows.Build(profile, appraisal.Success) : Array.Empty()); _creatureExtra?.Rebuild( - CreatureAppraisalRows.BuildExtra( - appraisal.Properties, - appraisal.ArmorLevels, - character, - character ? _localFactionBits() : 0)); + CreatureAppraisalRows.BuildExtra(appraisal.Properties)); } private void ConfigureScrollableText( diff --git a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs index 7511ba44..0b983ca8 100644 --- a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs +++ b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs @@ -4,72 +4,26 @@ namespace AcDream.App.UI.Layout; /// Retail character identity display helpers for gmStatManagementUI. /// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls /// AppraisalSystem::InqGenderHeritageDisplay(gender 0x71, heritage 0xBC, 0), -/// then — when CharacterTitleTable::GetCharacterTitleFromID(m_titleID) -/// resolves — AppendText(separator @data_794358) + AppendText(titleString). -/// Campaign CT slice CT4 (2026-08-24) PE-read RECOVERED the separator as a -/// single space " " (both here and for the allegiance-rank prefix below); -/// 's existing string.Join(" ", ...) already -/// matched it. +/// then appends the current CharacterTitleTable title when one is active. /// -/// -/// Name-line ruling (CT4, 2026-08-24; CLOSED at Campaign AS slice AS5, -/// 2026-08-25 — narrows AP-109; its FormatXp sliver survives). Retail's NAME line -/// (AllegianceData::GetFullName @0x005b6950) prefixes an allegiance -/// RANK title ("<RankTitle> <Name>", same space separator, PE-read -/// @data_794098) when AllegianceSystem::GetTitle(rank, heritage, gender) -/// @0x005b8dd0 resolves one. The RANK value is PropertyInt 0x1E -/// (AllegianceRank) read LIVE off the qualities bundle -/// (CBaseQualities::InqInt(qualities, 0x1e)) — NOT -/// . That state -/// class carries a numerically-equivalent rank for a DIFFERENT UI -/// (SocialAllegiancePageController, which has no qualities-bundle -/// access of its own); -/// reads every other header property straight off -/// props.GetInt(...), and now the rank-prefix too. The 17-function -/// Get*Title table (Gearknight/Tumerok author only a MALE function, -/// reused for both genders' dispatch branches; Lugian only a FEMALE one, -/// likewise reused both ways; Olthoi/OlthoiAcid excluded by the dispatch's -/// own unsigned range check) is ported verbatim at -/// , whose class remarks carry the -/// full per-function address citations and the two independently-verified -/// decomp call sites. 's Name -/// label now composes through -/// via -/// . -/// internal static class CharacterIdentityText { public const uint GenderPropertyId = 0x71u; public const uint HeritageGroupPropertyId = 0xBCu; - /// - /// CT4 fix round (2026-08-25, BLOCKER 2): retail's AppendText at - /// @0x004f0990 appends the resolved CharacterTitleTable - /// string VERBATIM — no article stripping. 26 real ACE - /// CharacterTitle entries begin with "The" (e.g. "The Noob"), so - /// the former StripLeadingArticle call mangled every one of them. - /// Heritage also drops its fallback the - /// same round: InqGenderHeritageDisplay's third argument - /// (creature type) is a hardcoded literal 0 at - /// @0x004f08db, not sourced from any producer — retail has no - /// "race" input to this line at all. - /// public static string StatHeaderLine(CharacterSheet sheet) { + string? heritage = !string.IsNullOrWhiteSpace(sheet.Heritage) + ? sheet.Heritage + : sheet.Race; + + string? title = StripLeadingArticle(sheet.Title); + if (string.IsNullOrWhiteSpace(sheet.Gender)) - return Join(sheet.Heritage, sheet.Title); - return Join(sheet.Gender, sheet.Heritage, sheet.Title); + return Join(heritage, title); + return Join(sheet.Gender, heritage, title); } - /// - /// Retail: AppraisalSystem::InqGenderDisplayName @0x005b47c0 → - /// EnumMapper::GetString(0x10000001, gender, ...) → - /// DBObj::GetDIDByEnum (master map category 1, EnumMapper DID - /// 0x2200000A) — a LIVE DAT read. This table is a hardcoded C# - /// mechanism substitute (register row AP-235); its content is verified - /// byte-exact against the live EnumMapper by the InstalledDat pin - /// CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain. - /// public static string? GenderDisplayName(int gender) => gender switch { 1 => "Male", @@ -77,19 +31,6 @@ internal static class CharacterIdentityText _ => null, }; - /// - /// Retail: AppraisalSystem::InqHeritageGroupDisplayName @0x005b4710 - /// hardcodes ids 2/5/0xd to "Gharu'ndim"/"Umbraen"/"Olthoi", else falls - /// through to EnumMapper::GetString(0x10000002, heritage, ...) → - /// DBObj::GetDIDByEnum (master map category 1, EnumMapper DID - /// 0x2200000B) — a LIVE DAT read whose raw entries are the - /// internal names ("Gharundim", "Shadowbound", "OlthoiAcid" for those - /// same three ids). This table is a hardcoded C# mechanism substitute - /// (register row AP-235); every entry, including the ones the CT4 review - /// flagged as unverified guesses (10 "Penumbraen", 12 "Olthoi"), is - /// verified byte-exact against the live EnumMapper chain by - /// 's sibling InstalledDat pin. - /// public static string? HeritageGroupDisplayName(int heritageGroup) => heritageGroup switch { 1 => "Aluvian", @@ -108,36 +49,20 @@ internal static class CharacterIdentityText _ => null, }; - /// - /// Campaign AS slice AS2 (2026-08-25): examination window element - /// 0x10000150 (Heritage), CharExamineUI::SetAppraiseInfo - /// @0x004B45F0 via AppraisalSystem::InqGenderHeritageDisplay - /// @0x005B5AE0. Unlike (the - /// stat-management panel's header, which never appends a title and has - /// no creature-type fallback), this composes ONLY - /// "<Gender> <Heritage>" — falling back to - /// (the assessed object's - /// creature-type display name, Int 2, resolved through the SAME - /// EnumMapper family already - /// serves the monster view's own 0x1000014E line) ONLY when - /// is exactly 0, matching retail's own - /// integer comparison rather than "the lookup failed". - /// - public static string GenderHeritageDisplay( - int gender, - int heritageGroup, - string? creatureTypeFallback) - { - string? heritage = heritageGroup == 0 - ? creatureTypeFallback - : HeritageGroupDisplayName(heritageGroup); - return Join(GenderDisplayName(gender), heritage); - } - private static string Join(params string?[] parts) { return string.Join(" ", parts .Where(p => !string.IsNullOrWhiteSpace(p)) .Select(p => p!.Trim())); } + + private static string? StripLeadingArticle(string? title) + { + if (string.IsNullOrWhiteSpace(title)) return null; + + string trimmed = title.Trim(); + return trimmed.StartsWith("the ", System.StringComparison.OrdinalIgnoreCase) + ? trimmed[4..] + : trimmed; + } } diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index 711c72b7..f0d0f1f3 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -24,28 +24,13 @@ public sealed class CharacterSheet /// Character name (first line of the report). public string Name { get; init; } = string.Empty; - /// - /// Character level. Null when retail PropertyInt 0x19 is absent — Campaign - /// CT slice CT4: gmStatManagementUI::UpdateCharacterInfo - /// (0x004f0770) shows the literal "???" (data_7b0f34, PE-recovered) - /// in that case rather than an integer; a bare formatted level uses - /// "%d" semantics (data_7a0184) — see - /// 's binding. - /// - public int? Level { get; init; } + /// Character level. + public int Level { get; init; } /// Gender display string, e.g. "Female". Null = omit. public string? Gender { get; init; } - /// Race string, e.g. "Aluvian". Null = omit. - /// CT4 fix round (2026-08-25): no longer read by - /// — retail's - /// InqGenderHeritageDisplay creature-type argument is a hardcoded - /// literal 0 (@0x004f08db), with no producer for a "race" - /// value distinct from . Left in place as an - /// unpopulated field rather than deleted, since no current producer sets - /// it either; a future consumer needing a genuinely distinct race value - /// should confirm a real retail source exists first. + /// Race string, e.g. "Aluvian". Null = omit. public string? Race { get; init; } /// Heritage group display string, e.g. "Aluvian". Null = omit. @@ -73,20 +58,6 @@ public sealed class CharacterSheet /// 0x10000233 (m_pPKStatusText). Null = omit. public string? PkStatus { get; init; } - /// - /// Campaign CT slice CT4: available Luminance points (retail - /// PropertyInt64 6, AvailableLuminance). Header element - /// 0x100005C5/0x100005C6 pair — shown only past level 200 with a - /// nonzero - /// (gmStatManagementUI::UpdateExperience 0x004f0a70's luminance - /// branch). - /// - public long AvailableLuminance { get; init; } - - /// Retail PropertyInt64 7, MaximumLuminance. See - /// . - public long MaximumLuminance { get; init; } - // ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ───────── /// Formatted birth date string (retail InqInt(0x62) → strftime). @@ -171,12 +142,6 @@ public sealed class CharacterSheet /// public int SkillCredits { get; init; } - /// Campaign CA CA4 (retired AP-73): true while a raise/train - /// request is awaiting its authoritative server record — retail permits - /// one in flight and ghosts the raise controls until the quality-change - /// message lands (gmStatManagementUI's awaiting flag). - public bool AwaitingRaise { get; init; } - /// /// Unassigned (banked) experience points. /// Retail InqInt64(2) — shown in footer line-2 in State-A display. diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index a938e8dc..dc2ecb2a 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using AcDream.App.Net; using AcDream.Core.Items; using AcDream.Core.Player; -using AcDream.Runtime.Gameplay; using DatReaderWriter; using AcDream.Content; @@ -11,21 +10,22 @@ namespace AcDream.App.UI.Layout; /// /// Assembles the live for the retail Character -/// window and owns the raise-request flow. Extracted from -/// GameWindow (Code Structure Rule 1: sheet assembly + XP-curve math -/// is feature logic, not wiring). +/// window and owns the raise-request flow (wire send + optimistic local +/// apply). Extracted from GameWindow (Code Structure Rule 1: sheet +/// assembly + XP-curve math is feature logic, not wiring). /// /// Retail property ids and the decomp anchors for every field are /// documented on ; the raise-cost formulas are /// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family). /// -/// Raise flow (Campaign CA CA4, retired AP-73): -/// server-authoritative, exactly retail's -/// (gmStatManagementUI — one request in flight, controls ghost while -/// awaiting, no local mutation). Displayed state changes only when the -/// authoritative quality records land (PrivateUpdateAttribute / -/// PrivateUpdateVital / PrivateUpdateSkill plus the XP/credit property -/// updates), which also release the in-flight gate. +/// State ownership: optimistic debits go through the owning +/// store's eventful APIs — / +/// (fires ObjectUpdated) +/// when the player object is in the table, else +/// / +/// (fires CharacterChanged). +/// Never write the raw property dictionaries from UI code. The next server +/// snapshot remains authoritative over every optimistic value. /// public sealed class CharacterSheetProvider { @@ -49,31 +49,6 @@ public sealed class CharacterSheetProvider private readonly Action? _sendRaiseSkill; private readonly Action? _sendTrainSkill; - /// Campaign CT slice CT2's title owner — its - /// DisplayTitleId feeds the CT4 heritage-line composition. - /// Null (tests, no live session) leaves - /// null. - private readonly RuntimeCharacterTitleState? _titles; - - /// CT2's CharacterTitleTable::GetCharacterTitleFromID DAT - /// chain ( in production, - /// DatLock-wrapped by the host). Returns null (retail's hardcoded - /// "Unknown" substitution belongs to the Titles-page controller, - /// not the header — the header line simply omits an unresolved title) - /// when the id doesn't resolve. - private readonly Func? _resolveDisplayTitle; - - /// - /// Campaign CT slice CT4: retail StringInfo lookup through - /// StringTable 0x23000001 by key (ID_StatManagement_Header_PKStatus_* - /// — gmStatManagementUI::UpdatePKStatus 0x004f00a0), the same - /// compute_str_hash mechanism ChatWindowController's - /// chatStrings delegate uses. Null (tests) or a resolution miss - /// both leave null — no invented - /// English fallback for this specific line (CT4 contract). - /// - private readonly Func? _resolveUiString; - /// Portal SkillTable (0x0E000004) — set by the host once dats load. public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; } @@ -90,10 +65,7 @@ public sealed class CharacterSheetProvider Action? sendRaiseAttribute = null, Action? sendRaiseVital = null, Action? sendRaiseSkill = null, - Action? sendTrainSkill = null, - RuntimeCharacterTitleState? titles = null, - Func? resolveDisplayTitle = null, - Func? resolveUiString = null) + Action? sendTrainSkill = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer)); @@ -105,9 +77,6 @@ public sealed class CharacterSheetProvider _sendRaiseVital = sendRaiseVital; _sendRaiseSkill = sendRaiseSkill; _sendTrainSkill = sendTrainSkill; - _titles = titles; - _resolveDisplayTitle = resolveDisplayTitle; - _resolveUiString = resolveUiString; } /// @@ -146,16 +115,7 @@ public sealed class CharacterSheetProvider return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() }; var props = CurrentPlayerProperties(); - // #431/CT4: retail's own PropertyInt 0x19 read (InqInt) — the header - // level's "???" fallback (CharacterSheet.Level's own doc comment) - // needs to distinguish "absent" from "present but zero", so this - // stays a raw dictionary probe rather than GetInt's zero-defaulting - // helper. The XP-curve math below still wants a concrete int, so it - // keeps using the 0-defaulted local. CT4 fix round: one TryGetValue - // instead of a ContainsKey-then-indexer double lookup. - bool hasLevel = props.Ints.TryGetValue(0x19u, out int levelValue); - int level = hasLevel ? levelValue : 0; - int? displayLevel = hasLevel ? levelValue : null; + int level = props.GetInt(0x19u); long totalXp = props.GetInt64(1u); long unassignedXp = props.GetInt64(UnassignedXpPropertyId); var xp = ComputeLevelXp(level, totalXp); @@ -164,42 +124,16 @@ public sealed class CharacterSheetProvider return new CharacterSheet { - // Campaign AS slice AS5 (narrows AP-109 — its FormatXp sliver survives): retail's NAME line - // (gmStatManagementUI::UpdateCharacterInfo @0x004F0770, - // @0x004f0807-@0x004f0895) prefixes AllegianceData::GetFullName - // @0x005B6950's rank title exactly like the examination window's - // title bar (AppraisalUiController.BuildCharacterTitleBarName) — - // same GetFullName port, same "plain name when rankless" - // fallback. Rank/heritage/gender come straight off THIS bundle - // (props.GetInt), per AP-109's own correction — never - // RuntimeAllegianceState. - Name = AllegianceRankTitleTable.ComposeFullName( - props.GetInt(AllegianceRankTitleTable.AllegianceRankPropertyId), - props.GetInt(CharacterIdentityText.HeritageGroupPropertyId), - props.GetInt(CharacterIdentityText.GenderPropertyId), - CharacterName()), - Level = displayLevel, + Name = CharacterName(), + Level = level, Gender = CharacterIdentityText.GenderDisplayName( props.GetInt(CharacterIdentityText.GenderPropertyId)), Heritage = CharacterIdentityText.HeritageGroupDisplayName( props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)), - // CT2/CT3's resolved display title — CT4's heritage line appends - // this (CharacterIdentityText.StatHeaderLine). - Title = _titles is not null && _resolveDisplayTitle is not null - ? _resolveDisplayTitle(_titles.DisplayTitleId) - : null, - PkStatus = PkStatusText(CurrentPlayerBitfield(), _resolveUiString), + PkStatus = PkStatusText(props.GetInt(134u, 0)), TotalXp = totalXp, XpToNextLevel = xp.toNext, XpFraction = xp.fraction, - // CT4 item 5: retail PropertyInt64 6/7 — the private-update - // (0x02CF) and PlayerDescription (0x0013) parsers both already - // copy every Int64 key generically (ReadInt64Table / - // LocalPlayerState.OnInt64PropertyUpdate have no id whitelist), - // so ids 6/7 flow through with zero additional wiring once ACE - // sends them. - AvailableLuminance = props.GetInt64(6u), - MaximumLuminance = props.GetInt64(7u), HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health), HealthMax = VitalMax(LocalPlayerState.VitalKind.Health), @@ -243,7 +177,6 @@ public sealed class CharacterSheetProvider : null, Deaths = props.GetInt(0x2Bu), SkillCredits = skillCredits, - AwaitingRaise = _awaitingRaise, UnassignedXp = unassignedXp, AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1), AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10), @@ -285,25 +218,6 @@ public sealed class CharacterSheetProvider : _localPlayer.Properties; } - /// - /// CT4 fix round (2026-08-25, SHOULD-FIX 3): the local player's live - /// PublicWeenieDesc bitfield — the ONLY source retail's PK line - /// actually reads (ACCWeenieObject::IsPK/IsPKLite, PWD bits - /// 0x20/0x02000000). Only available once the live - /// has arrived (CreateObject); the PlayerDescription snapshot on - /// carries no bitfield, so this returns 0 - /// (retail's own "neither" → NPK default) until then — matching - /// 's - /// precedent read. - /// - private uint CurrentPlayerBitfield() - { - uint guid = _playerGuid(); - return guid != 0u && _objects.Get(guid) is { } player - ? player.PublicWeenieBitfield ?? 0u - : 0u; - } - private sealed class ChangeBinding : IDisposable { private CharacterSheetProvider? _owner; @@ -319,68 +233,27 @@ public sealed class CharacterSheetProvider owner._objects.Cleared += OnCleared; owner._localPlayer.AttributeChanged += OnAttributeChanged; owner._localPlayer.CharacterChanged += OnCharacterChanged; - // CA4: vital events participate ONLY in the raise-gate release - // (see OnVitalChanged) — regen ticks fire this constantly and - // must not rebuild the sheet outside an awaited raise. - owner._localPlayer.Changed += OnVitalChanged; // Issue #267: skills/attributes are now vitae + buff aware, so the // sheet must refresh whenever the active-enchantment set changes // (vitae applied/removed on death/lifestone, a buff cast/expired), // not only on raw property/attribute updates. if (owner._localPlayer.Spellbook is { } spellbook) spellbook.EnchantmentsChanged += OnCleared; - // CT4 contract: the heritage line's appended display title MUST - // refresh live on BOTH RuntimeCharacterTitleState notices — CT2's - // TableReplaced (0x0029, retail's own unconditional Refresh()) and - // DisplayTitleChanged (the display half of 0x002B). - if (owner._titles is { } titles) - { - titles.TableReplaced += OnCleared; - titles.DisplayTitleChanged += OnDisplayTitleChanged; - } } - private void OnDisplayTitleChanged(uint _) => OnCleared(); - private void OnObjectChanged(ClientObject value) { CharacterSheetProvider? owner = _owner; if (owner is not null && value.ObjectId == owner._playerGuid()) - { - // An authoritative player-property record (XP, credits, …) - // is a quality change — retail releases the raise gate on - // any of them (ListenToElementMessage @ 0x004EFBE0). - owner.ReleaseAwaitingRaise(); _changed(); - } } private void OnCleared() => _changed(); - private void OnAttributeChanged(LocalPlayerState.AttributeKind _) - { - _owner?.ReleaseAwaitingRaise(); + private void OnAttributeChanged(LocalPlayerState.AttributeKind _) => _changed(); - } - private void OnCharacterChanged() - { - _owner?.ReleaseAwaitingRaise(); - _changed(); - } - - private void OnVitalChanged(LocalPlayerState.VitalKind _) - { - // Release-and-refresh only while a raise is in flight: the full - // vital record answering a RaiseVital (or the Endurance/Self - // side-push) must un-ghost the controls, but ordinary regen - // ticks outside a raise stay out of the sheet-rebuild path. - CharacterSheetProvider? owner = _owner; - if (owner is null || !owner._awaitingRaise) - return; - owner.ReleaseAwaitingRaise(); - _changed(); - } + private void OnCharacterChanged() => _changed(); public void Dispose() { @@ -394,17 +267,8 @@ public sealed class CharacterSheetProvider owner._objects.Cleared -= OnCleared; owner._localPlayer.AttributeChanged -= OnAttributeChanged; owner._localPlayer.CharacterChanged -= OnCharacterChanged; - owner._localPlayer.Changed -= OnVitalChanged; if (owner._localPlayer.Spellbook is { } spellbook) spellbook.EnchantmentsChanged -= OnCleared; - if (owner._titles is { } titles) - { - titles.TableReplaced -= OnCleared; - titles.DisplayTitleChanged -= OnDisplayTitleChanged; - } - // Panel unmount resets the one-in-flight raise gate — retail's - // awaiting flag lives on the panel instance and dies with it. - owner.ReleaseAwaitingRaise(); } } @@ -598,41 +462,13 @@ public sealed class CharacterSheetProvider private static long ClampToLong(ulong value) => value > long.MaxValue ? long.MaxValue : (long)value; - /// - /// Campaign CT slice CT4, re-sourced at the CT4 fix round (2026-08-25, - /// SHOULD-FIX 3): gmStatManagementUI::UpdatePKStatus - /// (0x004f00a0) — eax->vtable->IsPK() tested first, then - /// IsPKLite(), else "neither" resolves the NPK string (retail - /// always shows exactly one of the three; there is no hidden/omitted - /// case). ACCWeenieObject::IsPK/IsPKLite - /// (@0x0058c8b0/@0x0058c8a0) read the live - /// PublicWeenieDesc BITFIELD directly — bit 5 (0x20) and - /// bit 0x19 (0x02000000) — NOT PropertyInt 134 - /// (PlayerKillerStatus) bitwise-tested against ACE's own enum - /// values (a prior version of this method did that; ACE's enum bit - /// layout is not the PWD bit layout, so a combined-flag PropertyInt - /// value like 0x4 | 0x8 would misclassify). PropertyInt 134 - /// already drives the correct bits via - /// — see - /// — so this method - /// only needs to read , - /// matching the precedent read at - /// . - /// Text resolves through StringTable 0x23000001 by key - /// () — no hardcoded English fallback; a - /// null resolver or a resolution miss both leave the line empty, - /// matching the CT4 contract's "no invented English". - /// - private static string? PkStatusText(uint publicWeenieBitfield, Func? resolveUiString) + private static string? PkStatusText(int status) => status switch { - var bitfield = (PublicWeenieFlags)publicWeenieBitfield; - string key = (bitfield & PublicWeenieFlags.PlayerKiller) != 0 - ? "ID_StatManagement_Header_PKStatus_PK" - : (bitfield & PublicWeenieFlags.PlayerKillerLite) != 0 - ? "ID_StatManagement_Header_PKStatus_PKL" - : "ID_StatManagement_Header_PKStatus_NPK"; - return resolveUiString?.Invoke(key); - } + 0x2 => "Non-Player Killer", + 0x4 => "Player Killer", + 0x40 => "Player Killer Lite", + _ => null, + }; /// Unenchanted base attribute value (Ranks + Start). Used for /// — the retail @@ -661,22 +497,14 @@ public sealed class CharacterSheetProvider // ── Raise-request flow ───────────────────────────────────────────────── /// - /// Campaign CA CA4 (#431, retires AP-73): send a raise/train action and - /// wait for the server's authoritative record — retail's exact flow - /// (gmAttributeUI::RaiseSelection @ 0x0049D020 / - /// gmSkillUI::RaiseSelection @ 0x0049C8C0, pinned in - /// docs/research/2026-07-10-retail-panel-behavior-pseudocode.md §5): - /// one request in flight, the raise controls ghost while awaiting, and - /// NO local mutation of ranks/XP/credits — displayed state changes only - /// when the quality-change record lands (which CA2's inbound parsers - /// now deliver). The former optimistic ApplyLocalRaise layer is - /// deleted: against ACE, a wrong TrainSkill cost fails SILENTLY, so an - /// optimistic apply could show a trained skill the server refused with - /// nothing to ever correct it. + /// Send a raise/train action to the server and, when a send delegate + /// fired, optimistically apply the local effect so the sheet stays + /// current during the round trip. The next server snapshot remains + /// authoritative (a rejected raise is corrected by the property echo — + /// pending/rollback ledger tracked as a follow-up issue). /// public void HandleRaiseRequest(CharacterStatController.RaiseRequest request) { - if (_awaitingRaise) return; if (request.Cost <= 0) return; if (_canSendRaise is not null && !_canSendRaise()) return; @@ -714,25 +542,71 @@ public sealed class CharacterSheetProvider } if (sent) - _awaitingRaise = true; + ApplyLocalRaise(request); } - /// - /// Retail releases the one-in-flight raise gate on ANY authoritative - /// quality-change element message - /// (gmStatManagementUI::ListenToElementMessage @ 0x004EFBE0) — - /// the LocalPlayerState change events are our equivalent. Rejections - /// that produce NO quality change (ACE sends chat-only for a failed - /// Raise*, and nothing at all for a rejected RaiseSkill/TrainSkill) - /// leave the gate held exactly as the static retail evidence leaves it - /// unverified (the pseudocode doc's own caveat); panel remount resets - /// it, matching retail's per-instance field lifetime. Verify live at - /// CA5 before hardening further — see the narrowed AP-73 row. - /// - internal void ReleaseAwaitingRaise() => _awaitingRaise = false; + private void ApplyLocalRaise(CharacterStatController.RaiseRequest request) + { + uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount; + ulong cost = (ulong)request.Cost; - /// One raise/train request in flight — retail permits exactly - /// one (gmStatManagementUI's awaiting flag). - private bool _awaitingRaise; + switch (request.Kind) + { + case CharacterStatController.RaiseTargetKind.Attribute: + if (_localPlayer.ApplyAttributeRaise(request.StatId, amount, cost)) + SpendUnassignedExperience(request.Cost); + break; + case CharacterStatController.RaiseTargetKind.Vital: + if (_localPlayer.ApplyVitalRaise(request.StatId, amount, cost)) + SpendUnassignedExperience(request.Cost); + break; + case CharacterStatController.RaiseTargetKind.Skill: + if (_localPlayer.ApplySkillRaise(request.StatId, amount, cost)) + SpendUnassignedExperience(request.Cost); + break; + case CharacterStatController.RaiseTargetKind.TrainSkill: + if (_localPlayer.ApplySkillTraining(request.StatId)) + SpendSkillCredits(request.Cost); + break; + } + } + private void SpendUnassignedExperience(long cost) + { + if (cost <= 0) return; + uint guid = _playerGuid(); + if (guid != 0u && _objects.Get(guid) is { } player) + { + long current = player.Properties.GetInt64(UnassignedXpPropertyId); + if (current <= 0) return; + _objects.UpdateInt64Property(guid, UnassignedXpPropertyId, + current > cost ? current - cost : 0L); + return; + } + _localPlayer.DebitInt64Property(UnassignedXpPropertyId, cost); + } + + private void SpendSkillCredits(long cost) + { + if (cost <= 0) return; + int debit = cost > int.MaxValue ? int.MaxValue : (int)cost; + uint guid = _playerGuid(); + if (guid != 0u && _objects.Get(guid) is { } player) + { + foreach (uint propertyId in SkillCreditPropertyIds) + { + if (!player.Properties.Ints.TryGetValue(propertyId, out int current)) + continue; + _objects.UpdateIntProperty(guid, propertyId, + current > debit ? current - debit : 0); + return; + } + return; + } + foreach (uint propertyId in SkillCreditPropertyIds) + { + if (_localPlayer.DebitIntProperty(propertyId, debit)) + return; + } + } } diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index d6097c21..3f0f232e 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -35,15 +35,10 @@ namespace AcDream.App.UI.Layout; /// line-1 label = "Experience To Raise:", line-1 value = raise cost, /// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp. /// -/// Tab states: whichever of Attributes/Skills/Titles is active shows "Open" -/// (0x0C); the other two show "Closed" (0x0B) — Attributes is the retail-authored -/// default. Source: UIElement::SetState @ 0x00464E70. The imported Type-12 -/// tab owns its authored font color and propagates the state to its three chrome -/// children through PassToChildren. Campaign CT slice CT3 (2026-08-24) wires the -/// Titles tab click to a real page switch (previously a known-inert AP-109 gap); -/// owns the Titles page's OWN content -/// (row list, display-title text, Set-as-Display button) once its page container -/// is shown. +/// Tab states: Attributes = "Open", Skills = "Closed", Titles = "Closed". +/// Source: UIElement::SetState @ 0x00464E70 and UIStateId.Open (0x0C) / +/// UIStateId.Closed (0x0B). The imported Type-12 tab owns its authored font color and +/// propagates the state to its three chrome children through PassToChildren. /// /// Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable /// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection @@ -70,24 +65,6 @@ public static class CharacterStatController public const uint ListScrollbarId = 0x1000023Eu; // m_pListBox vertical scrollbar gutter public const uint ListDividerId = 0x1000023Fu; // bottom divider above footer - /// Campaign CT slice CT4: the luminance pair - /// (m_pLuminanceLabelText/m_pLuminanceText), shown only past level 200 - /// with nonzero MaximumLuminance — see - /// gmStatManagementUI::UpdateExperience (0x004f0a70)'s luminance - /// branch. CT4 fix round (2026-08-25, BLOCKER 1): the caption/value - /// SetText calls PE-byte-decoded from the gmStatManagementUI - /// vftable-adjacent data region — caption UTF-16 "Luminance:" at - /// @0x007c3dd4, value narrow "%s / %s" at - /// @0x007c3dcc — are now bound; see the Bind method's own - /// remarks. - public const uint LuminanceLabelId = 0x100005C5u; - public const uint LuminanceValueId = 0x100005C6u; - - /// Retail literal "Luminance:" — see - /// 's remarks for the PE-byte-decode - /// citation. - private const string LuminanceCaption = "Luminance:"; - // ── Footer STATE-A container id ────────────────────────────────────────── // 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row, // 0x10000242–0x10000245 labels+values) are the correct State-A versions with wider @@ -129,19 +106,10 @@ public static class CharacterStatController public const uint RaiseTenId = 0x100005EBu; // raise × 10 private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text - // Campaign CT slice CT4 (2026-08-24): the former hand-picked "Gold" - // header-level color constant is deleted — CT1's live-DAT pin confirmed - // the level element authors its own pale-gold FontColor (+ Outline); - // LabelAuthoredColor reads it from the widget instead. + private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold - /// Row highlight FALLBACK color — used only when - /// spriteResolve is unavailable (tests, headless mode) and the - /// real art cannot be drawn. CT5 fix - /// round: the former comment here claimed this tint "matches retail - /// sprite 0x06001397 visual intent" — that was never accurate (0x06001397 - /// is the spellbook's unrelated overlay sprite, and this solid tint was - /// never tuned to any specific sprite's actual pixels either way); it is - /// a synthetic no-art placeholder, not a retail-faithful color. + /// Row highlight color — semi-translucent gold, matches retail + /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent. private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f); // LayoutDesc 0x2100002E, FooterTitle 0x1000024E property 0x1B: // [0]=white, [1]=green, [2]=red, [3]=light blue (#7FFFFF). @@ -150,128 +118,39 @@ public static class CharacterStatController private static readonly Vector4 RetailVitaeBlue = new(127f / 255f, 1f, 1f, 1f); // ── Row layout constants ───────────────────────────────────────────────── - // Campaign CT slice CT5 (2026-08-25): the shared attribute/skill data-row - // template 0x10000248 (LayoutDesc 0x21000045 — InfoRegion::InfoRegion - // @0x004F1450 template index 0, used for BOTH gmAttributeUI and - // gmSkillUI rows) authors W=282 H=20 with icon 0x10000129 FLUSH LEFT - // (X=0 Y=0 W=20 H=20, full row height), name 0x1000012A at FIXED X=25 - // W=150, and value 0x1000012B at FIXED X=175 W=100 right-justified — - // its right edge (275) sits 7px short of the row's own 282px right - // edge, the authored gutter the owner reported (item 2). These are - // AUTHORED PIXEL VALUES, not width-relative fractions or derived - // offsets — the former RowHeight=22/IconSize=16px-at-X=4/ - // nameW=width*0.60 geometry had no dat basis at all. Ground truth + - // the explicit warning against composing a derived "gutter" formula: - // docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §2, pinned - // by CharacterPanelLiveDatTests.AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns. - private const float RowHeight = 20f; - private const float RowIconX = 0f; - private const float RowIconSize = 20f; - private const float RowNameX = 25f; - private const float RowNameW = 150f; - private const float RowValueX = 175f; - private const float RowValueW = 100f; - - // Section-header caption inset ONLY (AddSkillHeader's 0x10000249..0x1000024C - // captions carry their own authored geometry, distinct from the data-row - // template above) — retained at its pre-CT5 value. CT5 fix round - // (NOTE c): CT1 verified only the FOUR SPRITES for these header templates - // (SkillSectionHeaderTemplates_MatchExistingSpriteConstants: Width=280, - // Height=20, sprite match) — the caption LABEL's own authored geometry - // (W=280 with L5/R5 margins per the header template, versus our current - // header width, which comes from RowContentWidth/SkillViewportWidth same - // as the data rows, plus this flat 4px inset) was never pinned or - // cross-checked against that. This is a KNOWN, RECORDED residual gap — - // NOT a cleared divergence — left for a future slice to pin the caption - // child's authored margins the same way CT1 pinned the data-row - // template's icon/name/value columns; behavior is unchanged here. + // RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height + // and rows tighter. 16px icon fits inside 22px row with 3px vertical padding each side. + // The larger row font (0x40000001, MaxCharHeight=18) is clipped to the 22px height which + // gives a tight-but-readable line. Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height". + private const float RowHeight = 22f; + private const float IconSize = 16f; private const float RowPadX = 4f; + private const float IconGap = 6f; + private const float SkillRowHeight = 20f; private const float SkillHeaderHeight = 20f; - // Authored row-template CEILING width (0x10000248 W=282) — CT5: the - // row's own width comes from THIS authored constant as an upper bound, - // never the ListBox's raw 300px Width, and never a derived - // "listWidth minus gutter" formula (the ground-truth doc's explicit - // warning). CT5 fix round (NOTE b, tempered wording): this ceiling is - // NOT the row's width in every context. Attribute/vital rows land on it - // exactly (300px ListBox clamped to 282). The SKILL page instead first - // narrows the viewport to SkillViewportWidth's scrollbar-gutter - // measurement (scrollbar.Left − list.Left = 281px in the real - // production layout — CharacterPanelLiveDatTests. - // StatListBox_AuthorsFiveRowTemplatesInSharedLayout pins the scrollbar - // at X=281), and RowContentWidth then clamps skill rows to THAT - // (281 < 282) rather than the raw 282 ceiling — see - // Bind_SkillRow_ClampsToAuthoredScrollbarGutterWidth. private const float SkillContentWidth = 282f; private const uint SkillHeaderSpecializedSprite = 0x06000F90u; private const uint SkillHeaderTrainedSprite = 0x06000F86u; private const uint SkillHeaderUntrainedSprite = 0x06000F98u; private const uint SkillHeaderUnusableSprite = 0x06000F89u; + private const uint RowHighlightSprite = 0x06001397u; - // CT5 SEALED VERDICT (ground-truth doc §2 "SEALED VERDICT: RowHighlightSprite - // is wrong, not merely flagged"): gmAttributeUI::UpdateSelection - // @0x0049DEE0 calls SetState(selected ? 6 : 1) on the row; InfoRegion:: - // SetState @0x004F0EE0 forwards that to the row instantiated from THIS - // exact template (0x10000248), whose Highlight-state media is - // 0x06000F93 — a full-row background SWAP, not an overlay. The former - // 0x06001397 constant belongs to a DIFFERENT mechanism entirely: the - // spellbook row's separate selected-overlay CHILD element - // (UIElement_UIItem::SetSelectedState @0x004E1240, SpellbookRowStyle.cs) - // — that file and its tests are correct and must NOT be touched. - private const uint RowHighlightSprite = 0x06000F93u; + // Scrollbar chrome from base layout 0x2100003E, shared with chat/inventory. + private const uint ScrollTrackSprite = 0x06004C5Fu; + private const uint ScrollThumbSprite = 0x06004C63u; + private const uint ScrollThumbTop = 0x06004C60u; + private const uint ScrollThumbBot = 0x06004C66u; + private const uint ScrollUpSprite = 0x06004C69u; + private const uint ScrollDownSprite = 0x06004C6Cu; - // CT5 fix round (SHOULD-FIX 1, visible retail gap): the row template's - // NORMAL-state media — StateMedia[Normal] on 0x10000248, pinned byte- - // exact by CharacterPanelLiveDatTests. - // AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns - // (row.StateMedia["Normal"].File == 0x06004CC2). CT5 itself only wired - // the Highlight-state swap above; UNSELECTED rows drew fully - // transparent instead of this authored background. Independently - // decoded against the installed DAT (2026-08-25): PFID_A8R8G8B8, 48x48, - // a single uniform color (0,0,0,175) — i.e. a ~69%-opaque (175/255) - // flat black tile that retail's native-size copy-or-tile blit repeats - // across the row (see UiPanel.BackgroundSprite's doc comment for the - // tiling mechanism) to produce the dark band under every unselected - // attribute/skill row. Selected rows keep RowHighlightSprite above. - private const uint RowNormalSprite = 0x06004CC2u; - - // CT5 (AP-235 unification, gmAttributeUI::PostInit @0x0049DB70 verbatim): - // per-attribute icon DIDs resolve via DBObj::GetDIDByEnum(statEnum, - // category 0x10000002); per-vital (Attribute2ndInfoRegion) icon DIDs via - // DBObj::GetDIDByEnum(vitalEnum, category 0x10000003). Both categories - // are consumed through the shared RetailDataIdResolver.Resolve seam - // (the same master-map -> category-map -> value indirection already - // ported for the title chain and RetailKeyNames) rather than a fourth - // ad-hoc hardcoded DID table. Live-DAT-verified (2026-08-25): every - // hardcoded fallback value in AttrRows/VitalRows below already matches - // the resolved DID byte-exact — see - // CharacterPanelLiveDatTests.AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain. - private const uint AttributeIconCategory = 0x10000002u; - private const uint VitalIconCategory = 0x10000003u; - - // Scrollbar chrome from base layout 0x2100003E, shared with chat/ - // inventory — sprite set + retail button seating live in - // RetailScrollbarChrome (2026-08-24: the previous local constants seated - // the DOWN-arrow art on the top button). - - public enum CharacterStatTab + private enum CharacterStatTab { Attributes, Skills, - Titles, } - /// - /// Live character-panel binding. Keyboard panel actions use the same - /// tab switch function as the authored tab buttons, so F8/F9 cannot - /// diverge from click behavior. - /// - public sealed record Binding( - Action Refresh, - Action ShowTab, - Func CurrentTab); - public enum RaiseTargetKind { Attribute, @@ -297,13 +176,7 @@ public static class CharacterStatController private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill); // ── Attribute row descriptors — retail display order per spec §1 ───────── - // CT5 fix round (NOTE d): internal (not private) so - // CharacterPanelLiveDatTests.AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain - // can iterate THESE tuples directly instead of a re-typed duplicate - // literal array — a divergence between the two would previously have - // gone undetected. InternalsVisibleTo("AcDream.App.Tests") already - // covers this assembly (AcDream.App.csproj). - internal static readonly (string name, uint iconDid, uint statId)[] AttrRows = new[] + private static readonly (string name, uint iconDid, uint statId)[] AttrRows = new[] { ("Strength", 0x060002C8u, 1u), ("Endurance", 0x060002C4u, 2u), @@ -313,8 +186,7 @@ public static class CharacterStatController ("Self", 0x060002C7u, 6u), }; - // CT5 fix round (NOTE d): internal for the same reason as AttrRows above. - internal static readonly (string name, uint iconDid, uint maxStatId)[] VitalRows = new[] + private static readonly (string name, uint iconDid, uint maxStatId)[] VitalRows = new[] { ("Health", 0x06004C3Bu, 1u), // max enum 1; current enum 2 ("Stamina", 0x06004C3Cu, 3u), // max enum 3; current enum 4 @@ -374,37 +246,15 @@ public static class CharacterStatController /// a (GL tex handle, pixel width, pixel height) triple. Pass null in tests where /// icon rendering is not asserted. /// - /// - /// - /// CT5: ports DBObj::GetDIDByEnum(enumValue, - /// category) (master map -> category map -> value) for the per-attribute/ - /// per-vital row icon DIDs — pass (enumValue, category) => - /// RetailDataIdResolver.Resolve(dats, enumValue, category) under the - /// caller's dat lock. null (tests, or no live dat) falls back to - /// /' hardcoded DID column, - /// which the CT5 InstalledDat pin proves already matches the live-resolved - /// value byte-exact. - /// /// - /// - /// #431-CA5 gate fix (2026-08-24): the data-changed refresh. Retail's - /// panel refreshes rows from qualities on every authoritative - /// quality-change element message (InfoRegion::OnQualityChanged @ - /// 0x004F0EB0 → the 0x10000004 broadcast); before this, rows only - /// rebuilt on CLICKS, so a trained skill stayed in the untrained - /// section (and CA4's awaiting-ghost never visually released) until the - /// next click. The caller invokes this from the sheet-changed - /// subscription. - /// - public static Binding Bind( + public static void Bind( ImportedLayout layout, Func data, UiDatFont? datFont = null, UiDatFont? rowDatFont = null, Func? spriteResolve = null, RaiseRequestHandler? onRaiseRequest = null, - Action? onClose = null, - Func? iconDidResolve = null) + Action? onClose = null) { // rowDatFont: larger font for attribute row name/value text (18px vs 16px default). // Falls back to datFont when null (tests, or dat missing). @@ -421,30 +271,14 @@ public static class CharacterStatController UiElement? skillsTab = layout.FindElement(TabSkillsId); UiElement? titlesTab = layout.FindElement(TabTitlesId); UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId); - // CT3: unlike Attributes/Skills (which share ONE mounted page and just - // rebind its content — see the Attributes/Skills tab-switch note above), - // the Titles page (0x10000539) is its OWN separate, non-duplicated subtree - // (CT1 ground truth §3) that must be actually shown/hidden. - UiElement? titlesPage = FindDirectChildById(layout.Root, TitlesPageId); // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px): // Fix C: pass null → Label's null-guard keeps the build-time dat font. - // Campaign CT slice CT4 (2026-08-24): CT1's live-DAT pin - // (HeaderElements_AuthorExpectedFontsAndColors) confirmed all FOUR - // header identity elements — Name, Heritage, PkStatus, Level — carry - // their own authored FontColor (white/white/white/pale-gold with - // Outline). The "runtime color, dat carries none" reasoning this - // block used to justify a hand-picked Body/Gold constant per element - // was FALSIFIED by that pin: every element below now sources its - // color from the widget's own DAT-set DefaultColor - // (LabelAuthoredColor), matching the "authored color/font wins" - // pattern CT3's CharacterTitlesController already established for - // its row/display text. Level's Outline is likewise already applied - // at import time (DatWidgetFactory.BuildText reads dat property - // 0x21) — no controller-side Outline flag needed. - LabelAuthoredColor(layout, contentPage, NameId, null, () => data().Name); - LabelAuthoredColor(layout, contentPage, HeritageId, null, () => CharacterIdentityText.StatHeaderLine(data())); - LabelAuthoredColor(layout, contentPage, PkStatusId, null, () => data().PkStatus ?? string.Empty); + // Controllers still own the text color and the LinesProvider. + // Name = WHITE (retail "Horan" is white — confirmed 2026-06-26). + Label(layout, contentPage, NameId, null, Vector4.One, () => data().Name); + Label(layout, contentPage, HeritageId, null, Body, () => CharacterIdentityText.StatHeaderLine(data())); + Label(layout, contentPage, PkStatusId, null, Body, () => data().PkStatus ?? string.Empty); // ── Header captions (new — retail labels above/left of each number) ────── // LevelCaption (0x1000023A, 16px from dat): pass null → keep build-time dat font. @@ -453,17 +287,16 @@ public static class CharacterStatController // Level number: retail renders this as large gold centered text in the 65×50 element. // Fix C: the dat FontDid for the level element (0x1000023B) is now applied at build // time when the font resolver is provided (studio path). We no longer force rowDatFont - // here for the level — the dat's own FontDid drives the font. - // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo - // 0x004f0770. CT4 contract (PE-recovered 2026-08-24): InqInt(0x19) present formats - // with "%d" semantics (a bare integer, data_7a0184); absent shows the literal "???" - // (data_7b0f34) — CharacterSheet.Level is null in that case. - LabelAuthoredColor(layout, contentPage, LevelId, null, - () => data().Level is int lvl ? lvl.ToString(CultureInfo.InvariantCulture) : "???"); + // here for the level — the dat's own FontDid drives the font. The Gold color is still + // set via LinesProvider. SYNTHESIZED elements (the 9 attribute rows built in + // BuildAttributeRows) continue to use datFont directly since they have no dat origin. + // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo 0x004f0770. + // runtime color, dat carries none. + Label(layout, contentPage, LevelId, null, Gold, () => data().Level.ToString()); // TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font. LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):"); - LabelRight(layout, contentPage, TotalXpId, null, Body, () => FormatXp(data().TotalXp)); + LabelRight(layout, contentPage, TotalXpId, null, Body, () => data().TotalXp.ToString("N0")); // XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70). // Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by @@ -512,52 +345,10 @@ public static class CharacterStatController xpValue.RightAligned = true; xpValue.OneLine = true; xpValue.Padding = 0f; // avoid scroll clip - xpValue.LinesProvider = () => new[] { new UiText.Line(FormatXp(data().XpToNextLevel), Body) }; + xpValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) }; } } - // ── Luminance pair (0x100005C5/C6) — CT4 item 5, text bound at the ── - // ── CT4 fix round (2026-08-25, BLOCKER 1) ──────────────────────────── - // gmStatManagementUI::UpdateExperience (0x004f0a70): InqInt64(6) - // (AvailableLuminance) and InqInt64(7) (MaximumLuminance) are read - // unconditionally; the pair is hidden — UIElement_Text::ClearAllText - // (@0x004f0e31/@0x004f0e3c) on BOTH m_pLuminanceLabelText and - // m_pLuminanceText — whenever "InqInt(0x19) < 0xc8 (200) || - // MaximumLuminance == 0". ClearAllText empties the widget's text and - // leaves layout/Visible untouched, so this binds the SAME way every - // other dynamic label in this method does: a LinesProvider that - // re-reads data() on every draw and returns an EMPTY line set when - // the gate is closed (retail's ClearAllText) or the resolved content - // when it is open — no separate "refresh" call is needed, and no - // Visible flag is touched. Retail's SetText calls (recovered by - // PE-byte-decoding the gmStatManagementUI vftable-adjacent data - // region, since Binary Ninja mislabels the two string pointers as - // vftable slots rather than a StringTable key like the PK line): - // caption = literal "Luminance:" (UTF-16 @0x007c3dd4); value = - // narrow "%s / %s" (@0x007c3dcc) with (available, maximum) in that - // order, both formatted through ExperienceSystem::XPToString — ported - // as the shared FormatXp helper below (the same one Total XP / XP-to- - // next-level already use). - bool LuminanceVisible(CharacterSheet sheet) => - sheet.Level is int lvl && lvl >= 200 && sheet.MaximumLuminance != 0; - - if (FindTextByDatId(layout, contentPage, LuminanceLabelId) is UiText luminanceLabel) - { - luminanceLabel.LinesProvider = () => LuminanceVisible(data()) - ? new[] { new UiText.Line(LuminanceCaption, luminanceLabel.DefaultColor) } - : Array.Empty(); - } - if (FindTextByDatId(layout, contentPage, LuminanceValueId) is UiText luminanceValue) - { - luminanceValue.LinesProvider = () => - { - var sheet = data(); - if (!LuminanceVisible(sheet)) return Array.Empty(); - string text = $"{FormatXp(sheet.AvailableLuminance)} / {FormatXp(sheet.MaximumLuminance)}"; - return new[] { new UiText.Line(text, luminanceValue.DefaultColor) }; - }; - } - // The tab visuals are already retained in the imported LayoutDesc. Controllers // bind only click behavior and the active Open/Closed state below. @@ -565,12 +356,8 @@ public static class CharacterStatController // Mutable selected-index box: -1 = nothing selected. // Gather EVERY copy of the raise buttons in the tree. The raise button ids - // (0x10000246, 0x100005EB) each appear TWICE under BOTH the Attributes page - // (0x1000022B) and the Skills page (0x1000022C) — once per footer-state group - // (0x10000247/0x10000241) — four copies total. Verified against the committed - // fixture (CT3 fix round): Titles (0x10000539) authors NO copies of its own — - // correcting this comment's earlier, false "once per tab page - // (Attributes/Skills/Titles)" claim. ImportedLayout._byId keeps only the LAST + // (0x10000246, 0x100005EB) appear once per tab page (Attributes/Skills/Titles) + // in the dat inheritance structure; ImportedLayout._byId keeps only the LAST // mounted copy. We collect all copies so we can hide them all initially and // show/hide the correct set when a row is selected. // @@ -678,12 +465,9 @@ public static class CharacterStatController RetailTabBinding.SetClick(attributesTab, () => SwitchTab(CharacterStatTab.Attributes)); RetailTabBinding.SetClick(skillsTab, () => SwitchTab(CharacterStatTab.Skills)); - // CT3 (2026-08-24): the Titles tab now switches to its own real page - // (previously the known AP-109 gap — retail-authored closed visual, but - // no click routing at all). Content population is - // CharacterTitlesController's job, bound separately by the caller - // against the SAME imported layout root. - RetailTabBinding.SetClick(titlesTab, () => SwitchTab(CharacterStatTab.Titles)); + // Titles remain the known AP-109 gap. Keep its retail-authored closed visual, + // but do not make an inert page look interactive until that controller lands. + RetailTabBinding.SetClick(titlesTab, null); UpdateTabStates(); // ── Active-page selection (fixes the dark-overlay) ───────────────────── @@ -710,39 +494,8 @@ public static class CharacterStatController attrSel[0] = -1; skillSel[0] = -1; SetFooterSelected(false); - - // CT3: Titles is a genuinely separate page container (unlike - // Attributes/Skills, which share one mounted page and only rebind - // its content — see RebuildActiveList) — actually flip visibility - // between it and the shared Attributes/Skills content page. - bool showTitles = tab == CharacterStatTab.Titles; - if (titlesPage is not null) titlesPage.Visible = showTitles; - if (contentPage is not null) contentPage.Visible = !showTitles; - - if (showTitles) - { - // CT3 fix round: the prior comment here ("Titles authors its - // own copies of the raise buttons") was FALSE — verified - // against the fixture, Titles (0x10000539) has none; see the - // corrected collection comment above. contentPage.Visible = - // false (just above) already suppresses the Attributes - // page's real raise-button copies for both draw and click - // routing (UiElement early-returns on an invisible node - // before descending to children), so this loop is a no-op - // in the common case. It is kept only as a defensive - // fallback for the case where contentPage was not found at - // bind time (contentPage is null, line ~525) but allRaise1/ - // allRaise10 were still populated via the tree-walk/FindElement - // fallback above. - foreach (var b in allRaise1) b.Visible = false; - foreach (var b in allRaise10) b.Visible = false; - } - else - { - RebuildActiveList(); - RefreshActiveRaiseButtons(); - } - + RebuildActiveList(); + RefreshActiveRaiseButtons(); UpdateTabStates(); Console.WriteLine($"[CharacterStat] Tab click: {tab}"); } @@ -751,7 +504,7 @@ public static class CharacterStatController { RetailTabBinding.SetOpen(attributesTab, activeTab[0] == CharacterStatTab.Attributes); RetailTabBinding.SetOpen(skillsTab, activeTab[0] == CharacterStatTab.Skills); - RetailTabBinding.SetOpen(titlesTab, activeTab[0] == CharacterStatTab.Titles); + RetailTabBinding.SetOpen(titlesTab, false); } void RebuildActiveList() @@ -764,78 +517,40 @@ public static class CharacterStatController currentAttributeRows.Clear(); currentSkillRows.Clear(); - // CT6 (2026-08-25): both tabs now stack their rows inside a - // UiScrollablePanel viewport child of statList — the SAME - // shrink-and-scroll contract the Skills tab already had. - // UiScrollablePanel.LayoutScrollableChildren (called every - // OnDraw) recomputes Scroll.ViewHeight from the viewport's OWN - // current Height every frame; the viewport's Anchors - // (Left|Top|Bottom, a child of statList) track statList's live - // Height as the window resizes, so Scroll.HasOverflow flips live - // with no extra wiring. Previously only Skills got this - // treatment — Attributes added rows directly to statList with no - // clipping/scrolling and the shared scrollbar was force-hidden, - // which is the "we never show the scrollbar on Attributes" gap - // (owner report item 2) and the reason a shrunk window could not - // show the overflowing attribute/vital rows at all. - bool isSkills = activeTab[0] == CharacterStatTab.Skills; - float contentW = isSkills - ? SkillViewportWidth(statList, skillScrollbar) - : RowContentWidth(statList); - var viewport = new UiScrollablePanel + if (activeTab[0] == CharacterStatTab.Attributes) { - Left = 0f, - Top = 0f, - Width = contentW, - Height = statList.Height, - LineHeight = (int)RowHeight, - Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, - }; - statList.AddChild(viewport); - // #372-class fix (same mechanism as UiTemplateListBox.Viewport's - // own lazy getter): RebuildActiveList runs during Bind, BEFORE - // the window's first anchor pass ever reflows statList up from - // its raw DAT-authored height (160px) to its actual mounted - // height. Left to its lazy default, viewport's Left|Top|Bottom - // anchor would capture its baseline margins on ITS OWN first - // ApplyAnchor call — which happens AFTER statList has already - // grown — measuring a bogus non-zero bottom margin (e.g. - // "398 grown list − 160 stale viewport = 238px margin") that then - // permanently caps the viewport 238px short on every future - // resize. Capturing NOW, while viewport.Height still exactly - // equals statList's own CURRENT (pre-reflow, zero-margin) - // height, gives a true (0,0,0,0) baseline that then correctly - // full-stretches on every later resize. - viewport.CaptureCurrentAnchorBaseline(); - activeListEntries.Add(viewport); - - if (isSkills) - { - BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel, - allRaise1, allRaise10, SetFooterSelected, out currentSkillRows); + if (skillScrollbar is not null) + { + skillScrollbar.Model = null; + skillScrollbar.Visible = false; + } + currentAttributeRows = BuildAttributeRows(statList, rowDatFont, spriteResolve, data, attrSel, + allRaise1, allRaise10, SetFooterSelected); + activeListEntries.AddRange(currentAttributeRows); } else { - currentAttributeRows = BuildAttributeRows(viewport, rowDatFont, spriteResolve, data, attrSel, - allRaise1, allRaise10, SetFooterSelected, iconDidResolve); - } + float contentW = SkillViewportWidth(statList, skillScrollbar); + var viewport = new UiScrollablePanel + { + Left = 0f, + Top = 0f, + Width = contentW, + Height = statList.Height, + LineHeight = (int)SkillRowHeight, + Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + }; + statList.AddChild(viewport); + activeListEntries.Add(viewport); - // Always BOUND for whichever tab is active — no per-tab visibility - // toggle is needed here. CT6 fix round (S2 correction): the - // scrollbar's own .Visible=true just keeps it in the tree; what - // actually shows or hides it on screen is UiScrollbar's - // IsPresentationVisible, and 0x1000023E authors 0x79 - // (HideWhenDisabled) TRUE (fixture-verified — the 121/0x79 - // property on the fixture's scrollbar element carries - // BoolValue=true). That means a fitting list HIDES the bar - // entirely, not a full-track "disabled" thumb left visible — the - // previous comment here had the authored default backwards. - // Content that overflows still makes the bar visible and - // interactive, same as always. - if (skillScrollbar is not null) - { - skillScrollbar.Model = viewport.Scroll; - skillScrollbar.Visible = true; + BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel, + allRaise1, allRaise10, SetFooterSelected, out currentSkillRows); + + if (skillScrollbar is not null) + { + skillScrollbar.Model = viewport.Scroll; + skillScrollbar.Visible = true; + } } } @@ -885,16 +600,7 @@ public static class CharacterStatController } RefreshActiveRaiseButtons(); - // CT4 fix round: the luminance pair's LinesProvider re-reads - // data() on every draw (same as every other dynamic label here), - // so no explicit refresh call is needed for a level-up or a - // luminance-award quality change. } - - return new Binding( - () => RefreshAfterRaise(null), - SwitchTab, - () => activeTab[0]); } private static UiScrollbar? PrepareSkillScrollbar( @@ -963,7 +669,12 @@ public static class CharacterStatController Func spriteResolve) { bar.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }; - RetailScrollbarChrome.ApplyVertical(bar); + bar.TrackSprite = ScrollTrackSprite; + bar.ThumbSprite = ScrollThumbSprite; + bar.ThumbTopSprite = ScrollThumbTop; + bar.ThumbBotSprite = ScrollThumbBot; + bar.UpSprite = ScrollUpSprite; + bar.DownSprite = ScrollDownSprite; } private static float SkillViewportWidth(UiElement statList, UiScrollbar? bar) @@ -980,42 +691,6 @@ public static class CharacterStatController : SkillContentWidth; } - /// - /// CT5: the row's own rendered width is 's own - /// width clamped to the AUTHORED row-template CEILING - /// (282px, ) — the two numbers never - /// compose into a derived "gutter" formula (ground-truth doc §2's - /// explicit warning); a shorter clamps the row - /// down, but a wider one never stretches it past 282. - /// CT5 fix round (NOTE b, tempered wording): this does NOT mean - /// every row is 282px. Attribute/vital rows pass the raw ListBox - /// (300px, 's own dat - /// rect) and land on the 282 ceiling exactly. The skill page instead - /// passes the narrower SkillViewportWidth-computed viewport - /// (281px in production — the real scrollbar's authored gutter), so - /// skill rows clamp to 281, one pixel short of the template ceiling. - /// - /// - private static float RowContentWidth(UiElement list) - => list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth; - - /// - /// CT5 (AP-235): resolve a row icon DID through the live - /// DBObj::GetDIDByEnum chain when a resolver is available, - /// falling back to the hardcoded / - /// column value otherwise (tests, or a resolve miss). - /// - private static uint ResolveIconDid( - Func? resolver, - uint enumValue, - uint category, - uint fallback) - { - if (resolver is null) return fallback; - uint resolved = resolver(enumValue, category); - return resolved != 0u ? resolved : fallback; - } - // ── 9-row attribute list ───────────────────────────────────────────────── private static List BuildAttributeRows( @@ -1026,10 +701,9 @@ public static class CharacterStatController int[] sel, List allRaise1, List allRaise10, - Action setFooterSelected, - Func? iconDidResolve) + Action setFooterSelected) { - float listW = RowContentWidth(list); + float listW = list.Width; float y = 0f; var rows = new List(); @@ -1040,7 +714,7 @@ public static class CharacterStatController var row = AddRow(list, datFont, spriteResolve, left: 0f, top: y, width: listW, height: RowHeight, - iconDid: ResolveIconDid(iconDidResolve, statId, AttributeIconCategory, iconDid), + iconDid: iconDid, nameText: rowName, valueProvider: () => { @@ -1077,7 +751,7 @@ public static class CharacterStatController var row = AddRow(list, datFont, spriteResolve, left: 0f, top: y, width: listW, height: RowHeight, - iconDid: ResolveIconDid(iconDidResolve, maxStatId, VitalIconCategory, iconDid), + iconDid: iconDid, nameText: rowName, valueProvider: () => { @@ -1116,7 +790,7 @@ public static class CharacterStatController Action setFooterSelected, out List skillRows) { - float listW = RowContentWidth(list); + float listW = list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth; float y = 0f; var entries = new List(); var bindings = new List(); @@ -1145,7 +819,7 @@ public static class CharacterStatController CharacterSkill LiveSkill() => FindSkill(data(), skill.Id) ?? skill; var row = AddRow(list, datFont, spriteResolve, - left: 0f, top: y, width: listW, height: RowHeight, + left: 0f, top: y, width: listW, height: SkillRowHeight, iconDid: skill.IconDid, nameText: skill.Name, valueProvider: () => LiveSkill().CurrentLevel.ToString(), @@ -1161,7 +835,7 @@ public static class CharacterStatController }; bindings.Add(new SkillRowBinding(row, skill)); entries.Add(row); - y += RowHeight; + y += SkillRowHeight; } } } @@ -1325,14 +999,10 @@ public static class CharacterStatController Console.WriteLine($"[CharacterStat] Row click: index={clickedIndex} → selected={newSel} ({rowName})"); // Update highlight on all rows. - // CT5: retail's InfoRegion::SetState swaps the row's whole background - // to the template's Highlight-state media (RowHighlightSprite, - // 0x06000F93 — see its own doc comment) when spriteResolve is - // available; otherwise fall back to the translucent gold tint. - // CT5 fix round (SHOULD-FIX 1): the UNSELECTED branch now draws the - // template's Normal-state media (RowNormalSprite, 0x06004CC2 — see - // its own doc comment) the same way, instead of leaving the row - // fully transparent. + // Retail uses sprite 0x06001397 (Button state 6 — the dark horizontal bars) + // for the selected row background. When spriteResolve is available, apply the + // sprite; otherwise fall back to the translucent gold tint. + const uint HighlightSprite = 0x06001397u; for (int i = 0; i < rows.Count; i++) { var row = rows[i]; @@ -1341,7 +1011,7 @@ public static class CharacterStatController if (spriteResolve is not null) { row.BackgroundColor = Vector4.Zero; - row.BackgroundSprite = RowHighlightSprite; + row.BackgroundSprite = HighlightSprite; row.SpriteResolve = spriteResolve; } else @@ -1354,8 +1024,8 @@ public static class CharacterStatController else { row.BackgroundColor = Vector4.Zero; - row.BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u; - row.SpriteResolve = spriteResolve; + row.BackgroundSprite = 0u; + row.SpriteResolve = null; } } @@ -1398,28 +1068,23 @@ public static class CharacterStatController { row.BackgroundColor = Vector4.Zero; row.BackgroundSprite = RowHighlightSprite; - // CT5 fix round (NOTE f): unified with HandleRowClick's - // direct assignment — the per-row wrapper closure this - // used to allocate was functionally identical (same - // tuple shape, just differently-named elements, which - // the delegate conversion already accepts without it). - row.SpriteResolve = spriteResolve; + row.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }; + row.UseSelectionBars = true; } else { row.BackgroundColor = HighlightBg; row.BackgroundSprite = 0u; row.SpriteResolve = null; + row.UseSelectionBars = false; } } else { - // CT5 fix round (SHOULD-FIX 1): draw the Normal-state media - // (RowNormalSprite) on unselected rows — see HandleRowClick's - // matching comment for the full citation. row.BackgroundColor = Vector4.Zero; - row.BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u; - row.SpriteResolve = spriteResolve; + row.BackgroundSprite = 0u; + row.SpriteResolve = null; + row.UseSelectionBars = true; } } } @@ -1447,10 +1112,8 @@ public static class CharacterStatController var sheet = data(); long cost1 = GetRaiseCost(sheet, selectedIndex, amount: 1); long cost10 = GetRaiseCost(sheet, selectedIndex, amount: 10); - // CA4 (retired AP-73): while a raise awaits its authoritative - // record, retail ghosts the raise controls (one request in flight). - bool affordable1 = !sheet.AwaitingRaise && cost1 > 0 && sheet.UnassignedXp >= cost1; - bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10; + bool affordable1 = cost1 > 0 && sheet.UnassignedXp >= cost1; + bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10; foreach (var b in allRaise1) { @@ -1483,9 +1146,9 @@ public static class CharacterStatController bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained; long cost = trained ? selectedSkill.RaiseCost : selectedSkill.TrainedCost; - bool affordable = !sheet.AwaitingRaise && (trained + bool affordable = trained ? cost > 0 && sheet.UnassignedXp >= cost - : cost > 0 && sheet.SkillCredits >= cost); + : cost > 0 && sheet.SkillCredits >= cost; foreach (var b in allRaise1) { b.Visible = true; @@ -1500,7 +1163,7 @@ public static class CharacterStatController if (trained) { long cost10 = selectedSkill.Raise10Cost; - bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10; + bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10; b.TrySetRetailState(affordable10 ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted); @@ -1818,24 +1481,9 @@ public static class CharacterStatController } /// - /// Add a single attribute/vital/skill row to as a - /// containing icon + name + value children, - /// laid out at the AUTHORED template 0x10000248 pixel geometry (icon - /// flush left 20x20, name at X=25 W=150, value at X=175 W=100 - /// right-justified — see the row-layout-constants block above). Returns - /// the panel so the caller can wire . - /// - /// CT5 hand-built-vs-template ruling: this row stays HAND-BUILT - /// (not converted to UiTemplateListBox row instantiation). - /// Converting would touch every one of the ~15 call sites this method's - /// row feeds — raise-button affordability, footer State A/B, per-row - /// tooltip, section-header bucketing, live-refresh-on-quality-change, - /// and the selection-highlight swap — for a purely cosmetic slice whose - /// authored numbers this hand-built path can already hit byte-exact - /// (proven by CharacterPanelLiveDatTests.AttributeRowTemplate_...). - /// That is a large-blast-radius rewrite for a geometry-only fix; the - /// smaller-risk path is implementing the authored constants directly - /// here, which this method now does. + /// Add a single attribute/vital row to as a + /// containing icon + name + value children. + /// Returns the panel so the caller can wire . /// private static UiClickablePanel AddRow( UiElement list, @@ -1846,44 +1494,34 @@ public static class CharacterStatController string nameText, Func valueProvider, Func? valueColorProvider = null, - Vector4? nameColor = null) + Vector4? nameColor = null, + uint backgroundSprite = 0u, + bool useSelectionBars = true) { var row = new UiClickablePanel { - // #430: runtime-built rows author no P0x47/P0x48 of their own, and - // OnTooltipShow refuses a widget without a popup locator — so the - // Batch-B TooltipText never mounted. Use the shared popup skin, - // the ONLY locator pair the character layout (0x2100002E) itself - // references (live-DAT probed 2026-08-24) and the same inference - // UiItemSlot already ships for runtime-built widgets. - AuthoredTooltipRootElementId = - RetailTooltipPresenter.SharedPopupSkinRootElementId, - AuthoredTooltipLayoutDid = - RetailTooltipPresenter.SharedPopupSkinLayoutDid, Left = left, Top = top, Width = width, Height = height, - BackgroundColor = Vector4.Zero, - // CT5 fix round (SHOULD-FIX 1): a freshly-built row starts - // unselected, so it gets the template's Normal-state media - // (RowNormalSprite) up front — matching HandleRowClick's/ - // ApplySkillSelectionVisuals' unselected branch exactly, so a - // row never flashes transparent before its first click. - BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u, - SpriteResolve = spriteResolve, + BackgroundColor = Vector4.Zero, // transparent until selected + BackgroundSprite = spriteResolve is not null ? backgroundSprite : 0u, + SpriteResolve = spriteResolve is not null && backgroundSprite != 0u + ? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); } + : null, BorderColor = Vector4.Zero, + UseSelectionBars = useSelectionBars, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; - // Icon 0x10000129: flush left (X=0), Y=0, 20x20 — full row height, - // no vertical centering math needed since RowIconSize == RowHeight. + float iconY = (height - IconSize) * 0.5f; + var iconEl = new UiText { - Left = RowIconX, - Top = 0f, - Width = RowIconSize, - Height = RowIconSize, + Left = RowPadX, + Top = iconY, + Width = IconSize, + Height = IconSize, ClickThrough = true, DatFont = null, BackgroundSprite = spriteResolve is not null ? iconDid : 0u, @@ -1894,47 +1532,35 @@ public static class CharacterStatController Anchors = AnchorEdges.Left | AnchorEdges.Top, }; - // Name 0x1000012A: X=25 W=150 — fixed authored pixels, not a - // width-relative fraction. + float nameX = RowPadX + IconSize + IconGap; + float nameW = width * 0.60f; + float nameY = 0f; + string capturedName = nameText; Vector4 capturedNameColor = nameColor ?? Body; var nameEl = new UiText { - Left = RowNameX, - Top = 0f, - Width = RowNameW, + Left = nameX, + Top = nameY, + Width = nameW, Height = height, DatFont = datFont, ClickThrough = true, Centered = false, RightAligned = false, - // CT5 fix round (SHOULD-FIX 3): the authored template's - // 0x1000012A name column carries no margin property at all — - // Padding=1f re-created the X=26 glyph start (RowNameX + 1) this - // very slice existed to remove (see AttributeRowTemplate_...'s - // pin: name.X == 25f, no margin). 0f matches the value column's - // own (already-0) Padding. - Padding = 0f, - // CT5 fix round (NOTE a): both the name and value columns author - // VJustify=Center in the template (0x1000012A/0x1000012B share - // the same default UiText.VerticalJustify). OneLine=true routes - // this element through the same single-line vertical-centering - // draw path the value column below already uses instead of the - // multi-line/scroll path, which does not honor VerticalJustify - // the same way. - OneLine = true, + Padding = 1f, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, capturedNameColor) }; - // Value 0x1000012B: X=175 W=100, right-justified — its right edge - // (275) sits 7px short of the row's own 282px right edge, the - // authored gutter the owner reported. + float valueW = width - nameX - nameW - RowPadX; + float valueX = nameX + nameW; + var valueEl = new UiText { - Left = RowValueX, - Top = 0f, - Width = RowValueW, + Left = valueX, + Top = nameY, + Width = valueW > 0f ? valueW : 40f, Height = height, DatFont = datFont, ClickThrough = true, @@ -2096,12 +1722,12 @@ public static class CharacterStatController long skillCost = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained ? skill.RaiseCost : skill.TrainedCost; - return skillCost > 0 ? FormatXp(skillCost) : "Infinity!"; + return skillCost > 0 ? skillCost.ToString("N0") : "Infinity!"; } if (attrSel[0] < 0) return sheet.SkillCredits.ToString(); long cost = GetRaiseCost(sheet, attrSel[0]); - return cost > 0 ? FormatXp(cost) : "Infinity!"; + return cost > 0 ? cost.ToString("N0") : "Infinity!"; }); // Line-2 elements: pass null → keep dat font. @@ -2127,7 +1753,7 @@ public static class CharacterStatController if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) return sheet.SkillCredits.ToString(); } - return FormatXp(sheet.UnassignedXp); + return sheet.UnassignedXp.ToString("N0"); }); BindSelectedFooterState(stateB); @@ -2179,12 +1805,12 @@ public static class CharacterStatController long skillCost = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained ? skill.RaiseCost : skill.TrainedCost; - return skillCost > 0 ? FormatXp(skillCost) : "Infinity!"; + return skillCost > 0 ? skillCost.ToString("N0") : "Infinity!"; } if (attrSel[0] < 0) return sheet.SkillCredits.ToString(); long cost = GetRaiseCost(sheet, attrSel[0]); - return cost > 0 ? FormatXp(cost) : "Infinity!"; + return cost > 0 ? cost.ToString("N0") : "Infinity!"; }); LabelProvider(TextById(state, FooterLine2Label), null, Body, () => @@ -2207,7 +1833,7 @@ public static class CharacterStatController if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) return sheet.SkillCredits.ToString(); } - return FormatXp(sheet.UnassignedXp); + return sheet.UnassignedXp.ToString("N0"); }); } } @@ -2318,54 +1944,6 @@ public static class CharacterStatController } } - /// - /// Retail-equivalent of ExperienceSystem::XPToString - /// (sprintf("%I64d", value)GetNumberFormatA's - /// locale-grouped-decimal formatting) — shared by every field that - /// formats a retail XP-shaped 64-bit count: Total XP, XP-to-next-level, - /// and (CT4 fix round, 2026-08-25) the luminance available/maximum pair. - /// Retail text is US-formatted for everyone (the project's locale- - /// independence rule), so this is InvariantCulture, not - /// CurrentCulture — the pre-CT4-fix-round call sites used a bare - /// .ToString("N0"), which silently followed the host OS locale. - /// - private static string FormatXp(long value) => value.ToString("N0", CultureInfo.InvariantCulture); - - /// - /// Same binding shape as , but the per-line color is - /// read from the widget's own — the - /// value DatWidgetFactory.BuildText already seeded from the - /// element's authored dat property 0x1B — instead of a caller-supplied - /// constant. Campaign CT slice CT4 (2026-08-24): the header identity - /// block's four elements (Name/Heritage/PkStatus/Level) all carry their - /// own correct authored color (CT1's live-DAT pin), so "authored color - /// wins" here is both simpler and more correct than hand-picking a - /// runtime constant — the same precedent - /// 's row/display text already - /// set (rowText.DefaultColor). CT4 fix-round consistency note - /// (2026-08-25): this helper unconditionally forces - /// Centered = true/OneLine = true, which is correct for - /// the four elements it is actually called on (Name/Heritage/PkStatus/ - /// Level, all centered in the DAT), but would be WRONG for a - /// left/right-justified authored element (e.g. the luminance pair, - /// which is deliberately bound with its own inline LinesProvider below - /// rather than through this helper, precisely to preserve its authored - /// Left/Right justification). Left as-is rather than parameterizing - /// Centered/OneLine, since no current caller needs the non-centered - /// case — a future caller that does should not reuse this helper as-is. - /// - private static void LabelAuthoredColor(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Func text) - { - if (FindTextByDatId(layout, scope, id) is UiText t) - { - if (datFont is not null) t.DatFont = datFont; - t.Centered = true; - t.OneLine = true; - t.ClickThrough = true; - t.LinesProvider = () => new[] { new UiText.Line(text(), t.DefaultColor) }; - } - } - /// Two-line centered label. Provides TWO lines from LinesProvider so both /// fit side-by-side in a narrow element without truncation. The scroll path in /// renders multiple lines oldest-first (top-to-bottom), so diff --git a/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs b/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs deleted file mode 100644 index 6bea0f71..00000000 --- a/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs +++ /dev/null @@ -1,102 +0,0 @@ -using AcDream.Content; -using DatReaderWriter.DBObjs; - -namespace AcDream.App.UI.Layout; - -/// -/// Ports CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0's -/// title id -> display string chain: EnumMapper(0x22000041) -/// canonical key name -> compute_str_hash -> -/// StringTable(0x2300000E) localized text. -/// -/// -/// -/// Retail's chain is a two-level DBObj::GetDIDByEnum indirection -/// through a master map (DID 0x25000000): category 1 (EMAPPER) for -/// the EnumMapper, category 4 (STRINGTABLE) for the StringTable. Both -/// resolved DIDs are hardcoded here rather than porting the indirection -/// generically — the same precedent already -/// set for this exact category-4 family (its KeyNameTableId/ -/// MetaKeyNameTableId/DelimiterTableId). A THIRD consumer of -/// GetDIDByEnum has already appeared — -/// gmAttributeUI::PostInit @0x0049DB70 resolves per-attribute icon -/// DIDs via category 0x10000002 — so CT5 (not "if a third consumer -/// appears") is where the shared GetDIDByEnum(enumValue, category) -/// helper gets factored out, per -/// docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §5 item 1. -/// Both DIDs used here were verified end-to-end against ACE's -/// CharacterTitle.WarMage = 13 in Campaign CT slice CT1 (same §5, -/// pinned by -/// CharacterPanelLiveDatTests.TitleStringTable_ResolvesWarMageEndToEnd). -/// -/// -/// stays -/// id-only (the #368 headless-bot-observes-the-same-owner contract); this -/// resolver is the App-layer seam CT3 (Titles page) and CT4 (header -/// identity line) consume for display strings. -/// -/// -public sealed class CharacterTitleResolver -{ - /// EnumMapper DID — title id -> canonical key name (e.g. - /// "ID_CharacterTitle_War_Mage"). - public const uint TitleEnumMapperId = 0x22000041u; - - /// StringTable DID — canonical key hash -> localized text. - public const uint TitleStringTableId = 0x2300000Eu; - - private readonly IDatReaderWriter _dats; - private readonly DatStringResolver _strings; - private readonly Dictionary _resolvedCache = new(); - private EnumMapper? _titleEnumMapper; - private bool _loadedMapper; - - public CharacterTitleResolver(IDatReaderWriter dats) - { - _dats = dats ?? throw new ArgumentNullException(nameof(dats)); - _strings = new DatStringResolver(dats); - } - - /// - /// Resolves one title id to its localized display string. Returns null - /// for id 0 (retail's own early-return in - /// GetCharacterTitleFromID), an id absent from the EnumMapper, or - /// an unlocalized string — matching - /// 's own - /// null-on-miss contract. - /// - /// - /// A8 (CT2 fix round, 2026-08-24): memoizes the final resolved string - /// per (including misses). Retail caches the - /// computed hash directly on the string buffer - /// (GetCharacterTitleFromID's 0xFFFFFFFF lazy-hash - /// sentinel); a memo of the final string is the equivalent here given - /// our immutable, DAT-static tables — no invalidation is needed. - /// - public string? Resolve(uint titleId) - { - if (titleId == 0u) - return null; - - if (_resolvedCache.TryGetValue(titleId, out string? cached)) - return cached; - - if (!_loadedMapper) - { - _dats.Portal.TryGet(TitleEnumMapperId, out _titleEnumMapper); - _loadedMapper = true; - } - - string? resolved = null; - if (_titleEnumMapper is not null - && _titleEnumMapper.IdToStringMap.TryGetValue(titleId, out var rawNameValue)) - { - string rawName = rawNameValue.ToString(); - if (!string.IsNullOrEmpty(rawName)) - resolved = _strings.Resolve(TitleStringTableId, DatStringResolver.ComputeHash(rawName)); - } - - _resolvedCache[titleId] = resolved; - return resolved; - } -} diff --git a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs deleted file mode 100644 index a4e7e912..00000000 --- a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs +++ /dev/null @@ -1,448 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using AcDream.Runtime; - -namespace AcDream.App.UI.Layout; - -/// -/// Campaign CT slice CT3 (2026-08-24): binds the character window's Titles -/// page (LayoutDesc 0x2100002E, element 0x10000539 — -/// gmCharacterTitleUI) to CT2's -/// owner through the standard / -/// / classes only — no bespoke -/// widgets, matching every other social/options row-list page in this -/// codebase (, -/// , -/// ). -/// -/// -/// -/// Rows. AddTitleToList @0x0049A840 resolves each row's text -/// through CharacterTitleTable::GetCharacterTitleFromID (ported as -/// , CT2) and inserts it SORTED -/// (FindSortedInsertPosition @0x0049A760 — an ordinal string sort on -/// the resolved display text). This port rebuilds the full sorted row set on -/// every change () rather than performing a true -/// positional splice: has no insert-at-index -/// primitive, and no other consumer in this -/// codebase needs one either (Friends/Squelch/Fellowship/Allegiance/chargen -/// skills/the Options tabs all rebuild-on-change the same way) — the -/// resulting VISIBLE order is retail-exact even though the underlying -/// mechanism is "rebuild," not "splice." AddTitleToList itself -/// early-outs (@0x0049a873/@0x0049a914) before ever reaching -/// the insert — retail NEVER creates a row for id 0 or for an id -/// GetCharacterTitleFromID fails to resolve, so -/// skips those ids entirely rather than falling back to a placeholder row -/// text (CT3 fix round — the "Unknown" literal belongs ONLY to the -/// display-title text below, never to a row). -/// -/// -/// Selection + highlight. Retail's InfoRegion::SetState -/// mechanism (confirmed for the sibling stat rows by CT1's SEALED VERDICT) -/// applies SetState(selected ? 6 : 1) directly to the row element — -/// state 6 is . The title row -/// template (0x10000536) authors that exact Highlight state -/// (0x06001AAF) alongside its DirectState background -/// (0x06004CCA), so this controller uses the row's own -/// — no synthesized color -/// swap, unlike pages whose row template lacks a state-based highlight. -/// -/// -/// Selection lifetime (CT3 fix round — BLOCKER). Refresh -/// @0x0049abc0 itself calls SetSelectedItem(nullptr, 1) -/// (@0x0049ac5a) UNCONDITIONALLY, before it repopulates the list — -/// every code path that reaches Refresh() drops the current -/// selection outright, regardless of whether the previously-selected title -/// id is still earned. Refresh() runs on BOTH -/// (0x0029) -/// and (the -/// display-title half of 0x002B), so -/// and both clear -/// before rebuilding/re-highlighting. -/// RecvNotice_AddCharacterTitle @0x0049a990 (the add half of -/// 0x002B, ) is a DIFFERENT retail method -/// that splices one row into mTitleList without ever touching -/// m_pSelectedItem — selection SURVIVES a title add. Concretely: -/// after the user sets a display title and ACE echoes 0x002B, the -/// previously-highlighted row goes dark and the Set-as-Display button -/// re-ghosts, exactly like retail — but earning a brand-new title while a -/// row is selected leaves that selection alone. -/// -/// -/// The "Set as Display Title" button (0x10000535). -/// UpdateButtons @0x0049A500 (CORRECTED per the campaign plan's CT1 -/// fix round): Ghosted (state 0xD) UNLESS a row is SELECTED whose title id -/// DIFFERS from the CURRENT display title; no selection is the Ghosted case, -/// not the enabled one. A click sends -/// CM_Social::Event_SetDisplayCharacterTitle (CT2's -/// command seam) — wire only, no -/// local mutation; the ghost gate itself makes an already-current selection -/// unreachable from the UI, so the click handler's own defensive re-check is -/// belt-and-braces, not the primary guard. -/// -/// -/// Display-title text (0x1000052F). Refresh @0x0049abc0 -/// shows the resolved current display title, or retail's hardcoded literal -/// "Unknown" when the id does not resolve — refreshed on BOTH -/// (retail's own -/// RecvNotice_UpdateCharacterTitleTable unconditionally calls -/// Refresh() on every 0x0029, CT2 review anchor 1) and -/// . -/// -/// -/// The two page captions (0x1000052E/0x10000531). Left -/// untouched by this controller — -/// already resolves every element's authored StringInfo caption at -/// import time (ResolveAuthoredString), the SAME mechanism every -/// other DAT-authored label in this window already relies on, so no -/// controller-side string lookup is needed or added here. -/// -/// -public sealed class CharacterTitlesController : IDisposable -{ - public const uint CurrentDisplayTitleTextId = 0x1000052Fu; - public const uint TitleListBoxId = 0x10000532u; - public const uint SetDisplayButtonId = 0x10000535u; - - /// The row template's own text child (0x10000536's - /// single Type-0xC child) — CT1 ground truth §3. - private const uint RowTextId = 0x10000537u; - - /// Retail's hardcoded fallback literal (Refresh - /// @0x0049abc0) for a display title id that does not resolve — - /// ported verbatim, not a StringTable key (CT2 review anchor 3). - private const string UnknownTitleText = "Unknown"; - - private readonly record struct Row(UiElement Root, uint TitleId); - - private readonly RuntimeCharacterTitleState _titles; - private readonly Func _resolveTitle; - private readonly Func _sendSetTitle; - private readonly UiTemplateListBox _listBox; - private readonly UiText? _displayText; - private readonly UiButton? _setDisplayButton; - private readonly List _rows = new(); - private uint? _selectedTitleId; - private bool _disposed; - - private CharacterTitlesController( - RuntimeCharacterTitleState titles, - Func resolveTitle, - Func sendSetTitle, - UiTemplateListBox listBox, - UiText? displayText, - UiButton? setDisplayButton) - { - _titles = titles; - _resolveTitle = resolveTitle; - _sendSetTitle = sendSetTitle; - _listBox = listBox; - _displayText = displayText; - _setDisplayButton = setDisplayButton; - } - - /// - /// Binds the Titles page's list box, scrollbar, display-title text, and - /// Set-as-Display button under (the - /// character window's imported tree — the Titles page's element ids are - /// unique client-wide, so no page-scoped search is needed, unlike the - /// multi-tab social panel's row families). Returns null (logging why) - /// when the list box itself is missing — every other element is - /// optional so a partial import still gets what it can. - /// - /// CT2's - /// method group in production; a delegate (not the concrete DAT-backed - /// class) so this controller stays hermetically testable without a live - /// IDatReaderWriter. - public static CharacterTitlesController? Bind( - UiElement layoutRoot, - RuntimeCharacterTitleState titles, - Func resolveTitle, - Func templateResolver, - Func sendSetTitle) - { - ArgumentNullException.ThrowIfNull(layoutRoot); - ArgumentNullException.ThrowIfNull(titles); - ArgumentNullException.ThrowIfNull(resolveTitle); - ArgumentNullException.ThrowIfNull(templateResolver); - ArgumentNullException.ThrowIfNull(sendSetTitle); - - if (UiElement.FindDescendant(layoutRoot, TitleListBoxId) is not UiTemplateListBox listBox) - { - Console.WriteLine( - $"[D.2b] CharacterTitlesController: ListBox 0x{TitleListBoxId:X8} not " + - "found — the Titles page will not populate."); - return null; - } - listBox.TemplateResolver = templateResolver; - // The row template (0x10000536) authors a 270x24 box (CT1 ground - // truth §3); UiTemplateListBox's own DefaultLineHeight is 16, which - // would desync wheel/line scroll from the actual row pitch - // (CharacterManagementUiController.cs:463 sets its own row height - // the same way for the same reason). - listBox.LineHeight = 24; - // CT6 (2026-08-25): the Titles list (authored H=455 inside the - // 575px page — CT1 ground truth §3) shrinks/grows with the window - // the same way CharacterStatController's attribute/skill list does. - // CT6 fix round (S3 correction): the former Anchors fallback here - // ("if LayoutPolicy is null") was DEAD CODE — both 0x10000532 (this - // ListBox) and its page container 0x10000539 author - // HasOriginalParentSize=true in the real DAT (pinned by - // CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize), - // so LayoutImporter/DatWidgetFactory ALWAYS assigns a real - // LayoutPolicy to this element and the fallback branch never ran on - // either the installed DAT or the committed fixture. The actual - // reflow mechanism is that authored LayoutPolicy stretching with the - // mounted content's height — deleted rather than left as - // unreachable/misleading compatibility code. UiTemplateListBox's own - // internal viewport (created lazily inside AddItemFromTemplateList) - // already carries the #372-class eager-baseline-capture fix, so once - // the ListBox itself reflows, its scrollbar (bound below) picks up - // the new content/view relationship for free. - - uint scrollbarElementId = listBox.ScrollbarElementId; - UiElement? scrollbarElement = scrollbarElementId == 0 - ? null - : UiElement.FindDescendant(layoutRoot, scrollbarElementId); - if (scrollbarElement is UiScrollbar scrollbar) - scrollbar.Model = listBox.Scroll; - else - Console.WriteLine( - $"[D.2b] CharacterTitlesController: scrollbar 0x{scrollbarElementId:X8} " + - "not found — the Titles list will not scroll."); - - UiText? displayText = - UiElement.FindDescendant(layoutRoot, CurrentDisplayTitleTextId) as UiText; - UiButton? setDisplayButton = - UiElement.FindDescendant(layoutRoot, SetDisplayButtonId) as UiButton; - - var controller = new CharacterTitlesController( - titles, resolveTitle, sendSetTitle, listBox, displayText, setDisplayButton); - controller.WireButton(); - - titles.TableReplaced += controller.OnTableReplaced; - titles.TitleAdded += controller.OnTitleAdded; - titles.DisplayTitleChanged += controller.OnDisplayTitleChanged; - - controller.RebuildRows(); - // Bind-time refresh (CT3 fix round NOTE 6): retail itself only - // shows "Unknown" until the first notice arrives (nothing runs - // Refresh() before Refresh() is first called), but ACE always sends - // 0x0029 at SendSelf before this panel can even open, so the - // pre-notice "Unknown" frame is unreachable in live play. Refreshing - // at bind time instead keeps a window RE-mount (tab re-open, panel - // rebuild) consistent with whatever the table already holds, rather - // than flashing "Unknown" for one frame before the next notice. - controller.RefreshDisplayText(); - controller.RefreshButtonGhost(); - return controller; - } - - private void WireButton() - { - if (_setDisplayButton is null) return; - _setDisplayButton.OnClick = () => - { - // Belt-and-braces re-check (CT2 review anchor 2): retail's real - // guard is the Ghosted state itself — UiButton refuses to raise - // OnClick while !Enabled — so this branch is normally - // unreachable from a real click, but a direct-call test (or a - // stray event) must still send nothing while ghosted, and never - // wait for a confirmation ACE does not send when re-setting the - // already-current title. - if (_selectedTitleId is not uint id || id == _titles.DisplayTitleId) - return; - _sendSetTitle(id); - }; - } - - /// - /// 0x0029 CharacterTitle — retail's own Refresh() is - /// unconditional here (CT2 review anchor 1), and UnPack always - /// rebuilds mTitleList from scratch. BLOCKER fix (CT3 fix round): - /// Refresh also calls SetSelectedItem(nullptr, 1) - /// (@0x0049ac5a) unconditionally, BEFORE it repopulates — so the - /// selection is cleared here regardless of whether the previously - /// selected id is still earned, not merely dropped when it disappears. - /// - private void OnTableReplaced() - { - ClearSelection(); - RebuildRows(); - RefreshDisplayText(); - RefreshButtonGhost(); - } - - /// - /// 0x002B UpdateTitle, add half — CT2's F1 fix already dedupes - /// this event to genuine new memberships only (a repeat add fires no - /// event at all), so every firing here is a real new row. - /// RecvNotice_AddCharacterTitle @0x0049a990 splices the one new - /// row into mTitleList without ever touching - /// m_pSelectedItem (unlike Refresh's unconditional - /// clear) — selection deliberately survives a title add. - /// - private void OnTitleAdded(uint titleId) - { - RebuildRows(); - RefreshButtonGhost(); - } - - /// - /// 0x002B UpdateTitle, display half — this is the other trigger - /// for retail's Refresh() (CT2 review anchor 1), so it carries - /// the same unconditional SetSelectedItem(nullptr, 1) - /// (@0x0049ac5a) as . This handler - /// does not call (the row SET is unchanged — - /// only the display title moved), so it re-applies highlights directly - /// to actually dark out the previously-selected row. - /// - private void OnDisplayTitleChanged(uint titleId) - { - ClearSelection(); - ApplyRowHighlights(); - RefreshDisplayText(); - RefreshButtonGhost(); - } - - /// Refresh @0x0049abc0's SetSelectedItem(nullptr, - /// 1) (@0x0049ac5a) — clears the tracked selection only; the - /// caller is responsible for re-applying row highlights and the button - /// ghost state afterward. - private void ClearSelection() => _selectedTitleId = null; - - /// - /// Full sorted rebuild — see the class remarks for why this port - /// rebuilds rather than performing retail's literal single-row - /// positional insert. Preserves scroll position - /// (). Callers that - /// mirror retail's unconditional Refresh() selection clear - /// () call - /// themselves before this runs; the check below is a defensive - /// fallback for any other caller ( included) - /// so a selection can never point at a row that no longer exists. - /// - private void RebuildRows() - { - _listBox.FlushPreservingScroll(); - _rows.Clear(); - - // AddTitleToList @0x0049A840 early-outs (@0x0049a873/@0x0049a914): - // retail never creates a row for id 0 or for an id - // GetCharacterTitleFromID fails to resolve — "Unknown" is the - // display-title text's OWN fallback (RefreshDisplayText), never a - // row's (CT3 fix round — was previously ported backwards). - // A3/CT2 doc warning: EarnedTitleIds allocates a fresh array per - // read — safe here (a UI refresh call site, not a per-frame poll). - var candidates = new List<(uint Id, string Text)>(); - foreach (uint id in _titles.EarnedTitleIds) - { - if (id == 0) continue; - string? text = _resolveTitle(id); - if (text is null) continue; - candidates.Add((id, text)); - } - // FindSortedInsertPosition @0x0049A760: ordinal string sort on the - // resolved display text. OrderBy is a STABLE sort (unlike - // List.Sort) so equal-text rows keep retail's insert-after- - // equals order; ties are broken by title id for full determinism. - List<(uint Id, string Text)> sorted = candidates - .OrderBy(static c => c.Text, StringComparer.Ordinal) - .ThenBy(static c => c.Id) - .ToList(); - - foreach ((uint id, string text) in sorted) - { - UiElement? row = _listBox.AddItemFromTemplateList(0); - if (row is null) continue; - - if (row is UiDatElement datRow) - { - // Generic Type-3 container fallback (DatWidgetFactory) — - // "generic decoration; behavioral widgets opt back in" (its - // own class doc). Same page-opt-in shape - // CharacterCreationSkillsPage uses for its selectable rows. - datRow.ClickThrough = false; - uint capturedId = id; - datRow.OnClick = () => SelectRow(capturedId); - } - - if (UiElement.FindDescendant(row, RowTextId) is UiText rowText) - { - // Build the line array once per text change and capture it - // — LinesProvider runs every draw, so a `=> [new Line(...)]` - // literal would allocate a fresh array every frame - // (pattern: CharacterCreationSkillsPage.cs:829). DefaultColor - // is the row template's own authored font color, not a - // hardcoded white. - UiText.Line[] lines = [new UiText.Line(text, rowText.DefaultColor)]; - rowText.LinesProvider = () => lines; - } - - _rows.Add(new Row(row, id)); - } - - if (_selectedTitleId is uint selected && !_rows.Exists(r => r.TitleId == selected)) - _selectedTitleId = null; - - ApplyRowHighlights(); - } - - private void SelectRow(uint titleId) - { - if (_disposed) return; - _selectedTitleId = titleId; - ApplyRowHighlights(); - RefreshButtonGhost(); - } - - /// Retail InfoRegion::SetState(selected ? 6 : 1) — the - /// row's OWN authored Highlight/DirectState media swap, not a - /// synthesized color (see class remarks). - private void ApplyRowHighlights() - { - foreach (Row row in _rows) - { - if (row.Root is IUiDatStateful stateful) - { - stateful.TrySetRetailState( - row.TitleId == _selectedTitleId - ? UiButtonStateMachine.Highlight - : UiButtonStateMachine.Normal); - } - } - } - - private void RefreshDisplayText() - { - if (_displayText is null) return; - string text = _resolveTitle(_titles.DisplayTitleId) ?? UnknownTitleText; - // Cached array, authored color — same reasoning as the row text - // above (CT3 fix round). - UiText.Line[] lines = [new UiText.Line(text, _displayText.DefaultColor)]; - _displayText.LinesProvider = () => lines; - } - - /// - /// UpdateButtons @0x0049A500 (CORRECTED — campaign plan CT1 fix - /// round): Ghosted UNLESS a row is selected whose title id DIFFERS from - /// the current display title. No selection is the Ghosted case. - /// - private void RefreshButtonGhost() - { - if (_setDisplayButton is null) return; - bool shouldGhost = _selectedTitleId is not uint id || id == _titles.DisplayTitleId; - _setDisplayButton.TrySetRetailState( - shouldGhost ? UiButtonStateMachine.Ghosted : UiButtonStateMachine.Normal); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _titles.TableReplaced -= OnTableReplaced; - _titles.TitleAdded -= OnTitleAdded; - _titles.DisplayTitleChanged -= OnDisplayTitleChanged; - } -} diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs index a8f407c9..21e9071d 100644 --- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs +++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs @@ -100,10 +100,9 @@ internal static class ChatTranscriptRenderer /// accumulating, so the two-threshold hysteresis has nothing to damp — it /// exists to stop retail trimming on every single append. A single cap /// gives a STABLE window here; oscillating one would make the oldest - /// visible line jump around as messages arrive. Most entries are already - /// one line; an oversized server entry with embedded newlines is clipped - /// at the first complete line inside the retained suffix, matching - /// retail's newline preference. + /// visible line jump around as messages arrive. Cutting at whole lines is + /// automatic for the same reason: our unit already is the line, which is + /// what retail's newline preference is trying to achieve. /// /// public const int MaxTranscriptCharacters = 0x2710; @@ -120,14 +119,6 @@ internal static class ChatTranscriptRenderer IReadOnlyList detailed, Func? accept, int budget = MaxTranscriptCharacters) - => FindBudgetStart(detailed, accept, budget).LineIndex; - - private readonly record struct BudgetStart(int LineIndex, int CharacterOffset); - - private static BudgetStart FindBudgetStart( - IReadOnlyList detailed, - Func? accept, - int budget = MaxTranscriptCharacters) { long used = 0; for (int i = detailed.Count - 1; i >= 0; i--) @@ -136,68 +127,11 @@ internal static class ChatTranscriptRenderer continue; // +1 for the newline retail stores between lines. - long cost = detailed[i].Text.Length + 1L; - if (used + cost <= budget) - { - used += cost; - continue; - } - - int available = (int)Math.Max(0L, budget - used - 1L); - if (available > 0) - { - string text = detailed[i].Text; - int minimumOffset = Math.Max(0, text.Length - available); - int offset = FirstCharacterAfterLineBreak(text, minimumOffset); - if (offset < text.Length) - return new BudgetStart(i, offset); - - // A single newest unbroken message must still remain visible; - // dropping it wholesale is what made large @acecommands - // replies render as an empty transcript. - if (used == 0 && text.Length > 0) - return new BudgetStart(i, minimumOffset); - } - return new BudgetStart(i + 1, 0); + used += detailed[i].Text.Length + 1; + if (used > budget) + return i + 1; } - return new BudgetStart(0, 0); - } - - private static int FirstCharacterAfterLineBreak(string text, int start) - { - for (int i = Math.Clamp(start, 0, text.Length); i < text.Length; i++) - { - if (text[i] is not ('\r' or '\n')) - continue; - if (text[i] == '\r' && i + 1 < text.Length && text[i + 1] == '\n') - i++; - return i + 1; - } - return text.Length; - } - - private static FormattedLine SliceLine(FormattedLine line, int offset) - { - if (offset <= 0) - return line; - - string text = line.Text[offset..]; - if (line.Spans is not { Count: > 0 } spans) - return line with { Text = text }; - - var sliced = new List(); - int at = 0; - foreach (ChatTextSpan span in spans) - { - int end = at + span.Text.Length; - if (end > offset) - { - int from = Math.Max(offset, at) - at; - sliced.Add(span with { Text = span.Text[from..] }); - } - at = end; - } - return line with { Text = text, Spans = sliced }; + return 0; } /// @@ -323,14 +257,12 @@ internal static class ChatTranscriptRenderer // (defaultColor), matching retail's DoFontReset — not the color table's // unrelated index-0x00 slot. Vector4 currentColor = defaultColor; - BudgetStart start = FindBudgetStart(detailed, accept); - for (int lineIndex = start.LineIndex; lineIndex < detailed.Count; lineIndex++) + int firstLine = FirstLineWithinBudget(detailed, accept); + for (int lineIndex = firstLine; lineIndex < detailed.Count; lineIndex++) { FormattedLine d = detailed[lineIndex]; if (accept is not null && !accept(d.LogTextType)) continue; - if (lineIndex == start.LineIndex && start.CharacterOffset > 0) - d = SliceLine(d, start.CharacterOffset); if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved)) currentColor = resolved; // Wrapping can DROP the space it broke on, so a fragment is not diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index cc465bc2..fb224e99 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -5,7 +5,6 @@ using AcDream.App.Rendering; using AcDream.App.UI; using AcDream.Core.Chat; using AcDream.UI.Abstractions; -using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Chat; namespace AcDream.App.UI.Layout; @@ -63,7 +62,6 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private const uint TrackId = 0x10000012u; private const uint InputBarId = 0x10000013u; private const uint MenuId = 0x10000014u; - private const uint MenuLabelId = 0x10000015u; // button caption child: white, FontDid 0x40000002 private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 → UiField private const uint SendId = 0x10000019u; private const uint MaxMinId = 0x1000046Fu; @@ -190,62 +188,40 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private string? _tellTarget; - /// - /// Authored chat-string lookup (key name in StringTable 0x23000001 — - /// gmMainChatUI resolves every talk-focus label through - /// compute_str_hash'd ID_Chat_* keys; live-DAT probed 2026-08-24). - /// Null (tests / standalone mounts) falls back to the authored EoR - /// English transcribed below. - /// - private Func? _chatStrings; - - private string S(string key, string authoredFallback) - => _chatStrings?.Invoke(key) ?? authoredFallback; - - /// Channel rows: authored ID_Chat_TellTo* key + the authored - /// EoR English as the no-resolver fallback (both live-DAT probed - /// 2026-08-24 — the fallbacks ARE the authored strings). - private static readonly (string Key, string Fallback, ChatChannelKind Channel)[] ChannelItems = + private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = { - ("ID_Chat_TellToAll", "Chat to All", ChatChannelKind.Say), - ("ID_Chat_TellToFellows", "Tell to Fellows", ChatChannelKind.Fellowship), - ("ID_Chat_TellToGeneral", "Tell to General Chat", ChatChannelKind.General), - ("ID_Chat_TellToLFG", "Tell to LFG Chat", ChatChannelKind.Lfg), - ("ID_Chat_TellToSociety", "Tell to Society Chat", ChatChannelKind.Society), - ("ID_Chat_TellToMonarch", "Tell to Monarch", ChatChannelKind.Monarch), - ("ID_Chat_TellToPatron", "Tell to Patron", ChatChannelKind.Patron), - ("ID_Chat_TellToVassals", "Tell to Vassals", ChatChannelKind.Vassals), - ("ID_Chat_TellToAllegiance", "Tell to Allegiance", ChatChannelKind.Allegiance), - ("ID_Chat_TellToTrade", "Tell to Trade Chat", ChatChannelKind.Trade), - ("ID_Chat_TellToRoleplay", "Tell to Roleplay Chat", ChatChannelKind.Roleplay), - ("ID_Chat_TellToOlthoi", "Tell to Olthoi Chat", ChatChannelKind.Olthoi), + ("Squelch (ignore)", null), + ("Tell to Selected", null), + ("Chat to All", ChatChannelKind.Say), + ("Tell to Fellows", ChatChannelKind.Fellowship), + ("Tell to General Chat", ChatChannelKind.General), + ("Tell to LFG Chat", ChatChannelKind.Lfg), + ("Tell to Society Chat", ChatChannelKind.Society), + ("Tell to Monarch", ChatChannelKind.Monarch), + ("Tell to Patron", ChatChannelKind.Patron), + ("Tell to Vassals", ChatChannelKind.Vassals), + ("Tell to Allegiance", ChatChannelKind.Allegiance), + ("Tell to Trade Chat", ChatChannelKind.Trade), + ("Tell to Roleplay Chat", ChatChannelKind.Roleplay), + ("Tell to Olthoi Chat", ChatChannelKind.Olthoi), }; - /// - /// The talk button's SHORT caption: gmMainChatUI::HandleSelection - /// @0x004cd540 sets m_pChatTargetButtonText from the per-target - /// ID_Chat_ChatTargetMenu* string (table 0x23000001) — authored values - /// 'Chat'/'Tell'/'Fell'/'Pat'/'Mon'/'Vas'/'Alg'/'Gen'/'Trade'/'LFG'/ - /// 'RP'/'Soc'/'Olt' (live-DAT probed 2026-08-24; the previous - /// hand-invented longs like "General"/"Fellow"/"Alleg" were the - /// owner-reported "says General not Gen" delta). - /// - private string ChannelButtonLabel(ChatChannelKind k) => k switch + private static string ChannelButtonLabel(ChatChannelKind k) => k switch { - ChatChannelKind.Say => S("ID_Chat_ChatTargetMenu", "Chat"), - ChatChannelKind.Tell => S("ID_Chat_ChatTargetMenuSelected", "Tell"), - ChatChannelKind.General => S("ID_Chat_ChatTargetMenuGeneral", "Gen"), - ChatChannelKind.Trade => S("ID_Chat_ChatTargetMenuTrade", "Trade"), - ChatChannelKind.Lfg => S("ID_Chat_ChatTargetMenuLFG", "LFG"), - ChatChannelKind.Fellowship => S("ID_Chat_ChatTargetMenuFellows", "Fell"), - ChatChannelKind.Allegiance => S("ID_Chat_ChatTargetMenuAllegiance", "Alg"), - ChatChannelKind.Patron => S("ID_Chat_ChatTargetMenuPatron", "Pat"), - ChatChannelKind.Vassals => S("ID_Chat_ChatTargetMenuVassals", "Vas"), - ChatChannelKind.Monarch => S("ID_Chat_ChatTargetMenuMonarch", "Mon"), - ChatChannelKind.Roleplay => S("ID_Chat_ChatTargetMenuRoleplay", "RP"), - ChatChannelKind.Society => S("ID_Chat_ChatTargetMenuSociety", "Soc"), - ChatChannelKind.Olthoi => S("ID_Chat_ChatTargetMenuOlthoi", "Olt"), - _ => S("ID_Chat_ChatTargetMenu", "Chat"), + ChatChannelKind.Say => "Chat", + ChatChannelKind.Tell => "Tell", + ChatChannelKind.General => "General", + ChatChannelKind.Trade => "Trade", + ChatChannelKind.Lfg => "LFG", + ChatChannelKind.Fellowship => "Fellow", + ChatChannelKind.Allegiance => "Alleg", + ChatChannelKind.Patron => "Patron", + ChatChannelKind.Vassals => "Vassals", + ChatChannelKind.Monarch => "Monarch", + ChatChannelKind.Roleplay => "Roleplay", + ChatChannelKind.Society => "Society", + ChatChannelKind.Olthoi => "Olthoi", + _ => "Chat", }; private static bool ChannelAvailable(ChatChannelKind k) @@ -302,9 +278,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta UiDatFont? datFont, BitmapFont? debugFont, Func resolve, - Func? selectedTargetName = null, - Func? chatStrings = null, - Func? resolveFont = null) + Func? selectedTargetName = null) { ArgumentNullException.ThrowIfNull(windowFilters); @@ -333,7 +307,6 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta Root = window, DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo, _windowFilters = windowFilters, - _chatStrings = chatStrings, }; // Seed the unlocked skin until the common registered-window presenter @@ -481,24 +454,6 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta if (layout.FindElement(MenuId) is UiMenu menu) { menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve; - // The authored label child (0x10000015) carries the button - // caption's OWN color and font: pure white, FontDid 0x40000002 - // (live-DAT probed 2026-08-24) — not the transcript font and not - // the invented warm gold. Falls back to the previous defaults - // when the fixture lacks the child (hermetic tests). - if (FindInfo(rootInfo, MenuLabelId) is { } labelInfo) - { - if (labelInfo.FontColor is { } authoredColor) - menu.TextColor = authoredColor; - if (labelInfo.FontDid != 0u - && resolveFont?.Invoke(labelInfo.FontDid) is { } buttonFont) - menu.ButtonDatFont = buttonFont; - // The child spans the FULL 46x17 with H=Center (probed) — - // retail centers 'Chat'/'Gen' in the whole face, not at a - // fixed left indent (owner screenshot pair, 2026-08-24). - menu.ButtonTextCentered = - labelInfo.HJustify == HJustify.Center; - } menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed; menu.PopupBgSprite = MenuPopupBg; menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected; @@ -512,17 +467,19 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta var items = new List(ChannelItems.Length) { new(target is null - ? c.S("ID_Chat_SquelchSelectedNoSelection", - "Squelch (ignore) Selected") - : c.S("ID_Chat_SquelchSelected", "Squelch (ignore) ") + target, + ? "Squelch (ignore)" + : $"Squelch (ignore) {target}", TalkFocusSpecial.Squelch), new(target is null - ? c.S("ID_Chat_TellToSelectedNoSelection", "Tell to Selected") - : c.S("ID_Chat_TellToSelected", "Tell to ") + target, + ? "Tell to Selected" + : $"Tell to {target}", TalkFocusSpecial.TellToSelected), }; - foreach ((string key, string fallback, ChatChannelKind ch) in ChannelItems) - items.Add(new UiMenu.MenuItem(c.S(key, fallback), ch)); + foreach ((string label, ChatChannelKind? channel) in ChannelItems) + { + if (channel is { } ch) + items.Add(new UiMenu.MenuItem(label, ch)); + } menu.Items = items.ToArray(); } @@ -540,7 +497,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta }; // The button names the FOCUS, not the target: retail shows "Tell", // not the person's name. - menu.ButtonLabelProvider = () => c.ChannelButtonLabel(c._activeChannel); + menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel); menu.OnOpen = RebuildItems; menu.OnSelect = p => { @@ -580,28 +537,33 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta if (layout.FindElement(SendId) is UiButton sendEl) { sendEl.OnClick = () => c.Input.Submit(); - // The Send sprite is a blank gold button — retail draws the caption - // as text with the element's OWN authored style: pure white, - // FontDid 0x40000002 (live-DAT probed 2026-08-24; the previous - // warm gold was invented). + // The Send sprite is a blank gold button — retail draws the caption as text. sendEl.Label = "Send"; - ElementInfo? sendInfo = FindInfo(rootInfo, SendId); - sendEl.LabelFont = - (sendInfo?.FontDid is { } sendFontDid and not 0u - ? resolveFont?.Invoke(sendFontDid) - : null) ?? datFont; - sendEl.LabelColor = sendInfo?.FontColor ?? new Vector4(1f, 1f, 1f, 1f); + sendEl.LabelFont = datFont; + sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f); } // ── Size the channel button to its label + reflow the input field ─ // Retail's talk-focus button autosizes to the selected channel name; the input - // 2026-08-24: the previous content-widening reflow here (grow the - // button to its label, shift the input) was a compensation for the - // invented LONG captions ("General"). Retail keeps the authored - // 46x17 button — the authored SHORT captions ('Gen', font - // 0x40000002) fit it, which is exactly why retail abbreviates - // (gmMainChatUI::HandleSelection @0x004cd540 never resizes the - // element). The authored row layout stands untouched. + // field then fills the gap from the button's right edge to the Send button. The + // dat authors the button at a fixed 46px (too narrow for "Chat" once the LED + + // arrow are accounted for), so widen it to its content and shift the input. + // Recompute on every channel change (the button grows/shrinks with the label). + if (c.Menu is not null) + { + float inputRight = c.Input.Left + c.Input.Width; // == Send button's left edge + void ReflowInputRow() + { + c.Menu.Width = System.MathF.Round(c.Menu.NaturalButtonWidth()); + c.Menu.ResetAnchorCapture(); + c.Input.Left = c.Menu.Left + c.Menu.Width; + c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left); + c.Input.ResetAnchorCapture(); + } + var onSelect = c.Menu.OnSelect; + c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); }; + ReflowInputRow(); + } // ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ── // The dat already authors max/min (368,5,16,16) just left of the scrollbar @@ -1030,44 +992,6 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta FindRootOf(Input)?.SetKeyboardFocus(Input); } - /// - /// Retail EnterChatMode: enter write mode and select the complete - /// existing entry so the next typed character replaces it. - /// - internal void EnterChatMode(KeyChord? physicalChord = null) - { - UiRoot? root = FindRootOf(Input); - root?.SetKeyboardFocus(Input); - if (physicalChord is { Device: 0 } chord) - root?.SuppressPhysicalKeyUntilRelease(chord.Key); - Input.SelectAllText(); - } - - /// Retail ToggleChatEntry: toggle write-mode focus. - internal void ToggleChatEntry(KeyChord? physicalChord = null) - { - UiRoot? root = FindRootOf(Input); - if (root is null) - return; - root.SetKeyboardFocus(ReferenceEquals(root.KeyboardFocus, Input) ? null : Input); - if (physicalChord is { Device: 0 } chord) - root.SuppressPhysicalKeyUntilRelease(chord.Key); - } - - /// Retail command/alias hotkey: begin an ordinary slash command. - internal void StartCommand() - { - Input.SetText("/"); - FindRootOf(Input)?.SetKeyboardFocus(Input); - } - - /// Retail reply keys are silent when their independent target is empty. - internal void StartReply(string? name) - { - if (!string.IsNullOrEmpty(name)) - StartTell(name); - } - private static UiRoot? FindRootOf(UiElement element) { for (UiElement? at = element; at is not null; at = at.Parent) diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs index be320d65..10d2f26f 100644 --- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs @@ -272,11 +272,8 @@ public static class ConfigOptionsPageController public const uint ScrollThumbTop = 0x06004C60u; public const uint ScrollThumb = 0x06004C63u; public const uint ScrollThumbBottom = 0x06004C66u; - // Retail seating (UpdateScrollingArea @0x00470AA0): the top slot - // takes the INCREMENT designee's UP-arrow art, the bottom the - // DECREMENT designee's DOWN-arrow (see RetailScrollbarChrome). - public const uint ScrollUp = RetailScrollbarChrome.UpNormal; - public const uint ScrollDown = RetailScrollbarChrome.DownNormal; + public const uint ScrollUp = 0x06004C69u; + public const uint ScrollDown = 0x06004C6Cu; } /// Applies + geometry to a diff --git a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs index ae34464e..c150ee3d 100644 --- a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs +++ b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs @@ -3,8 +3,6 @@ using System.Numerics; using AcDream.Content; using AcDream.Core.Items; using AcDream.Core.Net.Messages; -using AcDream.Core.Properties; -using AcDream.Core.Ui; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -50,49 +48,6 @@ public static class CreatureAppraisalRows private const uint DotResistRating = 0x15Eu; private const uint LifeResistRating = 0x15Fu; - // ── Campaign AS slice AS4: society/allegiance/fellowship + configurable - // extras (G6/G7/G8). Ground truth: docs/research/2026-08-25-campaign-as- - // ground-truth.md §2b rows 1-2 and 8-14. Decomp anchors: CharExamineUI:: - // SetAppraiseInfo @0x004B45F0 — Society @0x004b49a1-0x004b4c24 (color - // rule sourced from the LOCAL player's own Faction1Bits, fetched a few - // lines earlier at @0x004b4971-0x004b4984 via CBaseQualities::InqInt); - // Monarch/Patron/Followers @0x004b4d97-0x004b4f54; configurable extras - // Fellowship @0x004b58be, Arrived in Dereth @0x004b593a, Time in Dereth - // @0x004b59bc, Chess Rank @0x004b5a5b, Fishing Skill @0x004b5afa, Deaths - // @0x004b5b96, Titles Earned @0x004b5c4d — all CHARACTER-path only; the - // monster path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) ends - // after its own ratings trailing spacer and never reaches any of this. - private const uint Faction1BitsProperty = (uint)PropertyInt.Faction1Bits; // 281 - private const uint SocietyRankCelestialHandProperty = - (uint)PropertyInt.SocietyRankCelhan; // 287 - private const uint SocietyRankEldrytchWebProperty = - (uint)PropertyInt.SocietyRankEldweb; // 288 - private const uint SocietyRankRadiantBloodProperty = - (uint)PropertyInt.SocietyRankRadblo; // 289 - private const int CelestialHandBit = 0x1; - private const int EldrytchWebBit = 0x2; - private const int RadiantBloodBit = 0x4; - private const int SocietyBitsMask = - CelestialHandBit | EldrytchWebBit | RadiantBloodBit; - - private const uint AllegianceRankProperty = (uint)PropertyInt.AllegianceRank; // 30 - private const uint AllegianceFollowersProperty = - (uint)PropertyInt.AllegianceFollowers; // 35 (Int table) - private const uint MonarchsTitleProperty = - (uint)PropertyString.MonarchsTitle; // 21 (String table) - private const uint PatronsTitleProperty = - (uint)PropertyString.PatronsTitle; // 35 (String table) - - private const uint FellowshipProperty = (uint)PropertyString.Fellowship; // 10 - private const uint DateOfBirthProperty = (uint)PropertyString.DateOfBirth; // 43 - private const uint AgeProperty = (uint)PropertyInt.Age; // 125 - private const uint ChessRankProperty = (uint)PropertyInt.ChessRank; // 181 - private const uint FishingSkillProperty = - (uint)PropertyInt.FakeFishingSkill; // 192 - private const uint NumDeathsProperty = (uint)PropertyInt.NumDeaths; // 43 (Int table) - private const uint NumCharacterTitlesProperty = - (uint)PropertyInt.NumCharacterTitles; // 262 - public static IReadOnlyList Build( AppraiseInfoParser.CreatureProfile profile, bool success) @@ -133,101 +88,17 @@ public static class CreatureAppraisalRows } /// - /// Armor-level unenchantable sentinel. Retail formats each per-part value - /// as "%d" (data_794344) below this threshold and - /// "*%d" (data_7b110c) with value - 9999 at/above it - /// — see e.g. CharExamineUI::SetAppraiseInfo - /// @0x004B5086-@0x004B50A5 (the head part) and every - /// subsequent part in the trio. + /// Port of CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0's + /// separate miscellaneous list. Retail reads all nine rating properties, + /// emits a leading and trailing blank row when any display group exists, + /// and uses Crit/CritResist only to decide whether their paired row exists. + /// HealingBoost is read but not displayed by this retail build. /// - private const int UnenchantableArmorLevel = 9999; - - /// - /// Port of the FULL extras list from - /// CharExamineUI::SetAppraiseInfo @ 0x004B45F0 (player path) and - /// CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0 (monster path, - /// ratings only — no society, no allegiance, no armor-level trio, no - /// configurable extras, no unenchantable legend; the monster function - /// ends immediately after its own trailing ratings spacer and never - /// touches any of that). Ground truth: - /// docs/research/2026-08-25-campaign-as-ground-truth.md §2b (the - /// complete row table), rulings R3 (legend unconditional) and R4 (spacer - /// discipline). - /// - /// Row order (character path only): Society (gap G6) → Monarch/Patron/ - /// Followers (gap G7) → armor-level trio → ratings block → configurable - /// extras: Fellowship/Arrived in Dereth/Time in Dereth/Chess Rank/ - /// Fishing Skill/Deaths/Titles Earned (gap G8) → the - /// * = Unenchantable legend, ALWAYS the last row - /// (@0x004b5d7d-@0x004b5ded). No separators exist between the - /// configurable-extra rows in the decomp — only the ratings family gets - /// the leading/trailing spacer treatment described below. - /// - /// - /// Rating gating/spacer logic: retail's CharExamineUI decompile - /// mangles its leading-spacer flag (ebx_13) into unreadable - /// call-argument artifacts — a known BN-decompiler loss (see - /// feedback_bn_decomp_field_names.md). Ruling R4 directs using - /// CreatureExamineUI's clean version of the identical algorithm - /// instead, which is what this method implements: one leading blank row - /// before the FIRST ratings-family row that fires, one trailing blank - /// row if ANY ratings-family row fired. Each of the three rating rows is - /// gated independently (307|313|314, 308|315|316, 350|351); 313/315 only - /// gate — their values (Crit/CritResist) are never displayed. - /// HealingBoost (Int 323) is read by retail and never rendered (ruling - /// R2) — discarded here too. - /// - /// - /// The response's parsed property tables. - /// - /// Parsed ArmorLevels blob (Success-only per ACE), or - /// when the response didn't carry one. - /// - /// - /// for the CharExamineUI (player) path, - /// which alone emits society/allegiance, the armor-level trio, the - /// configurable extras, and the trailing "* = Unenchantable" legend; - /// for the CreatureExamineUI (monster) - /// path, which never emits any of them. - /// - /// - /// The LOCAL player's own Faction1Bits (PropertyInt 281), fetched by - /// retail via CBaseQualities::InqInt on the local qualities - /// object a few lines before the Society row builds - /// (@0x004b4971-@0x004b4984). The composer stays pure — callers source - /// this from whatever seam exposes the local player's live properties - /// (see 's binding). Defaults to 0 - /// (factionless) for callers — mostly monster-path or pre-AS4 tests — - /// that never touch the Society row. - /// public static IReadOnlyList BuildExtra( - PropertyBundle properties, - AppraiseInfoParser.ArmorLevel? armorLevels, - bool character, - int localFactionBits = 0) + PropertyBundle properties) { ArgumentNullException.ThrowIfNull(properties); - var rows = new List(); - - if (character) - { - AddSocietyRow(rows, properties, localFactionBits); - if (Get(properties, AllegianceRankProperty) >= 1) - AddAllegianceCascade(rows, properties); - } - - if (character && armorLevels is { } levels && HasAnyArmorLevel(levels)) - { - rows.Add(Blank()); - rows.Add(ArmorLevelRow( - "Head/Chest/Groin", levels.Head, levels.Chest, levels.Abdomen)); - rows.Add(ArmorLevelRow( - "Bicep/Wrist/Hand", levels.UpperArm, levels.LowerArm, levels.Hand)); - rows.Add(ArmorLevelRow( - "Thigh/Shin/Foot", levels.UpperLeg, levels.LowerLeg, levels.Foot)); - } - int damage = Get(properties, DamageRating); int damageResist = Get(properties, DamageResistRating); int crit = Get(properties, CritRating); @@ -242,279 +113,38 @@ public static class CreatureAppraisalRows bool showResist = damageResist > 0 || critResist > 0 || critDamageResist > 0; bool showDotLife = dotResist > 0 || lifeResist > 0; - if (showRating || showResist || showDotLife) - { - rows.Add(Blank()); - if (showRating) - { - rows.Add(new CreatureAppraisalRow( - "Dmg/CritDmg", - $"Rating: {Number(damage)}/{Number(critDamage)}", - CreatureAppraisalValueStyle.Normal)); - } - if (showResist) - { - rows.Add(new CreatureAppraisalRow( - "Dmg/CritDmg", - $"Resist: {Number(damageResist)}/{Number(critDamageResist)}", - CreatureAppraisalValueStyle.Normal)); - } - if (showDotLife) - { - rows.Add(new CreatureAppraisalRow( - "DoT/Life:", - $"Resist: {Number(dotResist)}/{Number(lifeResist)}", - CreatureAppraisalValueStyle.Normal)); - } - rows.Add(Blank()); - } + if (!showRating && !showResist && !showDotLife) + return Array.Empty(); - if (character) + var rows = new List(5) + { + Blank(), + }; + if (showRating) { - AddConfigurableExtras(rows, properties); - rows.Add(new CreatureAppraisalRow( - "* = Unenchantable", - string.Empty, + "Dmg/CritDmg", + $"Rating: {Number(damage)}/{Number(critDamage)}", CreatureAppraisalValueStyle.Normal)); } - + if (showResist) + { + rows.Add(new CreatureAppraisalRow( + "Dmg/CritDmg", + $"Resist: {Number(damageResist)}/{Number(critDamageResist)}", + CreatureAppraisalValueStyle.Normal)); + } + if (showDotLife) + { + rows.Add(new CreatureAppraisalRow( + "DoT/Life:", + $"Resist: {Number(dotResist)}/{Number(lifeResist)}", + CreatureAppraisalValueStyle.Normal)); + } + rows.Add(Blank()); return rows; } - /// - /// Port of the Society row @0x004b49a1-@0x004b4c24 (gap G6). Gated on - /// PropertyInt 281 Faction1Bits being PRESENT on the response (matches - /// AppraisalProfile::InqInt's found/not-found return, not a - /// value-nonzero test — a slightly more literal reading than the ground - /// truth doc's informal "≠ 0" phrasing). Bit-priority order (Celestial - /// Hand 0x1, then Eldrytch Web 0x2, then — ONLY when neither of those - /// AND Radiant Blood's 0x4 bit is also clear — the unrecognized - /// "???" fallback (data_7af4e8), else Radiant Blood) comes - /// straight from the decompiled if/else-if chain; a target with more - /// than one bit set resolves to whichever bit the chain checks first. - /// The unrecognized branch never sets a color (retail's ebx_3 - /// stays at its zero initializer) — always - /// . - /// - private static void AddSocietyRow( - List rows, - PropertyBundle properties, - int localFactionBits) - { - if (!properties.Ints.TryGetValue(Faction1BitsProperty, out int targetBits)) - return; - - string name; - int rank; - int targetBit; - if ((targetBits & CelestialHandBit) != 0) - { - name = "Celestial Hand"; - rank = Get(properties, SocietyRankCelestialHandProperty); - targetBit = CelestialHandBit; - } - else if ((targetBits & EldrytchWebBit) != 0) - { - name = "Eldrytch Web"; - rank = Get(properties, SocietyRankEldrytchWebProperty); - targetBit = EldrytchWebBit; - } - else if ((targetBits & RadiantBloodBit) == 0) - { - rows.Add(new CreatureAppraisalRow( - "Society:", Unknown, CreatureAppraisalValueStyle.Normal)); - return; - } - else - { - name = "Radiant Blood"; - rank = Get(properties, SocietyRankRadiantBloodProperty); - targetBit = RadiantBloodBit; - } - - rows.Add(new CreatureAppraisalRow( - "Society:", - name + SocietyRankSuffix(rank), - SocietyColor(targetBit, localFactionBits))); - } - - /// - /// Rank-band suffix bounds @0x004b4ab9-@0x004b4b92 — five bands, each - /// inclusive on both ends; outside every band (including rank 0, the - /// value seen when InqInt on the rank property fails) the society - /// name stands alone with no suffix. - /// - private static string SocietyRankSuffix(int rank) => rank switch - { - >= 1 and <= 100 => " ~ Initiate", - >= 101 and <= 300 => " ~ Adept", - >= 301 and <= 600 => " ~ Knight", - >= 601 and <= 1000 => " ~ Lord", - >= 1001 and <= 1500 => " ~ Master", - _ => string.Empty, - }; - - /// - /// The Society color rule: green when the local player shares the - /// TARGET's selected bit (checked first, so a local player with - /// multiple bits set still resolves green if one of them matches); - /// red when the local player has neither of the target's bit but has - /// at least one of the other two society bits; normal (no color) when - /// the local player has no society bits at all. Same - /// InqInt-into-CBaseQualities read for all three branches - /// @0x004b49fd/@0x004b4a49/@0x004b4a8b. - /// - private static CreatureAppraisalValueStyle SocietyColor( - int targetBit, int localFactionBits) - { - if ((localFactionBits & targetBit) != 0) - return CreatureAppraisalValueStyle.Positive; - if ((localFactionBits & (SocietyBitsMask & ~targetBit)) != 0) - return CreatureAppraisalValueStyle.Negative; - return CreatureAppraisalValueStyle.Normal; - } - - /// - /// Port of the Monarch/Patron/Followers cascade @0x004b4d97-@0x004b4f54 - /// (gap G7), gated by the caller on AllegianceRank >= 1 (the SAME - /// var_108 InqInt read retail's header AllegianceName binding - /// already consumes — see ). Four arms, tested in retail's exact - /// order: MonarchsTitle (Str 21) absent → follower-count row; present + - /// PatronsTitle (Str 35) absent → Monarch-only row; both present and - /// EQUAL (ordinal — server-composed strings, no case-folding in the - /// decomp) → one combined row; both present and different → two rows. - /// - private static void AddAllegianceCascade( - List rows, PropertyBundle properties) - { - if (!properties.Strings.TryGetValue( - MonarchsTitleProperty, out string? monarchsTitle)) - { - int followers = Get(properties, AllegianceFollowersProperty); - if (followers < 0) - followers = 0; - string unit = followers == 1 ? "Follower" : "Followers"; - rows.Add(new CreatureAppraisalRow( - "Alleg. Monarch:", - $"{Number(followers)} {unit}", - CreatureAppraisalValueStyle.Normal)); - return; - } - - if (!properties.Strings.TryGetValue( - PatronsTitleProperty, out string? patronsTitle)) - { - rows.Add(new CreatureAppraisalRow( - "Monarch:", monarchsTitle, CreatureAppraisalValueStyle.Normal)); - return; - } - - if (string.Equals(monarchsTitle, patronsTitle, StringComparison.Ordinal)) - { - rows.Add(new CreatureAppraisalRow( - "Monarch/Patron:", - monarchsTitle, - CreatureAppraisalValueStyle.Normal)); - return; - } - - rows.Add(new CreatureAppraisalRow( - "Monarch:", monarchsTitle, CreatureAppraisalValueStyle.Normal)); - rows.Add(new CreatureAppraisalRow( - "Patron:", patronsTitle, CreatureAppraisalValueStyle.Normal)); - } - - /// - /// Port of the seven configurable-extras rows (gap G8, ground truth §2b - /// rows 8-14): Fellowship @0x004b58be, Arrived in Dereth @0x004b593a, - /// Time in Dereth @0x004b59bc, Chess Rank @0x004b5a5b, Fishing Skill - /// @0x004b5afa, Deaths @0x004b5b96, Titles Earned @0x004b5c4d. Each row - /// is independently gated on its OWN property being present on the - /// response — ACE already strips these per the TARGET's own visibility - /// options before the response is sent - /// (AppraiseInfo.cs:352-366), so no client-side option logic or - /// success gating belongs here. No spacers separate these rows in the - /// decomp. Time in Dereth reuses the ALREADY-PORTED - /// — the exact same - /// ClientUISystem::DeltaTimeToString @0x00565E10 the decomp calls - /// here (@0x004b59e0) — rather than porting a duplicate. - /// - private static void AddConfigurableExtras( - List rows, PropertyBundle properties) - { - if (properties.Strings.TryGetValue( - FellowshipProperty, out string? fellowship)) - { - rows.Add(new CreatureAppraisalRow( - "Fellowship:", fellowship, CreatureAppraisalValueStyle.Normal)); - } - - if (properties.Strings.TryGetValue( - DateOfBirthProperty, out string? arrived)) - { - rows.Add(new CreatureAppraisalRow( - "Arrived in Dereth:", - arrived, - CreatureAppraisalValueStyle.Normal)); - } - - if (properties.Ints.TryGetValue(AgeProperty, out int ageSeconds)) - { - rows.Add(new CreatureAppraisalRow( - "Time in Dereth:", - RetailDurationText.Format(ageSeconds), - CreatureAppraisalValueStyle.Normal)); - } - - if (properties.Ints.TryGetValue(ChessRankProperty, out int chessRank)) - { - rows.Add(new CreatureAppraisalRow( - "Chess Rank:", Number(chessRank), CreatureAppraisalValueStyle.Normal)); - } - - if (properties.Ints.TryGetValue(FishingSkillProperty, out int fishingSkill)) - { - rows.Add(new CreatureAppraisalRow( - "Fishing Skill:", - Number(fishingSkill), - CreatureAppraisalValueStyle.Normal)); - } - - if (properties.Ints.TryGetValue(NumDeathsProperty, out int deaths)) - { - rows.Add(new CreatureAppraisalRow( - "Deaths:", - deaths <= 0 ? "Has never died" : Number(deaths), - CreatureAppraisalValueStyle.Normal)); - } - - if (properties.Ints.TryGetValue( - NumCharacterTitlesProperty, out int titles)) - { - rows.Add(new CreatureAppraisalRow( - "Titles Earned:", Number(titles), CreatureAppraisalValueStyle.Normal)); - } - } - - private static bool HasAnyArmorLevel(AppraiseInfoParser.ArmorLevel levels) - => levels.Head > 0 || levels.Chest > 0 || levels.Abdomen > 0 - || levels.UpperArm > 0 || levels.LowerArm > 0 || levels.Hand > 0 - || levels.UpperLeg > 0 || levels.LowerLeg > 0 || levels.Foot > 0; - - private static CreatureAppraisalRow ArmorLevelRow( - string label, int a, int b, int c) - => new( - label, - $"AL: {ArmorLevelPart(a)}/{ArmorLevelPart(b)}/{ArmorLevelPart(c)}", - CreatureAppraisalValueStyle.Normal); - - private static string ArmorLevelPart(int value) - => value >= UnenchantableArmorLevel - ? $"*{Number(value - UnenchantableArmorLevel)}" - : Number(value); - private static CreatureAppraisalRow Blank() => new(string.Empty, string.Empty, CreatureAppraisalValueStyle.Normal); diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 5091ec3f..bb12f3fb 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -115,7 +115,7 @@ public static class DatWidgetFactory // pre-OP2 UiDatElement fallback (media drawn, ClickThrough=true, state // propagation) because nothing ever activates it. 5 => new UiTemplateListBox(info, resolve, info.TemplateList, info.ScrollbarElementId), - 6 => BuildMenu(info, resolve, elementFont, fontResolve), // UIElement_Menu (reg :120163) + 6 => new UiMenu(), // UIElement_Menu (reg :120163) 7 => BuildMeter(info, resolve, elementFont, stringResolve), // UIElement_Meter // UIElement_Panel (Type 8) — retail's tab-strip host (dat property 0x2E; // research doc §1.3/§10.1). OP2 rework (docs/research/2026-08-11-op2- @@ -133,7 +133,6 @@ public static class DatWidgetFactory 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137) 12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text 0x13 => new UiDialogRoot(), // ConfirmationDialog - 0x14 => new UiDialogRoot(), // ConfirmationMenuDialog 0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog 0x17 => new UiDialogRoot(), // MessageDialog 0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396) @@ -164,7 +163,7 @@ public static class DatWidgetFactory // ArrowCapClosedSprite doc comment). Built blank, exactly like the Type-6 // case above — a page controller wires its sprites/items the same way // ChatWindowController wires the channel menu. - 0x10000038u => BuildMenu(info, resolve, elementFont, fontResolve), + 0x10000038u => new UiMenu(), // UIOption_CheckboxBitfield64 (Type 0x10000044): the Chat tab's per-window // text-filter block. OP2 rework (docs/research/2026-08-11-op2-review- // mechanism.md MUST-FIX 4): the authored template (0x10000520) DOES author @@ -210,42 +209,6 @@ public static class DatWidgetFactory return e; } - /// - /// Retail's generic menu class supplies its standard face/popup chrome even - /// when no game-specific controller customizes it. This matters for catalog - /// dialogs such as ConfirmationMenu: their Type-6 leaf is the whole control, - /// and intentionally absorbs the - /// authored label child. Existing chat/vendor/options controllers overwrite - /// these defaults with their own probed variants. - /// - private static UiMenu BuildMenu( - ElementInfo info, - Func resolve, - UiDatFont? elementFont, - Func? fontResolve) - { - ElementInfo? label = info.Children.FirstOrDefault( - static child => child.Type == 12u); - UiDatFont? labelFont = label is { FontDid: not 0u } && fontResolve is not null - ? fontResolve(label.FontDid) ?? elementFont - : elementFont; - var menu = new UiMenu - { - SpriteResolve = resolve, - DatFont = labelFont, - ButtonDatFont = labelFont, - NormalSprite = 0x06004D65u, - PressedSprite = 0x06004D66u, - PopupBgSprite = 0x0600124Cu, - ItemNormalSprite = 0x0600124Eu, - ItemHighlightSprite = 0x0600124Du, - ButtonTextCentered = label?.HJustify == HJustify.Center, - }; - if (label?.FontColor is { } color) - menu.TextColor = color; - return menu; - } - /// /// Bind inherited scrollbar media structurally. Property 0x77 names the /// increment button and 0x78 the decrement button; retail @@ -269,28 +232,16 @@ public static class DatWidgetFactory uint decrementId = ReferencedElementId(info, 0x78u); ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId); ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId); - - // Seat by DESIGNATION, not authored position: retail's - // UpdateScrollingArea @0x00470AA0 moves the increment designee to the - // top/left corner (MoveTo(0,0)) and the decrement designee to the - // bottom/right corner regardless of where the layout drew them. The - // vertical base skin authors the DOWN-arrow (0x10000071) at Y=0 and - // the UP-arrow (0x10000072) at Y=32, so the previous authored-Y - // ordering rendered both arrows upside down (2026-08-24 owner - // report). Fall back to authored order only when the designations - // are missing. - ElementInfo? leadingButton = increment; - ElementInfo? trailingButton = decrement; - if (leadingButton is null && trailingButton is null) - { - ElementInfo[] typeOneChildren = info.Children - .Where(child => child.Type == 1u && child.Id != 1u) - .OrderBy(child => bar.Horizontal ? child.X : child.Y) - .ThenBy(child => child.ReadOrder) - .ToArray(); - leadingButton = typeOneChildren.FirstOrDefault(); - trailingButton = typeOneChildren.Length > 1 ? typeOneChildren[^1] : null; - } + ElementInfo? leadingButton = new[] { increment, decrement } + .Where(child => child is not null) + .OrderBy(child => bar.Horizontal ? child!.X : child!.Y) + .ThenBy(child => child!.ReadOrder) + .FirstOrDefault(); + ElementInfo? trailingButton = new[] { increment, decrement } + .Where(child => child is not null) + .OrderByDescending(child => bar.Horizontal ? child!.X : child!.Y) + .ThenByDescending(child => child!.ReadOrder) + .FirstOrDefault(); bar.UpSprite = ButtonStateImage(leadingButton, "Normal"); bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover"); bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed"); @@ -372,24 +323,9 @@ public static class DatWidgetFactory .OrderBy(child => child.Y) .ThenBy(child => child.ReadOrder) .ToArray(); - if (slices.Length > 0) - { - bar.ThumbTopSprite = ButtonStateImage(slices[0], "Normal"); - bar.ThumbTopRolloverSprite = ButtonStateImage(slices[0], "Normal_rollover"); - bar.ThumbTopPressedSprite = ButtonStateImage(slices[0], "Normal_pressed"); - } - if (slices.Length > 1) - { - bar.ThumbSprite = ButtonStateImage(slices[1], "Normal"); - bar.ThumbRolloverSprite = ButtonStateImage(slices[1], "Normal_rollover"); - bar.ThumbPressedSprite = ButtonStateImage(slices[1], "Normal_pressed"); - } - if (slices.Length > 2) - { - bar.ThumbBotSprite = ButtonStateImage(slices[^1], "Normal"); - bar.ThumbBotRolloverSprite = ButtonStateImage(slices[^1], "Normal_rollover"); - bar.ThumbBotPressedSprite = ButtonStateImage(slices[^1], "Normal_pressed"); - } + if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]); + if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]); + if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]); // R3-4/R3-7 (Campaign CC gate round 1 re-test 2): retail authors // TWO distinct thumb shapes for UIElement_Scrollbar (Type 11) — @@ -414,11 +350,7 @@ public static class DatWidgetFactory // slice children (chat) is unaffected since `slices.Length == 0` // is false for that shape. if (slices.Length == 0) - { - bar.ThumbSprite = ButtonStateImage(thumb, "Normal"); - bar.ThumbRolloverSprite = ButtonStateImage(thumb, "Normal_rollover"); - bar.ThumbPressedSprite = ButtonStateImage(thumb, "Normal_pressed"); - } + bar.ThumbSprite = DefaultImage(thumb); } return bar; @@ -859,32 +791,6 @@ public static class DatWidgetFactory field.TextColor = info.FontColor.Value; if (info.OutlineColor.HasValue) field.OutlineColor = info.OutlineColor.Value; - - // The chat input's authored focus rails (children 0x10000017/18, - // Type 3, media only for Normal_focussed — live-DAT probed - // 2026-08-24): UiField consumes its DAT children, so fold the - // rails into the field and let it draw them while focused (the - // owner-reported missing gold separator next to the channel - // button). Left/right assignment follows the authored X within - // the field, matching each rail's own edge anchoring. - foreach (ElementInfo child in info.Children) - { - if (child.Type != 3u - || !child.StateMedia.TryGetValue("Normal_focussed", out var railMedia) - || railMedia.File == 0u) - continue; - bool leftAnchored = child.X < info.Width * 0.5f; - if (leftAnchored) - { - field.FocusRailLeftSprite = railMedia.File; - if (child.Width > 0f) field.FocusRailLeftWidth = child.Width; - } - else - { - field.FocusRailRightSprite = railMedia.File; - if (child.Width > 0f) field.FocusRailRightWidth = child.Width; - } - } return field; } diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs index 48e92883..db66e5ed 100644 --- a/src/AcDream.App/UI/Layout/ExternalContainerController.cs +++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs @@ -43,7 +43,6 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine private readonly UiItemList _contentsList; private uint _openContainer; - private PendingBackpackPlacement? _pendingPlacement; private bool _closeRequested; private bool _disposed; @@ -99,10 +98,13 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine scrollbar.SpriteResolve ??= _contentsList.SpriteResolve; // Horizontal base LayoutDesc 0x2100003E media. The compatibility // factory treats all horizontal bars as scalar controls; this panel - // binds the authored model sprites explicitly — full state set + - // retail seating from RetailScrollbarChrome (increment/left = - // 0x06004C8C, decrement/right = 0x06004C89). - RetailScrollbarChrome.ApplyHorizontal(scrollbar); + // binds the authored model sprites explicitly. + scrollbar.TrackSprite = 0x06004C7Fu; + scrollbar.ThumbTopSprite = 0x06004C80u; + scrollbar.ThumbSprite = 0x06004C83u; + scrollbar.ThumbBotSprite = 0x06004C86u; + scrollbar.DownSprite = 0x06004C89u; + scrollbar.UpSprite = 0x06004C8Cu; } BindClose(layout, RequestClose); @@ -116,9 +118,6 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine _objects.Cleared += OnObjectsCleared; _selection.Changed += OnSelectionChanged; _itemInteraction.StateChanged += OnInteractionStateChanged; - _itemInteraction.PendingBackpackPlacementRequested += OnPendingPlacementRequested; - _itemInteraction.PendingBackpackPlacementCancelled += OnPendingPlacementCancelled; - _itemInteraction.PendingBackpackPlacementResolved += OnPendingPlacementResolved; ClearLists(); } @@ -214,14 +213,13 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine UiItemSlot targetCell, ItemDragPayload payload) { - if (payload.SourceKind == ItemDragSource.ShortcutBar) - return ItemDragAcceptance.None; if (!ReferenceEquals(targetList, _contentsList) - || _openContainer == 0u) + || payload.SourceKind == ItemDragSource.ShortcutBar + || payload.ObjId == 0u + || _openContainer == 0u + || payload.ObjId == _openContainer) return ItemDragAcceptance.Reject; - return EvaluateDrop(payload.ObjId) == InventoryContainerPlacementRejection.None - ? ItemDragAcceptance.Accept - : ItemDragAcceptance.Reject; + return ItemDragAcceptance.Accept; } public void HandleDropRelease( @@ -229,19 +227,8 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine UiItemSlot targetCell, ItemDragPayload payload) { - InventoryContainerPlacementRejection legality = EvaluateDrop(payload.ObjId); - if (legality != InventoryContainerPlacementRejection.None) - { - if (InventoryContainerPlacementPolicy.ComposeClientLocal( - legality, - _objects.Get(payload.ObjId), - _objects.Get(_openContainer), - playerId: 0u) is { } refusal) - { - _itemInteraction.ReportClientLocal(refusal); - } + if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept) return; - } if (!_itemInteraction.EnsureInventoryRequestReady()) return; if (_objects.Get(payload.ObjId) is not { } item) @@ -262,30 +249,17 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine InventoryRequestKind kind = amount < fullStack ? InventoryRequestKind.SplitToContainer : InventoryRequestKind.PutInContainer; - if (amount < fullStack) - { - _itemInteraction.TryDispatchInventoryRequest( - kind, - item.ObjectId, - () => - { + _itemInteraction.TryDispatchInventoryRequest( + kind, + item.ObjectId, + () => + { + if (amount < fullStack) _sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount); - return true; - }); - } - else - { - _itemInteraction.TryDispatchPendingBackpackPlacement( - item.ObjectId, - _openContainer, - placement, - kind, - () => - { + else _sendPutItemInContainer(item.ObjectId, _openContainer, placement); - return true; - }); - } + return true; + }); } private void OnExternalContainerChanged(ExternalContainerTransition transition) @@ -343,29 +317,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine AddContainerCell(guid); } - var visibleContents = new List(); foreach (uint guid in _objects.GetContents(_openContainer)) { if (!IsContainer(_objects.Get(guid))) - visibleContents.Add(guid); - } - if (_pendingPlacement is { } pending - && pending.ContainerId == _openContainer - && _objects.Get(pending.ItemId) is { } pendingItem - && !IsContainer(pendingItem)) - { - visibleContents.Remove(pending.ItemId); - visibleContents.Insert( - Math.Clamp(pending.Placement, 0, visibleContents.Count), - pending.ItemId); - } - foreach (uint guid in visibleContents) - { - bool waiting = _itemInteraction.IsPendingInventorySource(guid) - || _pendingPlacement is { } projection - && projection.ContainerId == _openContainer - && projection.ItemId == guid; - AddContentsCell(guid, waiting); + AddContentsCell(guid); } ApplyIndicators(); } @@ -382,15 +337,15 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine private void AddContainerCell(uint guid) { UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground); + cell.Clicked = () => OpenNestedContainer(guid); SetCapacity(cell, guid); _containerList.AddItem(cell); } - private void AddContentsCell(uint guid, bool waiting = false) + private void AddContentsCell(uint guid) { UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground); cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid); - cell.SetWaitingState(waiting); cell.DragAcceptSprite = 0x060011F9u; cell.DragRejectSprite = 0x060011F8u; _contentsList.AddItem(cell); @@ -435,10 +390,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine { if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive) return true; - if (IsContainer(_objects.Get(guid)) && guid != _state.CurrentContainerId) - OpenNestedContainer(guid); - else - Select(guid); + Select(guid); return false; } @@ -457,8 +409,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine bool pendingSource = _itemInteraction.IsPendingSource(cell.ItemId); cell.Selected = cell.ItemId != 0u && cell.ItemId == _selection.SelectedObjectId - && !pendingSource - && !_itemInteraction.IsPendingInventorySource(cell.ItemId); + && !pendingSource; cell.IsOpenContainer = cell.ItemId != 0u && cell.ItemId == _openContainer; } } @@ -538,48 +489,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine } private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators(); - private void OnInteractionStateChanged() - { - if (_window.IsVisible) - Populate(); - else - ApplyIndicators(); - } - - private void OnPendingPlacementRequested(PendingBackpackPlacement pending) - { - if (pending.ContainerId != _openContainer) - return; - _pendingPlacement = pending; - if (_window.IsVisible) - Populate(); - } - - private void OnPendingPlacementCancelled(PendingBackpackPlacement pending) - => ResolvePendingPlacement(pending); - - private void OnPendingPlacementResolved(PendingBackpackPlacement pending) - => ResolvePendingPlacement(pending); - - private void ResolvePendingPlacement(PendingBackpackPlacement pending) - { - if (_pendingPlacement is not { } current || current.Token != pending.Token) - return; - _pendingPlacement = null; - if (_window.IsVisible) - Populate(); - } - - private InventoryContainerPlacementRejection EvaluateDrop(uint itemId) - { - if (_objects.Get(itemId) is { } source && IsContainer(source)) - return InventoryContainerPlacementRejection.ContainerCapacityFull; - return InventoryContainerPlacementPolicy.Evaluate( - _objects, - itemId, - _openContainer, - playerId: 0u); - } + private void OnInteractionStateChanged() => ApplyIndicators(); private static bool IsContainer(ClientObject? item) => item is not null @@ -666,9 +576,6 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine _objects.Cleared -= OnObjectsCleared; _selection.Changed -= OnSelectionChanged; _itemInteraction.StateChanged -= OnInteractionStateChanged; - _itemInteraction.PendingBackpackPlacementRequested -= OnPendingPlacementRequested; - _itemInteraction.PendingBackpackPlacementCancelled -= OnPendingPlacementCancelled; - _itemInteraction.PendingBackpackPlacementResolved -= OnPendingPlacementResolved; _topContainer.PrimaryItemPressed = null; _containerList.PrimaryItemPressed = null; _contentsList.PrimaryItemPressed = null; diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index e03a5fc5..7fb41022 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -116,8 +116,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _itemInteraction = itemInteraction; _stackSplitQuantity = stackSplitQuantity; _selection = selection ?? throw new ArgumentNullException(nameof(selection)); - if (_itemInteraction is not null) - _itemInteraction.MergeAttempted += OnMergeAttempted; WindowChromeController.BindCloseButton(layout, onClose); @@ -301,13 +299,14 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (containerId == EffectiveOpen() || containerId == _playerGuid()) Populate(); } - private void OnInteractionStateChanged() => Populate(); + private void OnInteractionStateChanged() => ApplyIndicators(); private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending) { if (_pendingListPlacement is not null || pending.ItemId == 0u - || pending.ContainerId == 0u - || _objects.Get(pending.ItemId) is null) + || pending.ContainerId != EffectiveOpen() + || _objects.Get(pending.ItemId) is not { } item + || IsBag(item)) { return; } @@ -376,33 +375,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo // Side-bag column: ALWAYS the player's bags (constant across container switches; only the // open/selected indicators move). Equipped items never appear here. - var visibleBags = new List(); foreach (var guid in _objects.GetContents(p)) { var item = _objects.Get(guid); if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; bool isBag = IsBag(item); - if (isBag) visibleBags.Add(guid); - } - - PendingListPlacement? pending = _pendingListPlacement; - if (pending is { } bagProjection - && bagProjection.ContainerId == p - && _objects.Get(bagProjection.ItemId) is { } pendingBag - && IsBag(pendingBag)) - { - visibleBags.Remove(bagProjection.ItemId); - int index = Math.Clamp(bagProjection.Placement, 0, visibleBags.Count); - visibleBags.Insert(index, bagProjection.ItemId); - } - - foreach (uint guid in visibleBags) - { - bool waiting = IsWaitingSource(guid) - || pending is { } waitingBagProjection - && waitingBagProjection.ContainerId == p - && waitingBagProjection.ItemId == guid; - AddCell(_containerList, guid, isContainer: true, waiting); + if (isBag) AddCell(_containerList, guid, isContainer: true); } // Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has @@ -416,20 +394,20 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (!isBag) visibleContents.Add(guid); } + PendingListPlacement? pending = _pendingListPlacement; if (pending is { } projection && projection.ContainerId == open + && !visibleContents.Contains(projection.ItemId) && _objects.Get(projection.ItemId) is { } pendingItem && !IsBag(pendingItem)) { - visibleContents.Remove(projection.ItemId); int index = Math.Clamp(projection.Placement, 0, visibleContents.Count); visibleContents.Insert(index, projection.ItemId); } foreach (uint guid in visibleContents) { - bool waiting = IsWaitingSource(guid) - || pending is { } waitingProjection + bool waiting = pending is { } waitingProjection && waitingProjection.ContainerId == open && waitingProjection.ItemId == guid; AddCell(_contentsGrid, guid, isContainer: false, waiting); @@ -477,8 +455,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo dragIconTexture: _dragIconIds?.Invoke( ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u) ?? 0u); main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; - main.SetWaitingState(IsWaitingSource(p)); main.Clicked = () => OpenContainer(p); + main.DoubleClicked = () => _itemInteraction?.ActivateItem(p); SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity) _topContainer.AddItem(main); } @@ -496,21 +474,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo || item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; - private int CountLooseContents(uint containerId) - { - int count = 0; - foreach (uint guid in _objects.GetContents(containerId)) - { - if (_objects.Get(guid) is { } item - && item.CurrentlyEquippedLocation == EquipMask.None - && !IsBag(item)) - { - count++; - } - } - return count; - } - private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); /// The owned destination retail PlaceInBackpack currently uses. @@ -536,15 +499,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo cell.SetWaitingState(waiting); cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list) ConfigureDropFeedback(list, cell); + cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid); if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); } - else - { - cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid); - } list.AddItem(cell); } @@ -553,10 +513,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (_itemInteraction?.OfferPrimaryClick(guid) is not null and not ItemPrimaryClickResult.NotActive) return true; - if (_objects.Get(guid) is { } item && IsBag(item)) - OpenContainer(guid); - else - SelectItem(guid); + SelectItem(guid); return false; } @@ -565,7 +522,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (_itemInteraction?.OfferSelfPrimaryClick() is not null and not ItemPrimaryClickResult.NotActive) return true; - OpenContainer(guid); + SelectItem(guid); return false; } @@ -599,15 +556,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0; if (cap <= 0) { cell.CapacityFill = -1f; return; } - // Player contents contain two independent retail lists: loose items - // and side packs. ItemsCapacity applies only to the former; counting - // packs here made a main pack stay visually/full logically rejected - // even after the player freed an item slot. - int n = CountLooseContents(containerGuid); + int n = _objects.GetContents(containerGuid).Count; cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f); } - // ── IItemListDragHandler (B-Drag) — request first; server owns placement ──────────────────── + // ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ────────────── /// Retail ItemList_BeginDrag selects an unselected item before enabling its waiting /// mesh. Inventory items do not lift-remove (unlike the toolbar): the item stays in its slot /// until the server confirms the eventual drop. @@ -630,15 +583,35 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo // remove-on-lift stands. if (payload.SourceKind == ItemDragSource.ShortcutBar) return ItemDragAcceptance.None; - return EvaluateDrop(targetList, targetCell, payload.ObjId, out _, out _) - == InventoryContainerPlacementRejection.None - ? ItemDragAcceptance.Accept - : ItemDragAcceptance.Reject; + if (payload.ObjId == 0) + return ItemDragAcceptance.Reject; + bool sourceIsBag = _objects.Get(payload.ObjId) is { } source && IsBag(source); + if (targetList == _contentsGrid) + return sourceIsBag + ? ItemDragAcceptance.Reject + : ItemDragAcceptance.Accept; + if (targetList == _containerList || targetList == _topContainer) + { + // UIElement_ItemList::ItemList_DragOver @0x004E3400 checks the + // dragged object's container flag before interpreting this list. + // A container drag addresses the player's contained-container + // list itself; an empty authored slot is therefore a valid pack + // destination rather than "no target". + if (sourceIsBag) + return targetCell.ItemId == payload.ObjId + ? ItemDragAcceptance.Reject + : ItemDragAcceptance.Accept; + if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) + return ItemDragAcceptance.Reject; + return IsContainerFull(targetCell.ItemId) + ? ItemDragAcceptance.Reject + : ItemDragAcceptance.Accept; + } + return ItemDragAcceptance.Reject; } /// Resolve the destination and either split or move the stack. A partial split waits - /// for the server-created object's guid; a whole move displays only the - /// destination list's waiting projection until the server responds. Retail: + /// for the server-created object's guid; a whole move remains optimistic. Retail: /// ItemHolder::AttemptToPlaceInContainer @ 0x00588140. public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { @@ -654,24 +627,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo // DropReleased is still delivered to the list after a reject overlay; // pin the release to the same retail policy instead of relying on the // advisory color alone. - InventoryContainerPlacementRejection legality = EvaluateDrop( - targetList, - targetCell, - item, - out _, - out uint legalityDestination); - if (legality != InventoryContainerPlacementRejection.None) - { - if (InventoryContainerPlacementPolicy.ComposeClientLocal( - legality, - _objects.Get(item), - _objects.Get(legalityDestination), - _playerGuid()) is { } refusal) - { - _itemInteraction?.ReportClientLocal(refusal); - } + if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept) return; - } // UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every // release while m_pendingItem exists, before merge, split, or ordinary @@ -705,7 +662,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo container = EffectiveOpen(); placement = targetCell.ItemId != 0 ? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot - : CountLooseContents(container); // first empty = append after visible loose items + : _objects.GetContents(container).Count; // first empty = append } else if (targetList == _containerList || targetList == _topContainer) { @@ -740,65 +697,79 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { // UIAttemptSplitToContainer leaves the source stack where it is. ACE will // publish the reduced source plus a newly-guided destination stack. - if (_itemInteraction is not null) - { - _itemInteraction.TrySplitToContainer( - item, - container, - (uint)placement, - splitSize); - } - else - { - DispatchInventoryRequest( - InventoryRequestKind.SplitToContainer, - item, - () => - { - if (_sendStackableSplitToContainer is null) - return false; - _sendStackableSplitToContainer( - item, - container, - (uint)placement, - splitSize); - return true; - }); - } - return; - } - } - - // Canonical ownership never changes on request. Retail immediately - // publishes the destination ItemList's m_pendingItem projection and - // resolves it from the server move/failure response. - if (_itemInteraction is not null) - { - InventoryRequestKind kind = payload.SourceKind == ItemDragSource.Ground - ? InventoryRequestKind.Pickup - : InventoryRequestKind.PutInContainer; - if (!_itemInteraction.TryDispatchPendingBackpackPlacement( + DispatchInventoryRequest( + InventoryRequestKind.SplitToContainer, item, - container, - placement, - kind, () => { - if (_sendPutItemInContainer is null) + if (_sendStackableSplitToContainer is null) return false; - _sendPutItemInContainer(item, container, placement); + _sendStackableSplitToContainer( + item, + container, + (uint)placement, + splitSize); return true; - })) - { + }); return; } - return; } - if (_pendingListPlacement is not null) - return; - _pendingListPlacement = new PendingListPlacement(0u, item, container, placement); - Populate(); + // External-container contents retain canonical ownership while the request + // is in flight, but retail immediately inserts an m_pendingItem copy into + // the chosen destination slot and ghosts it. The server move/failure notice + // resolves that visual projection. UIElement_ItemList::HandleDropRelease + // @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680. + if (payload.SourceKind == ItemDragSource.Ground) + { + if (_itemInteraction is not null) + { + if (!_itemInteraction.TryDispatchPendingBackpackPlacement( + item, + container, + placement, + InventoryRequestKind.Pickup, + () => + { + if (_sendPutItemInContainer is null) + return false; + _sendPutItemInContainer(item, container, placement); + return true; + })) + { + return; + } + return; + } + else + { + if (_pendingListPlacement is not null) + return; + _pendingListPlacement = new PendingListPlacement(0u, item, container, placement); + Populate(); + } + } + else + { + if (_itemInteraction is not null) + { + DispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + item, + () => + { + if (_sendPutItemInContainer is null + || !_objects.MoveItemOptimistic(item, container, placement)) + { + return false; + } + _sendPutItemInContainer(item, container, placement); + return true; + }); + return; + } + _objects.MoveItemOptimistic(item, container, placement); + } _sendPutItemInContainer?.Invoke(item, container, placement); } @@ -861,57 +832,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { int cap = _objects.Get(container)?.ItemsCapacity ?? 0; if (cap <= 0) return false; - return CountLooseContents(container) >= cap; + return _objects.GetContents(container).Count >= cap; } - private void OnMergeAttempted(uint sourceId, uint targetId) - { - _notifyMergeAttempt?.Invoke(sourceId, targetId); - _selection.Select(targetId, SelectionChangeSource.Inventory); - } - - private InventoryContainerPlacementRejection EvaluateDrop( - UiItemList targetList, - UiItemSlot targetCell, - uint itemId, - out bool sourceIsBag, - out uint destinationId) - { - sourceIsBag = _objects.Get(itemId) is { } source && IsBag(source); - destinationId = 0u; - if (itemId == 0u) - return InventoryContainerPlacementRejection.InvalidItem; - - if (ReferenceEquals(targetList, _contentsGrid)) - { - destinationId = EffectiveOpen(); - // Carried containers belong to the authored container selector, - // never the loose-item grid, even when both address the player. - if (sourceIsBag) - return InventoryContainerPlacementRejection.ContainerCapacityFull; - } - else if (ReferenceEquals(targetList, _containerList) - || ReferenceEquals(targetList, _topContainer)) - { - destinationId = sourceIsBag ? _playerGuid() : targetCell.ItemId; - if (!sourceIsBag && (targetCell.ItemId == 0u || targetCell.ItemId == itemId)) - return InventoryContainerPlacementRejection.InvalidDestination; - } - else - { - return InventoryContainerPlacementRejection.InvalidDestination; - } - - return InventoryContainerPlacementPolicy.Evaluate( - _objects, - itemId, - destinationId, - _playerGuid()); - } - - private bool IsWaitingSource(uint itemGuid) - => _itemInteraction?.IsPendingInventorySource(itemGuid) == true; - /// Select an item (panel-wide green square) without changing the open container or /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0). private void SelectItem(uint guid) @@ -972,8 +895,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { var cell = list.GetItem(i); if (cell is null) continue; - bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true - || IsWaitingSource(cell.ItemId); + bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true; cell.Selected = cell.ItemId != 0 && cell.ItemId == _selection.SelectedObjectId && !pendingTargetSource; @@ -1090,7 +1012,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo } if (_itemInteraction is not null) { - _itemInteraction.MergeAttempted -= OnMergeAttempted; _itemInteraction.StateChanged -= OnInteractionStateChanged; _itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested; _itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled; diff --git a/src/AcDream.App/UI/Layout/JournalPanelController.cs b/src/AcDream.App/UI/Layout/JournalPanelController.cs index f0846720..141a1485 100644 --- a/src/AcDream.App/UI/Layout/JournalPanelController.cs +++ b/src/AcDream.App/UI/Layout/JournalPanelController.cs @@ -180,9 +180,6 @@ public sealed class JournalPanelController : IRetainedPanelController /// Switches to the notes tab — what opening a page from the index does. public void ShowNotes() => _tabPanel.SwitchTo(NotesPageId); - /// Switches to the authored journal index tab. - public void ShowPageList() => _tabPanel.SwitchTo(PageListPageId); - /// /// Completes construction. The index needs a callback that switches tabs, /// which needs the panel — so it is attached rather than constructed. diff --git a/src/AcDream.App/UI/Layout/KeyboardConfigController.cs b/src/AcDream.App/UI/Layout/KeyboardConfigController.cs index a04b4739..c1c67d33 100644 --- a/src/AcDream.App/UI/Layout/KeyboardConfigController.cs +++ b/src/AcDream.App/UI/Layout/KeyboardConfigController.cs @@ -66,17 +66,19 @@ namespace AcDream.App.UI.Layout; /// /// /// Row identity and binding storage (D4). Every row's identity is the DAT -/// pair (InputMapId, ActionId) — retail's own row key. The installed EoR -/// ActionMap's 306 pairs each resolve to one distinct acdream -/// ; the row's bindings ARE +/// pair (InputMapId, ActionId) — retail's own row key. Where +/// resolves that pair to an acdream +/// (research: roughly half of the DAT's 306 rows — see +/// that table's class doc for the full accounting), the row's bindings ARE /// 's bindings for that action: a rebind here takes /// effect immediately for live gameplay dispatch through the SAME -/// every other input path uses. Retail Load File / -/// Save As exchange the original PFile *.keymap format; acdream also writes -/// keybinds.json as its portable mirror for host-only commands. The -/// nullable/unmapped delegates remain solely -/// so an unknown future-DAT row stays visible and round-trippable instead of -/// crashing an older client. +/// every other input path uses, and persists to +/// keybinds.json exactly like any other rebind (D4 — no separate +/// .keymap file format). Where no exists yet +/// (mostly Emotes and CharacterSettings — see the identity table's class doc), +/// the row is still fully rendered, bindable, conflict-checked, and persisted +/// (/), +/// it just has no live gameplay consumer yet (register row). /// /// /// @@ -88,8 +90,8 @@ namespace AcDream.App.UI.Layout; /// against every multi-chord action in KeyBindings.RetailDefaults(): /// walk-mode's Hold, the three melee/missile/magic combat scopes, ...), so this /// row captures that pair ONCE at build time (from the first live binding, or -/// the retail identity table if the action starts wholly unbound) and reapplies -/// it to every chord this row ever writes — +/// / if the action +/// starts wholly unbound) and reapplies it to every chord this row ever writes — /// on a live rebind, on Cancel/Revert (RestoreSavedValue), and on Defaults /// (RestoreDefaultValue, which restores DAT-sourced KEYS only; Activation/ /// Scope are retail-side properties of the ACTION, not of which physical key @@ -111,8 +113,9 @@ namespace AcDream.App.UI.Layout; /// ANY conflicting target is non-user-bindable). This port's non-user-bindable /// analogue is a chord already bound to an acdream-only action with no /// row at all (Ctrl+M mute, the debug -/// F-keys, ...) — refused through retail's type-3 -/// OpenCantOverwriteBindingDialog with the exact DAT template. A genuine +/// F-keys, ...) — refused via +/// exactly like retail's distinct OpenCantOverwriteBindingDialog, with no +/// dialog (a hard stop, matching the DAT-verified refusal string). A genuine /// cross-row conflict collects EVERY conflicting row (not just the first) and /// opens a real confirm dialog through — /// retail's OpenOverwriteBindingDialog(&conflicts) — BEFORE reassigning; @@ -120,10 +123,15 @@ namespace AcDream.App.UI.Layout; /// /// /// -/// Campaign KB maps all 306 installed EoR rows to distinct live actions, so -/// every authored command is enabled and uses the normal caption color. The -/// nullable defensive path remains only to make an unknown future DAT row -/// visible without crashing an older client. +/// Caption dimming (AD-78, user-directed, 2026-08-11, gate 2). A row +/// whose is null (AP-203's store-only +/// set — mostly Emotes and CharacterSettings, plus every non-user-bindable +/// InputMap this screen renders) dims its synthesized caption via +/// in +/// . The row stays fully rendered, bindable, +/// conflict-checked, and persisted (per the paragraph above) — only the +/// caption color changes, so the dim is a visual "no live gameplay consumer +/// yet" marker, not a functional restriction. /// /// public sealed class KeyboardConfigController @@ -196,14 +204,8 @@ public sealed class KeyboardConfigController Action> BeginCapture, Action Save, Action Toggle, - // Resolves one of retail's ID_ActionKeyMap_* templates from string-table - // enum 0x10000004 (installed DID 0x23000004). Null means the retail text - // is unavailable; callers then leave the operation inert instead of - // inventing UI prose. - Func, string?> ResolveTemplate, - // Retail OpenCantOverwriteBindingDialog is a type-3 priority message on - // keyboard queue 0x10000001, not a scrolling-chat/system message. - Action ShowMessage, + Action DisplaySystemMessage, + string NonBindableRefusalText, // M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — confirm // BEFORE reassigning a chord already bound to another row on this screen. // message is pre-composed (real row labels, no invented retail text); @@ -219,36 +221,25 @@ public sealed class KeyboardConfigController // or ESC). Null keeps the pre-dialog capture behavior for hosts with // no dialog factory (unit fixtures). Func? OpenCaptureInstructions = null, - Action? CloseCaptureInstructions = null, - // Retail gmKeyboardUI's Load File / Save As workflows. Each opener - // invokes its callback only after a successful profile operation. - Func? CurrentKeymapFilename = null, - Action? OpenLoadKeymap = null, - Action? OpenSaveKeymap = null); + Action? CloseCaptureInstructions = null); public OptionPage Page { get; } = new(); public IReadOnlyList Rows => _rows; private readonly List _rows = new(); private readonly Dictionary<(uint LayoutId, uint ElementId), UiDatFont?> _templateFontCache = new(); - private RetailActionMapSnapshot? _snapshot; private Bindings? _bindings; private Func _describe = DescribeChord; private Func? _resolveTemplateFont; - private static readonly uint ActionVariable = DatStringResolver.ComputeHash("ACTION"); - private static readonly uint BindingsVariable = DatStringResolver.ComputeHash("BINDINGS"); - private static readonly uint KeyVariable = DatStringResolver.ComputeHash("KEY"); - private static readonly uint LabelVariable = DatStringResolver.ComputeHash("LABEL"); - private static readonly uint ValueVariable = DatStringResolver.ComputeHash("VALUE"); - private KeyboardConfigController() { } /// /// Builds every header + row across all six pages from /// , wires each row's key buttons to modal /// capture / right-click erase, and wires the screen's own six buttons - /// (Load File/Save As/Defaults/Revert/OK/Cancel). Returns null if the layout's window root + /// (Defaults/Revert/OK/Cancel; Load/Save File are INERT — D4, no + /// .keymap interchange). Returns null if the layout's window root /// did not import (a missing/malformed LayoutDesc). /// public static KeyboardConfigController? Bind( @@ -275,7 +266,6 @@ public sealed class KeyboardConfigController var controller = new KeyboardConfigController { - _snapshot = snapshot, _bindings = bindings, _resolveTemplateFont = resolveTemplateFont, // OP8 re-gate (2026-08-14): key-button captions through retail's @@ -407,9 +397,11 @@ public sealed class KeyboardConfigController // The row's own caption — synthesized, composed beside the authored key // buttons (UiText is sealed; see class doc). Occupies the "Command" column - // (x=0..270, matching the authored column headers). All 306 EoR rows - // are mapped; the dim color is only a forward-compatible signal for - // a row introduced by a different DAT revision. + // (x=0..270, matching the authored column headers). AD-78 (user-directed, + // 2026-08-11, gate 2): an unmapped row (MappedAction null — no live + // InputDispatcher consumer, AP-203) dims its caption; the key buttons + // themselves stay fully interactive (bindable/persisted/conflict-checked, + // see class doc). var captionText = new UiText { Left = 0f, @@ -431,41 +423,39 @@ public sealed class KeyboardConfigController }; if (label is not null) captionText.LinesProvider = () => new[] { new UiText.Line(label, captionText.DefaultColor) }; - // UIOption_ActionKeyMap::SetTooltip applies the ActionMap row's own - // tooltip to the row, while Refresh replaces each key button's tooltip - // with the dedicated existing/new-binding templates below. - captionText.AuthoredTooltipText = tooltip; built.AddChild(captionText); // M1: capture this row's live Activation/Scope ONCE, from the first // existing binding for the action (every multi-chord action in // KeyBindings.RetailDefaults() shares one Activation/Scope pair across // all its bindings — see class doc). Falls back to the Binding record's - // retail action-identity metadata when the action starts wholly unbound. + // own defaults (Press/Game) only when the action starts wholly unbound. IReadOnlyList liveBindings = mapped ? bindings.CurrentForAction(action) : Array.Empty(); (ActivationType Activation, InputScope Scope) template = liveBindings.Count > 0 ? (liveBindings[0].Activation, liveBindings[0].Scope) - : ( - RetailActionIdentityTable.ActivationFor(row.InputMapId, row.ActionId), - RetailActionIdentityTable.ScopeForInputMap(row.InputMapId)); + : (ActivationType.Press, InputScope.Game); IReadOnlyList defaults = DatDefaultsToChords(row.DefaultBindings); IReadOnlyList storedUnmapped = mapped ? Array.Empty() : bindings.CurrentForUnmapped((row.InputMapId, row.ActionId)); - // An unknown future-DAT row with no persisted chords displays its DAT - // defaults. Installed EoR rows always take the mapped branch. + // OP8 re-review round 2 (SHOULD-FIX): an unmapped/store-only row with + // no persisted chords displays its DAT DEFAULTS — retail shows the + // authored bindings (the Camera Alternate rows' arrow keys) and a + // blank row misreads as "unbound". Display-only: nothing here feeds + // the InputDispatcher, and the store only gains the defaults if the + // user actually edits the row (the apply closure below). IReadOnlyList initial = mapped ? liveBindings.Select(b => b.Chord).ToArray() : storedUnmapped.Count > 0 ? storedUnmapped : defaults; var model = new ActionKeyMapOptionRow(initial, defaults, apply: value => { - // A legacy compatibility store can still contain padding - // default(KeyChord) entries even though the retail production - // editor is dense; never publish those sentinels as bindings. + // Interior/padding default(KeyChord) entries (S4 — sparse-slot + // display, see ReplaceSlotValue) are never real bindings; filter + // them out at the write boundary, not at storage time. IReadOnlyList real = value.Where(c => c != default).ToArray(); if (mapped) bindings.SetForAction( @@ -484,6 +474,7 @@ public sealed class KeyboardConfigController for (int slot = 0; slot < keyButtons.Count; slot++) { int capturedSlot = slot; + keyButtons[slot].TooltipText = tooltip; keyButtons[slot].OnClick = () => BeginSlotCapture(view, capturedSlot, bindings); keyButtons[slot].OnRightClick = () => EraseSlot(view, capturedSlot); } @@ -525,36 +516,10 @@ public sealed class KeyboardConfigController for (int i = 0; i < view.KeyButtons.Count; i++) { bool bound = i < current.Count && current[i] != default; - if (!bound) - { - view.KeyButtons[i].Label = null; - view.KeyButtons[i].TooltipText = ResolveTemplate( - "ID_ActionKeyMap_TT_NewBinding", - EmptyTemplateVariables); - continue; - } - - string keyName = _describe(current[i]); - string? buttonLabel = ResolveTemplate( - "ID_ActionKeyMap_ButtonLabel", - new Dictionary { [LabelVariable] = keyName }); - view.KeyButtons[i].Label = buttonLabel; - view.KeyButtons[i].TooltipText = buttonLabel is null - ? null - : ResolveTemplate( - "ID_ActionKeyMap_TT_ExistingBinding", - new Dictionary { [ValueVariable] = buttonLabel }); + view.KeyButtons[i].Label = bound ? _describe(current[i]) : null; } } - private static readonly IReadOnlyDictionary EmptyTemplateVariables = - new Dictionary(); - - private string? ResolveTemplate( - string key, - IReadOnlyDictionary variables) => - _bindings?.ResolveTemplate(key, variables); - /// Raw enum spelling — construction-time default until Bind swaps /// in , and that class's own fallback /// for controls outside the DIK table. @@ -583,34 +548,13 @@ public sealed class KeyboardConfigController } } - void ArmCapture() => bindings.BeginCapture(captured => + bindings.BeginCapture(captured => { - if (captured is { } unsupported && IsUnsupportedRetailCapture(unsupported)) - { - // KeyHitHandler @0x004895AF..0x004895DF leaves its input - // handler registered for joystick input and mouse buttons 0/1. - // The authored MapInstructions says the same explicitly. Our - // dispatcher capture is one-shot, so re-arm it while leaving - // the existing wait dialog open. - ArmCapture(); - return; - } - if (instructionsContext != 0u) bindings.CloseCaptureInstructions?.Invoke(instructionsContext); if (captured is not { } chord) return; // Escape — retail cancels silently. - // KeyHitHandler @ 0x0048963B..0x0048964A checks the row's own - // current controls for an EXACT match before it performs any - // cross-map conflict work. Choosing a chord already present in a - // different slot of this row is therefore a silent no-op; it must - // not duplicate the chord into the clicked slot (and must not be - // rejected because an unrelated non-user-bindable action happens - // to share it). - if (view.Model.Current.Contains(chord)) - return; - (ConflictOutcome outcome, List conflictRows) = FindConflicts(chord, exclude: view); switch (outcome) { @@ -620,23 +564,18 @@ public sealed class KeyboardConfigController // conflicting target is non-user-bindable. This port's // analogue: a chord already bound to an acdream-only action // with no DAT row at all (Ctrl+M mute, the debug F-keys, ...) — - // OpenCantOverwriteBindingDialog @ 0x00489300: exact - // ID_ActionKeyMap_NonUserBindableBinding(KEY) text in a - // type-3 priority message dialog on queue 0x10000001. - string? refusal = bindings.ResolveTemplate( - "ID_ActionKeyMap_NonUserBindableBinding", - new Dictionary { [KeyVariable] = _describe(chord) }); - if (refusal is not null) - bindings.ShowMessage(refusal); + // OpenCantOverwriteBindingDialog's ported refusal, no dialog. + bindings.DisplaySystemMessage(bindings.NonBindableRefusalText); return; case ConflictOutcome.Rows: // M3: retail's OpenOverwriteBindingDialog — confirm BEFORE // reassigning (N-way: every conflicting row is named, not just // the first). Only on accept do the losing rows lose the slot. - string? message = ComposeOverwriteMessage(chord, conflictRows, bindings); - if (message is null) - return; + string names = string.Join(", ", conflictRows.Select(r => r.Label ?? "?")); + string message = + $"'{_describe(chord)}' is already bound to {names}. " + + $"Reassign it to '{view.Label}'?"; bindings.ConfirmOverwrite(message, accepted => { if (!accepted) return; @@ -654,76 +593,13 @@ public sealed class KeyboardConfigController return; } }); - - ArmCapture(); - } - - private static bool IsUnsupportedRetailCapture(KeyChord chord) - { - if (chord.Device > 1) - return true; // joystick/unknown device - if (chord.Device == 1 - && (chord.Key == InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Left) - || chord.Key == InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Right))) - return true; - return !RetailScanCodeMap.TryToFileControl(chord, out _); - } - - private string? ComposeOverwriteMessage( - KeyChord chord, - IReadOnlyList conflicts, - Bindings bindings) - { - string keyName = _describe(chord); - if (conflicts.Count == 1) - { - string? action = conflicts[0].Label; - if (action is null) return null; - return bindings.ResolveTemplate( - "ID_ActionKeyMap_OverwriteExistingBinding", - new Dictionary - { - [KeyVariable] = keyName, - [ActionVariable] = action, - }); - } - - var lines = new List(conflicts.Count); - foreach (RowView conflict in conflicts) - { - if (conflict.Label is null) return null; - string? line = bindings.ResolveTemplate( - "ID_ActionKeyMap_Binding", - new Dictionary - { - [ActionVariable] = conflict.Label, - [KeyVariable] = keyName, - }); - if (line is null) return null; - lines.Add(line); - } - - return bindings.ResolveTemplate( - "ID_ActionKeyMap_OverwriteExistingBindings", - new Dictionary - { - [KeyVariable] = keyName, - [BindingsVariable] = string.Join("\n", lines), - }); } private void ApplySlot(RowView view, int slot, KeyChord chord) { List updated = new(view.Model.Current); - // SetBinding @ 0x00487B32..0x00487B47 clamps a requested slot past - // m_qclCurrent.Count to Count. Retail's bindings are a dense list: - // clicking Mapping 3 on an empty row appends at Mapping 1; clicking it - // on a one-binding row appends at Mapping 2. - int targetSlot = Math.Clamp(slot, 0, updated.Count); - if (targetSlot == updated.Count) - updated.Add(chord); - else - updated[targetSlot] = chord; + while (updated.Count <= slot) updated.Add(default); + updated[slot] = chord; ReplaceSlotValue(view, updated); RefreshRowButtons(view); } @@ -740,9 +616,13 @@ public sealed class KeyboardConfigController private static void ReplaceSlotValue(RowView view, IReadOnlyList value) { - // The production path is dense (ApplySlot clamps to Count and erase - // removes an element). Keep the trailing-default trim as a defensive - // boundary for compatibility stores created by older schema versions. + // S4 (2026-08-11 review): only trim TRAILING empty slots. Retail's + // SetBinding(qc, slot) writes the SPECIFIC slot the user clicked — a row + // with no bindings whose "Mapping 3" button is set must keep the chord at + // display index 2, not collapse it onto index 0. Interior default(KeyChord) + // entries only ever come from ApplySlot's own padding, so trimming just the + // tail keeps RefreshRowButtons' positional read correct without inventing a + // nullable-chord storage type. int lastReal = -1; for (int i = 0; i < value.Count; i++) if (value[i] != default) lastReal = i; @@ -759,8 +639,9 @@ public sealed class KeyboardConfigController /// ICIDM::FindConflictingInputMaps/FindConflictingControls), /// scoped to this screen's own universe: the non-user-bindable check runs /// FIRST (S1 — retail's own order), then EVERY OTHER row's current chord set - /// (all 306 installed EoR rows are mapped) is collected in full, not just - /// the first match. + /// (covers BOTH mapped and unmapped rows — a chord already claimed by an + /// unmapped row is just as real a conflict as one claimed by a mapped one) is + /// collected in full, not just the first match. /// private (ConflictOutcome Outcome, List Rows) FindConflicts(KeyChord chord, RowView exclude) { @@ -778,20 +659,15 @@ public sealed class KeyboardConfigController foreach (RowView other in _rows) { if (ReferenceEquals(other, exclude)) continue; - // A future unknown-DAT row never reaches the dispatcher, so its - // display-only chord cannot create a live conflict. #373: mapped - // cross-context sharing consults the - // installed DAT's ActionMap.ConflictingMaps table. In particular, - // the melee/missile/magic contexts legitimately share the retail - // Insert/Delete/End/PageUp/PageDown cluster and must not erase one - // another. + // OP8 re-review round 2 R1: store-only rows (MappedAction null — + // the Camera Alternate scheme, Emote/CharacterSettings hotkeys) + // never reach the InputDispatcher, so a chord they display cannot + // actually collide with anything; counting them made the ten + // arrow-key defaults trip a false N-way confirm on any arrow + // rebind. Retail-mapped cross-context sharing (ConflictingMaps — + // the Insert/Delete/End/PageUp/PageDown combat cluster) remains + // deferred as ISSUES #373; only INERT rows are excluded here. if (other.MappedAction is null) continue; - if (_snapshot?.InputMapsConflict( - exclude.InputMapId, - other.InputMapId) != true) - { - continue; - } if (other.Model.Current.Contains(chord)) rows.Add(other); } @@ -801,42 +677,24 @@ public sealed class KeyboardConfigController private static void WireScreenButtons( ImportedLayout layout, KeyboardConfigController controller, Bindings bindings) { - UiText? filename = layout.FindElement(FilenameLabelId) as UiText; - void RefreshFilename() - { - if (filename is null || bindings.CurrentKeymapFilename is null) return; - string value = bindings.CurrentKeymapFilename(); - filename.LinesProvider = () => - new[] { new UiText.Line(value, filename.DefaultColor) }; - } - RefreshFilename(); - - if (layout.FindElement(LoadButtonId) is UiButton loadButton - && bindings.OpenLoadKeymap is { } openLoad) - { - loadButton.OnClick = () => openLoad(() => - { - controller.ReloadRowsFromBindings(bindings); - RefreshFilename(); - }); - } - - if (layout.FindElement(SaveAsButtonId) is UiButton saveAsButton - && bindings.OpenSaveKeymap is { } openSave) - { - saveAsButton.OnClick = () => openSave(RefreshFilename); - } + // Load File / Save As — INERT (D4: keybinds.json only, no .keymap + // interchange). Authored, clickable, no handler — same shape as OP3's + // still-inert buttons. + _ = layout.FindElement(LoadButtonId); + _ = layout.FindElement(SaveAsButtonId); + _ = layout.FindElement(FilenameLabelId); if (layout.FindElement(DefaultsButtonId) is UiButton defaultsButton) defaultsButton.OnClick = () => { + foreach (RowView row in controller._rows) + row.Model.SetDefaultValue(row.Model.DefaultValue); controller.Page.Defaults(); foreach (RowView row in controller._rows) controller.RefreshRowButtons(row); }; if (layout.FindElement(RevertButtonId) is UiButton revertButton) - { revertButton.OnClick = () => { controller.Page.Reset(); @@ -844,29 +702,16 @@ public sealed class KeyboardConfigController controller.RefreshRowButtons(row); }; - // gmKeyboardUI::OnOptionChanged @ 0x004DA890 addresses the - // m_pKeyboardRevertToSavedButton slot through the secondary - // IOptionChangeHandler base. It is Normal (state 1) exactly while - // OptionPage::Changed is true, otherwise Ghosted (state 0xD). - controller.Page.OnOptionChanged = () => - revertButton.Enabled = controller.Page.Changed; - controller.Page.OnOptionChanged(); - } - - // gmKeyboardUI::ListenToElementMessage @ 0x004DD230 handles the - // authored button action/release message (id 0x19, parameter 7). That - // is the ordinary retained-button click path, not evidence of a - // special right-click gesture. + // OK — right-click release in retail (idMessage 0x19); ported as a plain + // left-click here, matching every other Campaign OP button (the asymmetry + // is authored-input-only — no user-visible affordance differs, since + // retail's own right-click-release on just this pair of buttons carries + // no distinguishing visual cue either). if (layout.FindElement(OkButtonId) is UiButton okButton) okButton.OnClick = () => { - bool changed = controller.Page.Changed; - // Retail only rewrites the active keymap when at least one - // row differs; SaveCurrentValues still advances the Revert - // baseline unconditionally. - if (changed) - bindings.Save(); controller.Page.Apply(); + bindings.Save(); bindings.Toggle(); }; @@ -879,17 +724,4 @@ public sealed class KeyboardConfigController bindings.Toggle(); }; } - - private void ReloadRowsFromBindings(Bindings bindings) - { - foreach (RowView row in _rows) - { - IReadOnlyList chords = row.MappedAction is { } action - ? bindings.CurrentForAction(action).Select(static value => value.Chord).ToArray() - : bindings.CurrentForUnmapped((row.InputMapId, row.ActionId)); - row.Model.ReloadCurrentAndSaved(chords); - RefreshRowButtons(row); - } - Page.OnOptionChanged?.Invoke(); - } } diff --git a/src/AcDream.App/UI/Layout/MapHousePanelController.cs b/src/AcDream.App/UI/Layout/MapHousePanelController.cs index 7b0ddb56..b8b5c943 100644 --- a/src/AcDream.App/UI/Layout/MapHousePanelController.cs +++ b/src/AcDream.App/UI/Layout/MapHousePanelController.cs @@ -140,12 +140,6 @@ public sealed class MapHousePanelController : IRetainedPanelController public bool IsShowingHouse => _tabPanel.ActivePageElementId == HousePageId; - public bool IsShowingMap => _tabPanel.ActivePageElementId == MapPageId; - - public void ShowMap() => _tabPanel.SwitchTo(MapPageId); - - public void ShowHouse() => _tabPanel.SwitchTo(HousePageId); - public void OnShown() { _visible = true; diff --git a/src/AcDream.App/UI/Layout/OptionPageModel.cs b/src/AcDream.App/UI/Layout/OptionPageModel.cs index 804f4074..682a67b2 100644 --- a/src/AcDream.App/UI/Layout/OptionPageModel.cs +++ b/src/AcDream.App/UI/Layout/OptionPageModel.cs @@ -554,10 +554,10 @@ public sealed class ActionKeyMapOptionRow : IOptionRow public bool Changed => !_current.SequenceEqual(_saved); - /// Replaces the DAT master-map default used by the next - /// Reset-to-Defaults operation. The installed DAT is immutable during one - /// client process, so the keyboard controller normally seeds this once - /// when it builds the row. + /// Reset-to-Defaults reloads the DAT master maps fresh + /// (gmKeyboardUI::RestoreDefaultValues — research doc §5.6) before + /// restoring each row, so the default slot list itself can change between + /// presses (a fresh DAT read), not just at construction time. public void SetDefaultValue(IReadOnlyList value) => _default = value; /// The capture/erase entry point — writes m_current and applies @@ -574,16 +574,6 @@ public sealed class ActionKeyMapOptionRow : IOptionRow public void SaveCurrentValue() => _saved = _current; - /// Re-seeds both the live value and Revert baseline after retail's - /// Load File swaps the dispatcher keymap. The dispatcher has already applied - /// the profile, so this intentionally does not call the row's write-back. - public void ReloadCurrentAndSaved(IReadOnlyList value) - { - _current = value; - _saved = value; - _notifyPageOptionChanged?.Invoke(); - } - public void RestoreSavedValue() { _current = _saved; diff --git a/src/AcDream.App/UI/Layout/OptionsPanelController.cs b/src/AcDream.App/UI/Layout/OptionsPanelController.cs index 90c4b520..29d1d423 100644 --- a/src/AcDream.App/UI/Layout/OptionsPanelController.cs +++ b/src/AcDream.App/UI/Layout/OptionsPanelController.cs @@ -134,28 +134,6 @@ public sealed class OptionsPanelController : IRetainedPanelController public OptionPage ConfigPage => _pages[ConfigPageId]; - /// True when the authored Gameplay Options page is active. - public bool IsShowingGameplay => - _tabPanel.ActivePageElementId == GameplayPageId; - - public bool IsShowingCharacter => - _tabPanel.ActivePageElementId == CharacterPageId; - - public bool IsShowingConfiguration => - _tabPanel.ActivePageElementId == ConfigPageId; - - /// - /// Programmatic form of retail action 0x1000001B, resolved from - /// the installed ActionMap as "Show/Hide Gameplay Options Page". This is - /// the final fallback of ClientUISystem::OnAction(EscapeKey) at - /// 0x00564CBF. - /// - public void ShowGameplay() => _tabPanel.SwitchTo(GameplayPageId); - - public void ShowCharacter() => _tabPanel.SwitchTo(CharacterPageId); - - public void ShowConfiguration() => _tabPanel.SwitchTo(ConfigPageId); - private OptionsPanelController(UiTabPanel tabPanel, Action? afterApply) { _tabPanel = tabPanel; diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index c061e0ac..4d925ed9 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -115,7 +115,6 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo _objects.ObjectUpdated += OnObjectChanged; _objects.Cleared += OnObjectsCleared; _selection.Changed += OnSelectionChanged; - _itemInteraction.StateChanged += OnInteractionStateChanged; // ── Slots-toggle wiring ─────────────────────────────────────────────────────────────────── foreach (var id in ArmorSlotElementIds) @@ -217,7 +216,6 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo Populate(); } private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators(); - private void OnInteractionStateChanged() => Populate(); private void OnObjectsCleared() { ApplyAetheriaVisibility(); @@ -227,8 +225,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo /// The object belongs to the player (wielded gear or pack contents) — so a change to it may /// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries /// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always - /// has WielderId==p (login, from CreateObject) or ContainerId==p, so the - /// equip-location need not be tested here; OnObjectMoved carries the + /// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by + /// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved carries the /// complete old/new retail placement for transitions that satisfy neither after mutation. private bool Concerns(ClientObject o) { @@ -258,8 +256,6 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo uint dragTex = _dragIconIds?.Invoke( worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects) ?? 0u; list.Cell.SetItem(worn.ObjectId, tex, dragIconTexture: dragTex); - list.Cell.SetWaitingState( - _itemInteraction.IsPendingInventorySource(worn.ObjectId)); } ApplyAetheriaVisibility(); ApplySelectionIndicators(); @@ -282,8 +278,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo foreach (var (_, list) in _slots) { list.Cell.Selected = list.Cell.ItemId != 0 - && list.Cell.ItemId == _selection.SelectedObjectId - && !_itemInteraction.IsPendingInventorySource(list.Cell.ItemId); + && list.Cell.ItemId == _selection.SelectedObjectId; } } @@ -374,7 +369,6 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo _objects.ObjectUpdated -= OnObjectChanged; _objects.Cleared -= OnObjectsCleared; _selection.Changed -= OnSelectionChanged; - _itemInteraction.StateChanged -= OnInteractionStateChanged; foreach (var (_, list) in _slots) { list.PrimaryItemPressed = null; diff --git a/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs b/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs deleted file mode 100644 index 3d271ddd..00000000 --- a/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Numerics; -using AcDream.Core.Selection; -using AcDream.Plugin.Abstractions; - -namespace AcDream.App.UI.Layout; - -/// -/// Retained-view projection of VTank's ShowCollisionDebug shapes. -/// The collision query remains in the canonical physics world; this owner -/// only projects its detached per-quantum samples into the already-open UI -/// phase, avoiding a nested Vulkan backbuffer pass. -/// -internal sealed class ProjectileDebugOverlayController -{ - private static readonly Vector4 ClearColor = new(0f, 1f, 0f, 0.95f); - private static readonly Vector4 BlockedColor = new(1f, 0f, 0f, 0.95f); - - private readonly UiPanel _root; - private readonly Func> _samples; - private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> - _camera; - private readonly List _markers = []; - - private ProjectileDebugOverlayController( - UiPanel root, - Func> samples, - Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera) - { - _root = root; - _samples = samples; - _camera = camera; - } - - internal static ProjectileDebugOverlayController Mount( - UiRoot host, - Func> samples, - Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera) - { - ArgumentNullException.ThrowIfNull(host); - ArgumentNullException.ThrowIfNull(samples); - ArgumentNullException.ThrowIfNull(camera); - var root = new UiPanel - { - Name = "PluginProjectileDebugOverlay", - BackgroundColor = Vector4.Zero, - BorderColor = Vector4.Zero, - ClickThrough = true, - Visible = false, - ZOrder = -9_999, - Anchors = AnchorEdges.None, - }; - host.AddChild(root); - return new ProjectileDebugOverlayController(root, samples, camera); - } - - internal void Tick() - { - IReadOnlyList samples = _samples(); - var camera = _camera(); - if (samples.Count == 0 - || camera.Viewport.X <= 0f - || camera.Viewport.Y <= 0f) - { - HideAll(); - return; - } - - EnsureMarkerCount(samples.Count); - _root.Left = 0f; - _root.Top = 0f; - _root.Width = camera.Viewport.X; - _root.Height = camera.Viewport.Y; - int visible = 0; - for (int index = 0; index < samples.Count; index++) - { - PluginProjectileDebugSample sample = samples[index]; - if (!ScreenProjection.TryProjectSphereToScreenRect( - sample.WorldPosition, - sample.Radius, - camera.View, - camera.Projection, - camera.Viewport, - out Vector2 minimum, - out Vector2 maximum, - out _, - minSidePixels: 4f) - || maximum.X < 0f - || maximum.Y < 0f - || minimum.X > camera.Viewport.X - || minimum.Y > camera.Viewport.Y) - { - continue; - } - - UiPanel marker = _markers[visible++]; - marker.Left = MathF.Max(0f, minimum.X); - marker.Top = MathF.Max(0f, minimum.Y); - marker.Width = MathF.Max( - 1f, - MathF.Min(camera.Viewport.X, maximum.X) - marker.Left); - marker.Height = MathF.Max( - 1f, - MathF.Min(camera.Viewport.Y, maximum.Y) - marker.Top); - marker.BorderColor = sample.IsClear ? ClearColor : BlockedColor; - marker.Visible = true; - } - for (int index = visible; index < _markers.Count; index++) - _markers[index].Visible = false; - _root.Visible = visible > 0; - } - - private void EnsureMarkerCount(int count) - { - while (_markers.Count < count) - { - var marker = new UiPanel - { - Name = $"PluginProjectileDebugMarker{_markers.Count}", - BackgroundColor = Vector4.Zero, - BorderColor = ClearColor, - BorderThickness = 1.5f, - ClickThrough = true, - Visible = false, - Anchors = AnchorEdges.None, - }; - _markers.Add(marker); - _root.AddChild(marker); - } - } - - private void HideAll() - { - _root.Visible = false; - for (int index = 0; index < _markers.Count; index++) - _markers[index].Visible = false; - } -} diff --git a/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs b/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs index 7b0ede81..7546525f 100644 --- a/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs +++ b/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs @@ -71,18 +71,15 @@ public sealed class RetailAppraisalNameResolver public string ResolveCreature(int creatureType) => _creatures.Resolve(creatureType); - // Campaign CT slice CT5 (2026-08-25): the three hardcoded overrides this - // method used to re-implement inline (2/5/13 -> "Gharu'ndim"/"Umbraen"/ - // "Olthoi") are dead duplication — CharacterIdentityText.HeritageGroupDisplayName - // already bakes the identical AppraisalSystem::InqHeritageGroupDisplayName - // overrides into its own switch (including id 12, which this method used - // to leave to the fallback branch and get the same "Olthoi" answer - // anyway). One owner for the override table now; behavior is unchanged - // (byte-identical for every heritage id — verified by the existing - // GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain pin, - // which exercises the shared table directly). public string ResolveHeritage(int heritageGroup) - => CharacterIdentityText.HeritageGroupDisplayName(heritageGroup) ?? string.Empty; + => heritageGroup switch + { + 2 => "Gharu'ndim", + 5 => "Umbraen", + 13 => "Olthoi", + _ => CharacterIdentityText.HeritageGroupDisplayName(heritageGroup) + ?? string.Empty, + }; public string ResolveMaterial(int materialType) => materialType > 0 diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs deleted file mode 100644 index ba951cf5..00000000 --- a/src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs +++ /dev/null @@ -1,122 +0,0 @@ -using AcDream.App.UI; - -namespace AcDream.App.UI.Layout; - -/// Retail type-7 ConfirmationMenuDialog, used by Configure -/// Keyboard's authored Load File button. -internal sealed class RetailConfirmationMenuDialogView : IRetailDialogView -{ - public const uint RootElementId = 0x1Fu; - public const uint MenuElementId = 0x21u; - public const uint AcceptButtonId = 0x22u; - public const uint RejectButtonId = 0x23u; - public const uint PopupElementId = 0x3Du; - - private readonly UiRoot _host; - private readonly RetailDialogData _data; - private readonly uint _context; - private readonly Action _closeDialog; - private readonly UiElement? _popup; - private readonly UiMenu _menu; - private readonly UiButton _accept; - private readonly UiButton _reject; - - public RetailConfirmationMenuDialogView( - UiRoot host, - ImportedLayout layout, - RetailDialogData data, - uint context, - Action closeDialog) - { - _host = host ?? throw new ArgumentNullException(nameof(host)); - ArgumentNullException.ThrowIfNull(layout); - _data = data ?? throw new ArgumentNullException(nameof(data)); - _context = context; - _closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog)); - - Root = layout.Root as UiDialogRoot - ?? throw new ArgumentException( - "Confirmation-menu layout root is not a UiDialogRoot.", nameof(layout)); - _popup = layout.FindElement(PopupElementId); - _menu = layout.FindElement(MenuElementId) as UiMenu - ?? throw new ArgumentException( - "Confirmation-menu layout is missing menu element 0x21.", nameof(layout)); - _accept = layout.FindElement(AcceptButtonId) as UiButton - ?? throw new ArgumentException( - "Confirmation-menu layout is missing accept button 0x22.", nameof(layout)); - _reject = layout.FindElement(RejectButtonId) as UiButton - ?? throw new ArgumentException( - "Confirmation-menu layout is missing reject button 0x23.", nameof(layout)); - - IReadOnlyList items = _data.TryGet( - RetailDialogProperty.MenuItems, out string[] values) - ? values - : Array.Empty(); - _menu.Items = items.Select( - static (label, index) => new UiMenu.MenuItem(label, index)).ToArray(); - int selected = Math.Clamp( - _data.GetInt32(RetailDialogProperty.MenuSelection), - 0, - Math.Max(0, items.Count - 1)); - _menu.Selected = items.Count == 0 ? null : selected; - _menu.OnSelect = payload => _menu.Selected = payload; - _menu.ButtonLabelProvider = () => - _menu.Selected is int index && index >= 0 && index < items.Count - ? items[index] - : string.Empty; - - if (_data.GetString(RetailDialogProperty.MenuAcceptLabel) is { } acceptLabel) - _accept.Label = acceptLabel; - if (_data.GetString(RetailDialogProperty.MenuRejectLabel) is { } rejectLabel) - _reject.Label = rejectLabel; - - Root.Cancel = Reject; - _accept.OnClick = Accept; - _reject.OnClick = Reject; - SizeAndCenter(); - } - - public UiDialogRoot Root { get; } - - public void Tick() => SizeAndCenter(); - - public void SetPendingCount(int count) - { - } - - public void DetachHandlers() - { - Root.Cancel = null; - _accept.OnClick = null; - _reject.OnClick = null; - _menu.OnSelect = null; - } - - private void Accept() - { - _data.Set( - RetailDialogProperty.MenuSelection, - _menu.Selected is int selected ? selected : -1); - _closeDialog(_context); - } - - private void Reject() - { - _data.Set(RetailDialogProperty.MenuSelection, -1); - _closeDialog(_context); - } - - private void SizeAndCenter() - { - var space = _host.EffectiveCanvasSize; - Root.Left = 0f; - Root.Top = 0f; - Root.Width = space.X; - Root.Height = space.Y; - if (_popup is null) return; - _popup.LayoutPolicy = null; - _popup.Anchors = AnchorEdges.None; - _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); - _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); - } -} diff --git a/src/AcDream.App/UI/Layout/RetailDialogData.cs b/src/AcDream.App/UI/Layout/RetailDialogData.cs index 75361a59..3dd769e5 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogData.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs @@ -14,11 +14,6 @@ public static class RetailDialogProperty public const uint TextInputAcceptLabel = 0x9Au; public const uint TextInputRejectLabel = 0x9Bu; public const uint TextInputResult = 0x9Cu; - public const uint MenuItems = 0xA6u; - public const uint MenuItem = 0xA7u; - public const uint MenuAcceptLabel = 0xA8u; - public const uint MenuRejectLabel = 0xA9u; - public const uint MenuSelection = 0xABu; /// /// When true, Dialog::SetData @ 0x00476BE0 sets UIElement boolean /// attribute 0x40. The Keystone-owned attribute name is unavailable. @@ -102,19 +97,6 @@ public sealed class RetailDialogData } : defaultValue; - public int GetInt32(uint propertyId, int defaultValue = 0) - => _values.TryGetValue(propertyId, out object? raw) - ? raw switch - { - byte value => value, - ushort value => value, - int value => value, - uint value when value <= int.MaxValue => (int)value, - Enum value => Convert.ToInt32(value), - _ => defaultValue, - } - : defaultValue; - public string? GetString(uint propertyId) => _values.TryGetValue(propertyId, out object? raw) ? raw as string : null; @@ -166,18 +148,4 @@ public sealed class RetailDialogData .Set(RetailDialogProperty.ElementAttribute40, true) .Set(RetailDialogProperty.Message, message); } - - /// Type-7 confirmation menu used by retail's keyboard-profile - /// Load File workflow (gmKeyboardUI::MakeLoadKeymapDialog). - public static RetailDialogData ConfirmationMenu( - IReadOnlyList items, - int selectedIndex = 0) - { - ArgumentNullException.ThrowIfNull(items); - return new RetailDialogData() - .Set(RetailDialogProperty.Type, RetailDialogType.ConfirmationMenu) - .Set(RetailDialogProperty.ElementAttribute40, true) - .Set(RetailDialogProperty.MenuItems, items.ToArray()) - .Set(RetailDialogProperty.MenuSelection, selectedIndex); - } } diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index 1970e18a..9efc211c 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -169,28 +169,20 @@ public sealed class RetailDialogFactory : IDisposable /// UIOption_ActionKeyMap::OpenMapWarnDialog @ 0x00488A00: type 2, /// caller-chosen queue key, element attribute 0x40 set, message text. /// - public uint MakeWait( - string message, - uint queueKey = DefaultQueueKey, - bool priority = false) + public uint MakeWait(string message, uint queueKey = DefaultQueueKey) { RetailDialogData data = RetailDialogData.Wait(message) .Set(RetailDialogProperty.QueueKey, queueKey); - if (priority) - data.Set(RetailDialogProperty.Priority, true); return MakeDialog(data, callback: null); } public uint MakeMessage( string message, Action? callback = null, - uint queueKey = DefaultQueueKey, - bool priority = false) + uint queueKey = DefaultQueueKey) { RetailDialogData data = RetailDialogData.Message(message) .Set(RetailDialogProperty.QueueKey, queueKey); - if (priority) - data.Set(RetailDialogProperty.Priority, true); return MakeDialog(data, callback); } @@ -204,17 +196,6 @@ public sealed class RetailDialogFactory : IDisposable return MakeDialog(data, callback); } - public uint MakeConfirmationMenu( - IReadOnlyList items, - int selectedIndex, - Action? callback = null, - uint queueKey = DefaultQueueKey) - { - RetailDialogData data = RetailDialogData.ConfirmationMenu(items, selectedIndex) - .Set(RetailDialogProperty.QueueKey, queueKey); - return MakeDialog(data, callback); - } - /// /// Retail CloseDialog @ 0x00478160. The context can identify an active /// nonqueued dialog, an active queued dialog, or an item still pending in a queue. @@ -414,8 +395,7 @@ public sealed class RetailDialogFactory : IDisposable if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait or RetailDialogType.Message - or RetailDialogType.ConfirmationTextInput - or RetailDialogType.ConfirmationMenu)) + or RetailDialogType.ConfirmationTextInput)) { throw new NotSupportedException( $"Retail dialog type {(uint)type} does not have a ported presenter yet."); @@ -435,10 +415,6 @@ public sealed class RetailDialogFactory : IDisposable new RetailConfirmationTextInputDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), - RetailDialogType.ConfirmationMenu => - new RetailConfirmationMenuDialogView( - _host, layout, info.Data, info.Context, - context => CloseDialog(context)), _ => new RetailConfirmationDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), diff --git a/src/AcDream.App/UI/Layout/RetailKeyNames.cs b/src/AcDream.App/UI/Layout/RetailKeyNames.cs index a641fa61..770bb90b 100644 --- a/src/AcDream.App/UI/Layout/RetailKeyNames.cs +++ b/src/AcDream.App/UI/Layout/RetailKeyNames.cs @@ -37,7 +37,8 @@ namespace AcDream.App.UI.Layout; /// / DIK_LMENU), names its METAKEY through the meta table + OS fallback and /// joins with the authored ID_KeyDescDelimiter ("+", table enum 3 → /// DID 0x23000007). A binding whose KEY IS a modifier key (retail's -/// walk-mode DIK_LSHIFT row has meta-mode 0) shows only the key name — never +/// walk-mode DIK_LSHIFT row has meta-mode 0; acdream's +/// carries the wire-side self-modifier bit) shows only the key name — never /// "Shift+ShiftLeft". /// /// @@ -78,34 +79,21 @@ public sealed class RetailKeyNames /// /// Display name for one bound chord — retail - /// GetNameFromKey(QualifiedControl). Mouse controls use retail's - /// DIMOFS semantic/table lookup. If the table misses, DirectInput would - /// provide a localized object name; acdream's non-DirectInput fallback is - /// the stable user-facing "Mouse Button N". + /// GetNameFromKey(QualifiedControl). Mouse chords keep the + /// pre-existing enum spelling: retail names mouse controls through the + /// DirectInput mouse device, which this port does not have (AD-95a). /// public string Describe(KeyChord chord) { if (chord == default) return string.Empty; - if (TryGetMouseSemantic(chord, out string? mouseSemantic, out int buttonNumber)) - { - string mouseName = _resolveString( - KeyNameTableId, - DatStringResolver.ComputeHash(mouseSemantic!)) - ?? $"Mouse Button {buttonNumber}"; - return Compose(chord, mouseName); - } if (!TryGetDik(chord.Key, out byte dik, out string? dikName)) return FallbackSpelling(chord); - return Compose(chord, LookupName(dikName!, dik, KeyNameTableId)); - } - - private string Compose(KeyChord chord, string keyName) - { var composed = new System.Text.StringBuilder(); // Meta-mode bits ascending, skipping the key's own self-modifier bit - // (retail's walk-mode LSHIFT row carries meta-mode 0 on the wire). + // (retail's walk-mode LSHIFT row carries meta-mode 0 on the wire; the + // chord's stored self bit is acdream's encoding, not display truth). foreach ((ModifierMask flag, Key metaKey) in MetaOrder) { if ((chord.Modifiers & flag) == 0 || IsSelfModifier(chord.Key, flag)) @@ -116,36 +104,10 @@ public sealed class RetailKeyNames composed.Append(_delimiter); } - composed.Append(keyName); + composed.Append(LookupName(dikName!, dik, KeyNameTableId)); return composed.ToString(); } - private static bool TryGetMouseSemantic( - KeyChord chord, - out string? semantic, - out int buttonNumber) - { - int zeroBased = (int)chord.Key switch - { - -1001 => 0, - -1002 => 1, - -1003 => 2, - -1004 => 3, - -1005 => 4, - _ => -1, - }; - if (chord.Device != 1 || zeroBased < 0) - { - semantic = null; - buttonNumber = 0; - return false; - } - - semantic = $"DIMOFS_BUTTON{zeroBased}"; - buttonNumber = zeroBased + 1; - return true; - } - private string LookupName(string dikName, byte dik, uint tableId) => _resolveString(tableId, DatStringResolver.ComputeHash(dikName)) ?? _osKeyName?.Invoke((byte)(dik & 0x7F), (dik & 0x80) != 0) @@ -164,7 +126,6 @@ public sealed class RetailKeyNames (ModifierMask.Shift, Key.ShiftLeft), (ModifierMask.Ctrl, Key.ControlLeft), (ModifierMask.Alt, Key.AltLeft), - (ModifierMask.Win, Key.SuperLeft), }; private static bool IsSelfModifier(Key key, ModifierMask flag) @@ -173,15 +134,15 @@ public sealed class RetailKeyNames ModifierMask.Shift => key is Key.ShiftLeft or Key.ShiftRight, ModifierMask.Ctrl => key is Key.ControlLeft or Key.ControlRight, ModifierMask.Alt => key is Key.AltLeft or Key.AltRight, - ModifierMask.Win => key is Key.SuperLeft or Key.SuperRight, _ => false, }; /// /// Silk key → DirectInput scan code + DIK name — the reverse of - /// 's keyboard table: the 84 - /// DAT-default codes plus the additional controls accepted by retail's - /// plain-text keymap format. DIK codes with bit 0x80 are the extended set — the + /// 's keyboard table (same 84 + /// DAT-observed codes) plus the modifier keys live capture can produce + /// that no DAT default binds directly (DIK_LCONTROL 0x1D, DIK_LMENU 0x38, + /// DIK_RMENU 0xB8). DIK codes with bit 0x80 are the extended set — the /// same split Win32's GetKeyNameText expects in bit 24. /// private static bool TryGetDik(Key key, out byte dik, out string? name) @@ -245,7 +206,6 @@ public sealed class RetailKeyNames Key.KeypadMultiply => ((byte)0x37, "DIK_MULTIPLY"), Key.AltLeft => ((byte)0x38, "DIK_LMENU"), Key.Space => ((byte)0x39, "DIK_SPACE"), - Key.CapsLock => ((byte)0x3A, "DIK_CAPITAL"), Key.F1 => ((byte)0x3B, "DIK_F1"), Key.F2 => ((byte)0x3C, "DIK_F2"), Key.F3 => ((byte)0x3D, "DIK_F3"), @@ -273,15 +233,10 @@ public sealed class RetailKeyNames Key.KeypadDecimal => ((byte)0x53, "DIK_DECIMAL"), Key.F11 => ((byte)0x57, "DIK_F11"), Key.F12 => ((byte)0x58, "DIK_F12"), - Key.F13 => ((byte)0x64, "DIK_F13"), - Key.F14 => ((byte)0x65, "DIK_F14"), - Key.F15 => ((byte)0x66, "DIK_F15"), Key.KeypadEnter => ((byte)0x9C, "DIK_NUMPADENTER"), Key.ControlRight => ((byte)0x9D, "DIK_RCONTROL"), Key.KeypadDivide => ((byte)0xB5, "DIK_DIVIDE"), - Key.PrintScreen => ((byte)0xB7, "DIK_SYSRQ"), Key.AltRight => ((byte)0xB8, "DIK_RMENU"), - Key.Pause => ((byte)0xC5, "DIK_PAUSE"), Key.Home => ((byte)0xC7, "DIK_HOME"), Key.Up => ((byte)0xC8, "DIK_UP"), Key.PageUp => ((byte)0xC9, "DIK_PRIOR"), @@ -292,9 +247,6 @@ public sealed class RetailKeyNames Key.PageDown => ((byte)0xD1, "DIK_NEXT"), Key.Insert => ((byte)0xD2, "DIK_INSERT"), Key.Delete => ((byte)0xD3, "DIK_DELETE"), - Key.SuperLeft => ((byte)0xDB, "DIK_LWIN"), - Key.SuperRight => ((byte)0xDC, "DIK_RWIN"), - Key.Menu => ((byte)0xDD, "DIK_APPS"), _ => ((byte)0, null), }; return name is not null; diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs index 8f54e684..d217dcaa 100644 --- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs +++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs @@ -626,59 +626,15 @@ public sealed class RetailTooltipPresenter : IDisposable ? bitmapFont.MeasureWidth : static s => s.Length * 8f; - // CA5-gate correction (2026-08-24, owner retail-render oracle): - // retail sizes a tooltip in TWO passes, and the second is what makes - // long tooltips multi-line. StartTooltip @0x0045DE90: - // 1. MEASURE — InqSizewMargins(..., UITS_MAX_WIDTH): wrap at the - // authored P0x3D max width, else the display width, producing - // the measured extent. - // 2. Resize the ROOT by the measured-vs-authored delta through - // ResizeTo, where the popup skin's authored max/min CLAMP. - // 3. RecalculateGlyphList — the text RE-WRAPS at its FINAL - // (possibly clamped) width. - // 4. A second ResizeTo grows the root's HEIGHT (width unchanged) - // when the re-wrapped glyph extent needs more than the text - // child's current height. - // The pre-correction port did only pass 1, so a description longer - // than the clamped popup stayed one clipped line. + float wrapWidth = MathF.Max(1f, _host.EffectiveCanvasSize.X); + var wrapped = UiText.WrapWords(tooltipText, measure, wrapWidth); + text.LinesProvider = () => wrapped + .Select(line => new UiText.Line(line, text.DefaultColor)) + .ToArray(); + + float measuredWidth = wrapped.Count == 0 ? 0f : wrapped.Max(measure); float lineHeight = text.DatFont?.LineHeight ?? text.Font?.LineHeight ?? 14f; - - // CA5 re-check corrections (2026-08-24, owner screenshots vs retail): - // (a) The popup skins' TEXT CHILD (0x10000396) authors P0x3D=256 — - // live-DAT probed on all four skins. InqSizewMargins' - // UITS_MAX_WIDTH branch reads GetAttribute_Int(0x3D) on the TEXT - // element BEFORE the display-width fallback, so retail wraps - // tooltip text at 256px, not the screen width. (TS-85's "zero - // 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; - - // (c) The text child's authored margins (P0x23-0x26 — L2/R2/U2/D2 on - // the popup skins) participate exactly as InqSizewMargins does: - // GlyphList::Recalculate wraps at (width − margL − margR) and - // the measured result adds the margins back - // (@0x00469762/@0x004697..: `Recalculate(..., w − margL − margR)` - // then `*out += margR + margL`). Without this, line one fit one - // more word than retail (256 vs retail's 252 wrap) and the text - // drew flush against the parchment's right border. - float marginsX = text.MarginLeft + text.MarginRight; - float marginsY = text.MarginTop + text.MarginBottom; - - float wrapBound = text.AuthoredResizeMaxWidth is { } authoredMaxTextWidth - ? MathF.Max(1f, authoredMaxTextWidth) - : MathF.Max(1f, _host.EffectiveCanvasSize.X); - float measureWrapWidth = MathF.Max(1f, wrapBound - marginsX); - var measured = UiText.WrapWords(tooltipText, measure, measureWrapWidth); - float measuredWidth = - (measured.Count == 0 ? 0f : measured.Max(measure)) + marginsX; - float measuredHeight = measured.Count * lineHeight + marginsY; + float measuredHeight = wrapped.Count * lineHeight; float requestedWidth = root.Width + (measuredWidth - authoredTextWidth); float requestedHeight = root.Height + (measuredHeight - authoredTextHeight); @@ -695,46 +651,10 @@ public sealed class RetailTooltipPresenter : IDisposable if (root.AuthoredResizeMinWidth is { } minWidth && requestedWidth < minWidth) requestedWidth = minWidth; - float authoredRootWidth = root.Width; - float authoredRootHeight = root.Height; root.Width = requestedWidth; root.Height = requestedHeight; - - // The text child follows the root's ACTUAL growth (retail's - // anchored resize) — the clamp is what makes these differ from the - // measured extents. - float textFinalWidth = MathF.Max( - 1f, authoredTextWidth + (requestedWidth - authoredRootWidth)); - if (text.AuthoredResizeMaxWidth is { } textMaxWidth) - textFinalWidth = MathF.Min(textFinalWidth, textMaxWidth); - float textFinalHeight = - authoredTextHeight + (requestedHeight - authoredRootHeight); - - // Pass 3: re-wrap at the final clamped width, inside the margins - // (RecalculateGlyphList wraps glyphs at width − margL − margR). - var wrapped = UiText.WrapWords( - tooltipText, measure, MathF.Max(1f, textFinalWidth - marginsX)); - text.LinesProvider = () => wrapped - .Select(line => new UiText.Line(line, text.DefaultColor)) - .ToArray(); - text.Width = wrapped.Count == 0 - ? 0f - : MathF.Min(textFinalWidth, wrapped.Max(measure) + marginsX); - float rewrappedHeight = wrapped.Count * lineHeight + marginsY; - - // Pass 4: grow the root's height by the re-wrap's overflow beyond - // the text child's post-resize height (width unchanged), through - // the same ResizeTo clamps. - if (rewrappedHeight > textFinalHeight) - { - float grownHeight = root.Height + (rewrappedHeight - textFinalHeight); - if (root.AuthoredResizeMaxHeight is { } maxH2 && grownHeight > maxH2) - grownHeight = maxH2; - if (root.AuthoredResizeMinHeight is { } minH2 && grownHeight < minH2) - grownHeight = minH2; - root.Height = grownHeight; - } - text.Height = rewrappedHeight; + text.Width = measuredWidth; + text.Height = measuredHeight; } /// Retail UIElementManager::StartTooltip @0x00459700: the diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs index 2db7d37f..d9b22a22 100644 --- a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs +++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs @@ -43,30 +43,9 @@ public static class RetailWindowFrame /// /// Optional DAT element whose effective 0x3C..0x3F properties provide /// max-height, max-width, min-height, and min-width respectively. Explicit - /// option values win; wrapper chrome is added to DAT content constraints - /// UNLESS is set. + /// option values win; wrapper chrome is added to DAT content constraints. /// public ElementInfo? DatConstraintSource { get; init; } - - /// - /// True when is itself retail's own - /// OUTER window frame — chrome already included in its authored - /// 0x3C..0x3F values — rather than a content element the wrapper's own - /// chrome gets layered on top of. CT6 fix-round BLOCKER B1: the - /// Character window's resize clamp is authored on the shared - /// gmPanelUI host (0x100005FE, LayoutDesc - /// 0x2100006E) — that host IS retail's outer frame (5px bevel + - /// 300x362 content + 5px = 310x372), so its authored MinWidth/MinHeight/ - /// MaxWidth/MaxHeight ALREADY include the chrome our NineSlice wrapper - /// draws. Adding the wrapper's own chrome inset on top double-counts - /// the bevel and clamps the mounted window BELOW its own authored - /// minimum. Set true only when the DAT source element genuinely already - /// represents the complete outer frame, not a content root — the - /// default (false) preserves the normal content-plus-chrome behavior - /// every other window relies on. - /// - public bool DatConstraintSourceIsOuterFrame { get; init; } - public float? MinWidth { get; init; } public float? MinHeight { get; init; } public float? MaxWidth { get; init; } @@ -178,36 +157,16 @@ public static class RetailWindowFrame outerFrame.Opacity = Math.Clamp(options.Opacity, 0f, 1f); outerFrame.Visible = options.Visible; - int constraintInset = options.DatConstraintSourceIsOuterFrame ? 0 : inset; outerFrame.MinWidth = ResolveConstraint( - options.MinWidth, options.DatConstraintSource, 0x3Fu, outerWidth, constraintInset); + options.MinWidth, options.DatConstraintSource, 0x3Fu, outerWidth, inset); outerFrame.MinHeight = ResolveConstraint( - options.MinHeight, options.DatConstraintSource, 0x3Eu, outerHeight, constraintInset); + options.MinHeight, options.DatConstraintSource, 0x3Eu, outerHeight, inset); outerFrame.MaxWidth = Math.Max( outerFrame.MinWidth, - ResolveConstraint(options.MaxWidth, options.DatConstraintSource, 0x3Du, float.MaxValue, constraintInset)); + ResolveConstraint(options.MaxWidth, options.DatConstraintSource, 0x3Du, float.MaxValue, inset)); outerFrame.MaxHeight = Math.Max( outerFrame.MinHeight, - ResolveConstraint(options.MaxHeight, options.DatConstraintSource, 0x3Cu, float.MaxValue, constraintInset)); - - // Mount-time invariant (CT6 fix-round BLOCKER B1): a window must never - // open already violating its own clamp. This would have caught B1 at - // the first test run — the pre-fix Character mount computed outer - // bounds of 320/382/1010 (300x362 content + 10px chrome inset added - // TWICE) while its own MinWidth clamped to 320 but its actual outer - // WIDTH was only 310, silently sitting below its own minimum until - // RetailWindowManager.ResizeTo forcibly widened it despite ResizeX - // being false. - if (outerWidth < outerFrame.MinWidth || outerWidth > outerFrame.MaxWidth) - throw new InvalidOperationException( - $"RetailWindowFrame.Mount(\"{options.WindowName}\"): mounted outer width " + - $"{outerWidth} is outside its own clamp [{outerFrame.MinWidth}, {outerFrame.MaxWidth}] " + - "— the window would open already violating its authored resize bounds."); - if (outerHeight < outerFrame.MinHeight || outerHeight > outerFrame.MaxHeight) - throw new InvalidOperationException( - $"RetailWindowFrame.Mount(\"{options.WindowName}\"): mounted outer height " + - $"{outerHeight} is outside its own clamp [{outerFrame.MinHeight}, {outerFrame.MaxHeight}] " + - "— the window would open already violating its authored resize bounds."); + ResolveConstraint(options.MaxHeight, options.DatConstraintSource, 0x3Cu, float.MaxValue, inset)); // Capture the wrapper/content baseline at the mounted design extent now, // so a resize that occurs before the first draw uses the same margins as a diff --git a/src/AcDream.App/UI/Layout/SelectedObjectController.cs b/src/AcDream.App/UI/Layout/SelectedObjectController.cs index 34d0aa42..7076e794 100644 --- a/src/AcDream.App/UI/Layout/SelectedObjectController.cs +++ b/src/AcDream.App/UI/Layout/SelectedObjectController.cs @@ -94,8 +94,6 @@ public sealed class SelectedObjectController : IRetainedPanelController private readonly StackSplitQuantityState _splitQuantity; private readonly SelectionState _selection; private readonly Func _isVendorSplitExempt; - private readonly Func _isCoinstack; - private readonly Func _coinTotal; private readonly Action> _unsubscribeHealthChanged; private readonly Action> _unsubscribeItemManaChanged; private readonly Action> _unsubscribeObjectUpdated; @@ -130,9 +128,7 @@ public sealed class SelectedObjectController : IRetainedPanelController StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, Action> unsubscribeObjectUpdated, - Func isVendorSplitExempt, - Func? isCoinstack, - Func? coinTotal) + Func isVendorSplitExempt) { _isHealthTarget = isHealthTarget; _isOwnedByPlayer = isOwnedByPlayer; @@ -147,8 +143,6 @@ public sealed class SelectedObjectController : IRetainedPanelController _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _isVendorSplitExempt = isVendorSplitExempt ?? throw new ArgumentNullException(nameof(isVendorSplitExempt)); - _isCoinstack = isCoinstack ?? (_ => false); - _coinTotal = coinTotal ?? (() => 0); _unsubscribeHealthChanged = unsubscribeHealthChanged; _unsubscribeItemManaChanged = unsubscribeItemManaChanged; _unsubscribeObjectUpdated = unsubscribeObjectUpdated; @@ -325,9 +319,7 @@ public sealed class SelectedObjectController : IRetainedPanelController StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, Action> unsubscribeObjectUpdated, - Func isVendorSplitExempt, - Func? isCoinstack = null, - Func? coinTotal = null) + Func isVendorSplitExempt) => new SelectedObjectController( layout, selection, subscribeHealthChanged, unsubscribeHealthChanged, @@ -335,7 +327,7 @@ public sealed class SelectedObjectController : IRetainedPanelController isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize, sendQueryHealth, manaPercent, sendQueryItemMana, datFont, splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated, - isVendorSplitExempt, isCoinstack, coinTotal); + isVendorSplitExempt); /// /// Port of gmToolbarUI::HandleSelectionChanged (:198635): @@ -381,11 +373,16 @@ public sealed class SelectedObjectController : IRetainedPanelController // ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ── uint stackSize = _stackSize(g); string? objectName = _resolveName(g); - _currentName = _isCoinstack(g) && _isOwnedByPlayer(g) - ? $"{stackSize} {objectName} (of {_coinTotal()})" - : stackSize > 1u && !string.IsNullOrEmpty(objectName) - ? $"{stackSize} {objectName}" - : objectName; + _currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName) + ? $"{stackSize} {objectName}" + : objectName; + + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] ApplySelection guid=0x{g:X8} stackSizeOperand={stackSize} " + + $"objectName={objectName ?? "null"} builtLabel={_currentName ?? "null"}"); + } // ── 3. Selection overlay: brief flash (retail container ObjectSelected // = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ────────────── @@ -413,6 +410,18 @@ public sealed class SelectedObjectController : IRetainedPanelController _splitQuantity.Reset(stackSize, initialValue: seed); if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true; if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true; + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=true " + + $"isVendorSplitExempt={vendorSplitExempt} maxSplitSize={stackSize} seed={seed}"); + } + } + else if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=false " + + $"failingPredicate=stackSize<=1u stackSize={stackSize}"); } // ── 4. Health: query, and show the meter only if real health is already known. @@ -532,26 +541,6 @@ public sealed class SelectedObjectController : IRetainedPanelController } } - /// - /// Retail gmToolbarUI::RecvNotice_SplitStack @ 0x004BD2A0: when - /// the notice still names the selected stack and its size is greater than - /// one, focus the numeric quantity field and select all of its text. - /// - public bool FocusSplitStackEntry(uint objectId) - { - if (_current != objectId - || _stackSize(objectId) <= 1u - || _stackSizeEntry is null - || !_stackSizeEntry.Visible) - { - return false; - } - - _stackSizeEntry.FindRoot()?.SetKeyboardFocus(_stackSizeEntry); - _stackSizeEntry.SelectAllText(); - return true; - } - private void OnObjectUpdated(ClientObject updated) { if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum) diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs index 50316f5f..2ae0d719 100644 --- a/src/AcDream.App/UI/Layout/SocialPanelController.cs +++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs @@ -243,8 +243,6 @@ public sealed class SocialPanelController : IRetainedPanelController /// F4 ToggleFellowshipPanel's tab-switch half. public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId); - public void ShowFriends() => _tabPanel.SwitchTo(FriendsPageId); - /// True when the Allegiance tab is the active page — lets /// implement the /// close-on-second-press-of-the-SAME-tab semantics every other @@ -266,8 +264,6 @@ public sealed class SocialPanelController : IRetainedPanelController /// True when the Fellowship tab is the active page. public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId; - public bool IsShowingFriends => _tabPanel.ActivePageElementId == FriendsPageId; - /// True while the social panel's own window is shown — set by /// /. Fix-round blast SF-2: /// gates the Friends/Squelch rebuild (see ) so their diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs index dc888539..2d4bc0ac 100644 --- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs +++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs @@ -216,8 +216,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController public bool Handle(InputAction action) { - if (TryMapSpellShortcut(action, out int index)) + if (action is >= InputAction.UseSpellSlot_1 and <= InputAction.UseSpellSlot_9) { + int index = (int)action - (int)InputAction.UseSpellSlot_1; IReadOnlyList spells = _spellbook.GetFavorites(_activeTab); if (index < spells.Count) { @@ -242,27 +243,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController } } - internal static bool TryMapSpellShortcut( - InputAction action, - out int index) - { - if (action is >= InputAction.UseSpellSlot_1 - and <= InputAction.UseSpellSlot_9) - { - index = (int)action - (int)InputAction.UseSpellSlot_1; - return true; - } - - index = action switch - { - InputAction.UseSpellSlot_10 => 9, - InputAction.UseSpellSlot_11 => 10, - InputAction.UseSpellSlot_12 => 11, - _ => -1, - }; - return index >= 0; - } - private void SelectTab(int tab) { _activeTab = Math.Clamp(tab, 0, 7); diff --git a/src/AcDream.App/UI/Layout/ToolbarInputController.cs b/src/AcDream.App/UI/Layout/ToolbarInputController.cs index 4c9605b5..d298da41 100644 --- a/src/AcDream.App/UI/Layout/ToolbarInputController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarInputController.cs @@ -56,23 +56,6 @@ public sealed class ToolbarInputController return true; } - if (action is InputAction.UseQuickSlot_10 - or InputAction.UseQuickSlot_11 - or InputAction.UseQuickSlot_12 - or InputAction.UseQuickSlot_13) - { - slot = action switch - { - InputAction.UseQuickSlot_10 => 9, - InputAction.UseQuickSlot_11 => 10, - InputAction.UseQuickSlot_12 => 11, - InputAction.UseQuickSlot_13 => 12, - _ => -1, - }; - use = true; - return true; - } - if (value >= (int)InputAction.UseQuickSlot_14 && value <= (int)InputAction.UseQuickSlot_18) { diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index 247268f9..b1ddf49e 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -181,8 +181,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// list, with a real thumb/up/down-button subtree matching /// 's own shape exactly: thumb caps /// 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, + /// 0x10000071) 0x06004C69/6A/6B, down button + /// (element 0x10000072) 0x06004C6C/6D/6E, /// 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 + @@ -288,10 +288,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag private const uint TypeMenuScrollThumbTopSprite = 0x06004C60u; private const uint TypeMenuScrollThumbSprite = 0x06004C63u; private const uint TypeMenuScrollThumbBottomSprite = 0x06004C66u; - // Retail seating (UpdateScrollingArea @0x00470AA0): top = the INCREMENT - // designee's UP-arrow art, bottom = the DECREMENT designee's DOWN-arrow. - private const uint TypeMenuScrollUpSprite = RetailScrollbarChrome.UpNormal; - private const uint TypeMenuScrollDownSprite = RetailScrollbarChrome.DownNormal; + private const uint TypeMenuScrollUpSprite = 0x06004C69u; + private const uint TypeMenuScrollDownSprite = 0x06004C6Cu; /// /// Retail's ordered category table, transcribed verbatim from @@ -381,18 +379,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // X-close confirmation is already up; HandleButtonClicks' 0x100000d6 // case only opens a NEW one when this is 0 (pc:204155). private uint _closeConfirmContext; - private int _lastAlternateCurrencyPurchase; - private bool _alternateCurrencyInventoryObserved; - private PendingVendorSplit? _pendingVendorSplit; // F5: see DragOverGlobalTimeSink's own doc comment. private readonly DragOverGlobalTimeSink _dragOverSink; private bool _disposed; - private readonly record struct PendingVendorSplit( - uint SourceGuid, - uint WeenieClassId, - int Quantity); - private VendorUiController( VendorState vendor, RetailWindowHandle window, @@ -507,7 +497,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // succeeds and this stops being a dead end — closes half of AP-161 // finding #2. _itemList.ExamineItemRequested = ExamineItem; - _itemList.PrimaryItemPressed = PressVendorItem; if (itemScrollbar is not null) { itemScrollbar.Model = _itemList.Scroll; @@ -537,16 +526,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // gate (pc:204229-204246) — the Selling tab's list is the ONLY drop // target. UiItemList.RegisterDragHandler is the structural analogue. _sellingList?.RegisterDragHandler(this); - if (_buyingList is not null) - { - _buyingList.PrimaryItemPressed = PressVendorItem; - _buyingList.ExamineItemRequested = ExamineItem; - } - if (_sellingList is not null) - { - _sellingList.PrimaryItemPressed = PressVendorItem; - _sellingList.ExamineItemRequested = ExamineItem; - } // F5: mount the global-time sink so a live drag hovering anywhere // over this window auto-switches to the Selling tab — see @@ -656,10 +635,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // separate "staging changed" gate from "holdings changed" (both // UpdateTotalValue calls read the LIVE holding fresh, same as // BuildCostText's own PropertyInt.CoinValue read). - _objects.ObjectAdded += OnObjectAdded; _objects.ObjectUpdated += OnObjectMoneyChanged; - _objects.StackSizeUpdated += OnStackSizeUpdated; - _objects.ObjectMoved += OnObjectMoved; ShowTab(VendorPanelTab.Items); ClearContent(); @@ -683,7 +659,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // mechanism every other panel already uses, not a vendor-specific // special case. _objects.ObjectRemoved += OnObjectRemoved; - _itemInteraction.RuntimeTransactions.Inventory.RequestFailed += OnInventoryRequestFailed; // Slice 6.3: mirrors ExternalContainerController's own // _itemInteraction.StateChanged subscription — the Buy button must // disable the instant a reservation is taken (BeginUseRequestReservation @@ -889,14 +864,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag private void ShowTab(VendorPanelTab tab) { - // gmVendorUI::OpenTab resets m_last_sale. Authoritative inventory - // remains the preferred source; this only clears the optimistic - // post-buy subtraction used before that update arrives. - if (_lastAlternateCurrencyPurchase != 0) - { - _lastAlternateCurrencyPurchase = 0; - RefreshMoneyText(); - } _itemsPage.Visible = tab == VendorPanelTab.Items; _buyingPage.Visible = tab == VendorPanelTab.Buying; _sellingPage.Visible = tab == VendorPanelTab.Selling; @@ -925,9 +892,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // staging lists the same way the category selection resets. _buyStaging.Clear(); _sellStaging.Clear(); - _pendingVendorSplit = null; - ResetAlternateCurrencyTracking(); - RefreshMoneyText(); _selectedCategoryIndex = -1; ShowTab(VendorPanelTab.Items); RebuildCategories(); @@ -944,8 +908,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // the time this fires the relevant list is already empty in // the normal flow, and the OTHER (untouched) list must // survive a refresh triggered by its sibling. - ResetAlternateCurrencyTracking(); - RefreshMoneyText(); ShowTab(VendorPanelTab.Items); RebuildCategories(); _window.Show(); @@ -956,8 +918,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // the session (contract's C2/C3 close semantics). _buyStaging.Clear(); _sellStaging.Clear(); - _pendingVendorSplit = null; - ResetAlternateCurrencyTracking(); ClearContent(); ShowTab(VendorPanelTab.Items); _window.Hide(); @@ -1150,10 +1110,15 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag cell.SetItem(item.ItemGuid, icon); cell.Selected = item.ItemGuid == selectedGuid; VendorShopItem captured = item; - cell.Clicked = () => - _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); - // gmVendorUI::HandleMousePresses @ 0x004C40D0: a - // double-click in the browse list calls BuySingleItem. + cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); + // AP-171: double-click buys the item — a DELIBERATE, + // user-approved modernization. Retail has NO + // double-click-to-buy anywhere in the named function + // table (negative evidence recorded at the Slice 6 + // research); the user requested it explicitly + // 2026-08-08 after being told so. Select-then-buy so + // the quantity/price path is identical to the Buy + // button's. cell.DoubleClicked = () => { _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); @@ -1285,23 +1250,14 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag SetPlainText(_itemNameText, nameText); VendorShopProfile profile = _vendor.Profile; - int price = ComputeShopItemPrice(item, quantity); + int rawValue = item.Value ?? 0; + int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize); + int price = VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, quantity); SetPlainText(_itemCostText, BuildCostText(profile, quantity, price)); SetActionButtonsEnabled(true); } - private int ComputeShopItemPrice(VendorShopItem item, int quantity) - { - int rawValue = item.Value ?? 0; - int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize); - return VendorPricing.SellPrice( - perUnit, - item.ItemType ?? 0u, - _vendor.Profile.SellPrice, - quantity); - } - /// /// Right-click examine on a shop row — mirrors /// ExternalContainerController.ExamineItem's "select then @@ -1318,13 +1274,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag _itemInteraction.ExamineSelectedOrEnterMode(guid); } - private bool PressVendorItem(uint guid) - { - if (guid != 0u) - _selection.Select(guid, SelectionChangeSource.Vendor); - return false; - } - /// /// Slice 6.2: reacts to ANY global selection change, not just ones this /// panel originated — mirrors ExternalContainerController.OnSelectionChanged. @@ -1439,15 +1388,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// private void OnObjectRemoved(ClientObject item) { - if (IsCurrentAlternateCurrency(item)) - { - _alternateCurrencyInventoryObserved = true; - _lastAlternateCurrencyPurchase = 0; - RefreshMoneyText(); - } - if (_pendingVendorSplit is { } split && split.SourceGuid == item.ObjectId) - _pendingVendorSplit = null; - if (_selection.SelectedObjectId == item.ObjectId) { _selection.Clear( @@ -1521,10 +1461,11 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// (pc:203494-203497) via the SAME /// generic int-property bundle every other PropertyInt-driven display /// reads. The alt-currency holding is retail's - /// shopVendorProfile->trade_num - m_last_sale. This controller - /// mirrors the immediate subtraction after dispatch and then reconciles - /// to the authoritative player-owned currency stacks when their object - /// updates arrive; the profile amount is only the pre-observation fallback. + /// shopVendorProfile->trade_num - m_last_sale; + /// m_last_sale only changes on a completed Slice-6 purchase, so + /// with no purchase mechanism yet this port uses + /// directly + /// (retail's m_last_sale == 0 case — see the register, AP-161). /// /// private string BuildCostText(VendorShopProfile profile, int quantity, int price) @@ -1536,7 +1477,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag "This item costs {0} {1}. You have {2} {1}.", price, profile.AlternateCurrencyPluralName, - ResolveAlternateCurrencyAmount(profile)); + (int)profile.AlternateCurrencyAmount); } int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0; @@ -1633,17 +1574,11 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag return; uint quantity = ResolveBuyQuantity(shopItem); - VendorShopProfile profile = _vendor.Profile; - if (_itemInteraction.TryBuy( - _vendor.VendorId, - shopItem.ItemGuid, - (int)quantity, - profile.AlternateCurrencyWcid)) - { - RecordAlternateCurrencyPurchase( - profile, - ComputeShopItemPrice(shopItem, (int)quantity)); - } + _itemInteraction.TryBuy( + _vendor.VendorId, + shopItem.ItemGuid, + (int)quantity, + _vendor.Profile.AlternateCurrencyWcid); } private bool TryFindShopItem(uint guid, out VendorShopItem shopItem) @@ -1716,9 +1651,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag (int)quantity, _vendor.Profile.AlternateCurrencyWcid)) { - RecordAlternateCurrencyPurchase( - _vendor.Profile, - ComputeShopItemPrice(shopItem, (int)quantity)); _buyStaging.Remove(shopItem.ItemGuid, BuyStagingRemovalAmount(shopItem)); } } @@ -1750,8 +1682,11 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// /// pyreal affordability — transaction total vs. purse /// (pc:204017: m_transactionValue <= m_totalValue). - /// alt-currency affordability — vs. the authoritative held trade - /// currency minus m_last_sale (pc:204032). + /// alt-currency affordability — vs. held trade currency minus + /// m_last_sale (pc:204032). This session tracks no + /// m_last_sale credit yet (see the register's AP-161 residual), + /// so this uses the vendor's raw held count, retail's own + /// m_last_sale == 0 case. /// container-slot capacity (pc:204053: /// containerSlotsNeeded > player.ContainersCapacity - containersUsed). /// item-slot capacity (pc:204067: the same shape for @@ -1816,7 +1751,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag return; } } - else if (transactionValue > ResolveAlternateCurrencyAmount(profile)) + else if (transactionValue > (int)profile.AlternateCurrencyAmount) { _systemMessage?.Invoke(NotEnoughMoneyMessage); return; @@ -1841,10 +1776,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag } if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, profile.AlternateCurrencyWcid)) - { - RecordAlternateCurrencyPurchase(profile, transactionValue); _buyStaging.Clear(); - } } /// F1: the SAME per-row price formula shows, summed over every staged entry. @@ -1970,7 +1902,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag return string.Format( CultureInfo.InvariantCulture, "You have {0} {1}.", - ResolveAlternateCurrencyAmount(profile), + (int)profile.AlternateCurrencyAmount, profile.AlternateCurrencyPluralName); } int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0; @@ -2027,52 +1959,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// private void OnObjectMoneyChanged(ClientObject updated) { - TryResolvePendingVendorSplit(updated); if (updated.ObjectId != _playerGuid()) return; - RefreshMoneyText(); - } - - private void OnObjectAdded(ClientObject item) - { - TryResolvePendingVendorSplit(item); - if (IsCurrentAlternateCurrency(item) - && _objects.IsOwnedByObject(item.ObjectId, _playerGuid())) - { - ReconcileAlternateCurrencyInventory(); - } - } - - private void OnStackSizeUpdated(ClientObject item) - { - if (IsCurrentAlternateCurrency(item) - && _objects.IsOwnedByObject(item.ObjectId, _playerGuid())) - { - ReconcileAlternateCurrencyInventory(); - } - } - - private void OnObjectMoved(ClientObjectMove move) - { - if (move.Item is not { } item) - return; - - TryResolvePendingVendorSplit(item); - if (!IsCurrentAlternateCurrency(item)) - return; - - ReconcileAlternateCurrencyInventory(); - } - - private void ReconcileAlternateCurrencyInventory() - { - _alternateCurrencyInventoryObserved = true; - _lastAlternateCurrencyPurchase = 0; - RefreshMoneyText(); - } - - private void RefreshMoneyText() - { UpdateBuyTransactionText(); UpdateSellTransactionText(); // Post-buy gate finding (2026-08-08): the Items tab's cost sentence @@ -2082,57 +1970,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag RefreshSelectionDisplay(); } - private bool IsCurrentAlternateCurrency(ClientObject item) - { - uint wcid = _vendor.Profile.AlternateCurrencyWcid; - return wcid != 0u && item.WeenieClassId == wcid; - } - - private int ResolveAlternateCurrencyAmount(VendorShopProfile profile) - { - if (profile.AlternateCurrencyWcid == 0u) - return 0; - - long live = 0; - bool found = false; - foreach (ClientObject item in _objects.Objects) - { - if (item.WeenieClassId != profile.AlternateCurrencyWcid - || !_objects.IsOwnedByObject(item.ObjectId, _playerGuid())) - { - continue; - } - found = true; - live += Math.Max(1, item.StackSize); - } - - long baseline = found || _alternateCurrencyInventoryObserved - ? live - : profile.AlternateCurrencyAmount; - return (int)Math.Clamp( - baseline - _lastAlternateCurrencyPurchase, - 0L, - int.MaxValue); - } - - private void RecordAlternateCurrencyPurchase(VendorShopProfile profile, int price) - { - if (profile.AlternateCurrencyWcid == 0u || price <= 0) - return; - _lastAlternateCurrencyPurchase = price; - RefreshMoneyText(); - } - - private void ResetAlternateCurrencyTracking() - { - _lastAlternateCurrencyPurchase = 0; - uint wcid = _vendor.Profile.AlternateCurrencyWcid; - _alternateCurrencyInventoryObserved = wcid != 0u - && _objects.Objects.Any(item => - item.WeenieClassId == wcid - && _objects.IsOwnedByObject(item.ObjectId, _playerGuid())); - } - /// /// F1: port of gmVendorUI::InqListSlotCount (pc:200038-200065, /// 0x004c0c10) — see 's own doc @@ -2377,9 +2214,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag cell.SetItem(shopItem.ItemGuid, icon); cell.Selected = shopItem.ItemGuid == selectedGuid; VendorShopItem captured = shopItem; - cell.Clicked = () => - _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); - cell.DoubleClicked = () => RemoveOneBuyingUnit(captured.ItemGuid); + cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); list.AddItem(cell); } } @@ -2412,16 +2247,13 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems(), - AllowDragSource = true, - SourceKind = ItemDragSource.Inventory, + AllowDragSource = false, TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(), }; cell.SetItem(item.ObjectId, icon); cell.Selected = item.ObjectId == selectedGuid; uint captured = item.ObjectId; - cell.Clicked = () => - _selection.Select(captured, SelectionChangeSource.Vendor); - cell.DoubleClicked = () => RemoveSellingEntry(captured); + cell.Clicked = () => _selection.Select(captured, SelectionChangeSource.Vendor); list.AddItem(cell); } } @@ -2433,62 +2265,15 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // gate, pc:204229-204246) ────────────────────────────────────────────── /// - /// Retail RecvNotice_ItemListBeginDrag @ 0x004C4380: lifting an - /// already-staged Selling row removes it in full. A partial toolbar split - /// is not applied to this list; retail prints the literal refusal and - /// restores the slider to its maximum. + /// The Selling list never sources a drag of its own — every staged cell + /// sets AllowDragSource = false (F3, Slice 6 review), the same + /// non-drag-source convention every vendor row uses — so + /// 's drag-lift dispatch (which routes to the + /// SOURCE list's own registered handler) can never actually reach this + /// method in practice. Implemented as a no-op for interface completeness. /// public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { - if (!ReferenceEquals(sourceList, _sellingList) || payload.ObjId == 0u) - return; - - _selection.Select(payload.ObjId, SelectionChangeSource.Vendor); - RemoveSellingEntry(payload.ObjId, reportRemoval: false); - if (_objects.Get(payload.ObjId) is not { } item) - return; - - uint fullStack = (uint)Math.Max(1, item.StackSize); - uint selected = _splitQuantity.GetObjectSplitSize( - payload.ObjId, - _selection.SelectedObjectId ?? 0u, - fullStack); - if (selected < fullStack) - { - _itemInteraction.ReportClientLocal( - "You cannot split items from this panel"); - _splitQuantity.Reset(fullStack); - } - } - - private void RemoveOneBuyingUnit(uint itemGuid) - { - if (!_buyStaging.TryGet(itemGuid, out _)) - return; - _selection.Select(itemGuid, SelectionChangeSource.Vendor); - ReportShoppingListRemoval(itemGuid); - _buyStaging.Remove(itemGuid, 1); - } - - private void RemoveSellingEntry(uint itemGuid, bool reportRemoval = true) - { - if (!_sellStaging.TryGet(itemGuid, out _)) - return; - _selection.Select(itemGuid, SelectionChangeSource.Vendor); - if (reportRemoval) - ReportShoppingListRemoval(itemGuid); - _sellStaging.Remove(itemGuid, -1); - } - - private void ReportShoppingListRemoval(uint itemGuid) - { - string? name = _objects.Get(itemGuid)?.GetAppropriateName(); - if (string.IsNullOrWhiteSpace(name)) - name = _vendor.Items.FirstOrDefault(item => item.ItemGuid == itemGuid).Name; - if (string.IsNullOrWhiteSpace(name)) - name = "that item"; - _itemInteraction.ReportClientLocal( - $"Removing {name} from shopping list"); } public ItemDragAcceptance OnDragOver( @@ -2548,9 +2333,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// silent=0, showing a rejection string) chained into /// VendorSellUI::AddItemToSell (pc:203546-203567) on /// success: auto-switch to the "Selling" tab, globally select the - /// dropped item, and stage it. For a partial stack retail first calls - /// AttemptToPlaceInContainer, stages the source as a temporary - /// row, then replaces that row when the new split object arrives. + /// dropped item, stage it. Purely client-local — sends nothing to the + /// server, matching the Buying tab's "Add to List". /// public void HandleDropRelease( UiItemList targetList, @@ -2570,29 +2354,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag ShowTab(VendorPanelTab.Selling); _selection.Select(payload.ObjId, SelectionChangeSource.Vendor); - - ClientObject item = _objects.Get(payload.ObjId)!; - int fullStack = Math.Max(1, item.StackSize); - if (quantity < fullStack) - { - _pendingVendorSplit = new PendingVendorSplit( - payload.ObjId, - item.WeenieClassId, - quantity); - if (!_itemInteraction.TrySplitToContainer( - payload.ObjId, - item.ContainerId, - 0u, - (uint)quantity)) - { - _pendingVendorSplit = null; - _systemMessage?.Invoke("Cannot split the stack to sell it"); - return; - } - - string name = string.IsNullOrWhiteSpace(item.Name) ? "item" : item.Name; - _systemMessage?.Invoke($"Splitting the {name} before selling them"); - } _sellStaging.Add(payload.ObjId, quantity); } @@ -2603,12 +2364,18 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// is the staged quantity a successful drop /// would use. /// - /// Retail's full-stack branch does pass the literal -1 sentinel - /// to AddItemToSell. The enclosing - /// VendorSellUI::AcceptDragObject, however, first compares the - /// live split slider with the maximum and creates a separate stack when - /// they differ. Therefore the quantity exposed here is the live slider - /// amount for stackables, not always the source's full count. + /// F6 (Slice 6b/6c review, byte-verified): this is ALWAYS the item's + /// FULL current stack — retail's VendorSellUI::AddItemToSell + /// (pc:203546-203567) stages via gmVendorUI::AddItem(..., + /// itemGuid, -1, ...), a LITERAL -1 "full stack" sentinel + /// argument, never a slider read. A prior version of this port read the + /// LIVE split-quantity slider here instead (the Slice 6b/6c research + /// doc's Q4 section had flagged this exact source as an unverified + /// inferred analogy to the Buying tab's AddToBuyList) — that + /// inference is now known WRONG: Sell staging has no partial-quantity + /// feature in retail at all, unlike Buy. See + /// VendorStagingList.Add's own doc comment for the Buy side's + /// (genuinely slider-driven) contrast. /// /// private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity) @@ -2632,44 +2399,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag item.PublicWeenieBitfield ?? 0u); if (rejection == VendorSellRejection.None) - { - uint fullStack = (uint)Math.Max(1, item.StackSize); - quantity = (int)_splitQuantity.GetObjectSplitSize( - itemGuid, - _selection.SelectedObjectId ?? 0u, - fullStack); - } + quantity = (int)Math.Max(1, item.StackSize); return rejection; } - private void TryResolvePendingVendorSplit(ClientObject item) - { - if (_pendingVendorSplit is not { } pending - || item.ObjectId == pending.SourceGuid - || item.WeenieClassId != pending.WeenieClassId - || item.StackSize != pending.Quantity - || !_objects.IsOwnedByObject(item.ObjectId, _playerGuid())) - { - return; - } - - if (_sellStaging.Replace(pending.SourceGuid, item.ObjectId)) - _pendingVendorSplit = null; - } - - private void OnInventoryRequestFailed(PendingInventoryRequest request, uint _) - { - if (_pendingVendorSplit is not { } pending - || request.Kind != InventoryRequestKind.SplitToContainer - || request.ItemId != pending.SourceGuid) - { - return; - } - - _sellStaging.Remove(pending.SourceGuid, -1); - _pendingVendorSplit = null; - } - /// /// G4/Slice 6b: port of retail's close/pushpin button handler — /// gmVendorUI::HandleButtonClicks's 0x100000d6 case @@ -2829,12 +2562,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag _disposed = true; _vendor.Changed -= OnVendorChanged; _selection.Changed -= OnSelectionTransition; - _objects.ObjectAdded -= OnObjectAdded; _objects.ObjectRemoved -= OnObjectRemoved; _objects.ObjectUpdated -= OnObjectMoneyChanged; - _objects.StackSizeUpdated -= OnStackSizeUpdated; - _objects.ObjectMoved -= OnObjectMoved; - _itemInteraction.RuntimeTransactions.Inventory.RequestFailed -= OnInventoryRequestFailed; _itemInteraction.StateChanged -= OnInteractionStateChanged; _splitQuantity.Changed -= OnSplitQuantityChanged; _buyStaging.Changed -= RebuildBuyingList; @@ -2850,17 +2579,6 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag _typeMenu.OnSelect = null; _typeMenu.ButtonLabelProvider = null; _itemList.ExamineItemRequested = null; - _itemList.PrimaryItemPressed = null; - if (_buyingList is not null) - { - _buyingList.ExamineItemRequested = null; - _buyingList.PrimaryItemPressed = null; - } - if (_sellingList is not null) - { - _sellingList.ExamineItemRequested = null; - _sellingList.PrimaryItemPressed = null; - } if (_close is not null) _close.OnClick = null; if (_buyButton is not null) diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 17c72a01..c45d3727 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -14,12 +14,6 @@ namespace AcDream.App.UI; /// public static class MarkupDocument { - // Retail's generic runtime-text tooltip skin. Plugin controls have no - // LayoutDesc of their own, so a tooltip= attribute explicitly opts them - // into the same popup that game-code SetTooltip call sites use. - private const uint RuntimeTooltipRootElementId = 0x10000397u; - private const uint RuntimeTooltipLayoutDid = 0x21000041u; - /// Raw XML markup for a single panel. /// Object whose public properties are bound to {PropName} attributes. /// Surface id → (GL handle, width, height) for chrome sprites. @@ -80,47 +74,13 @@ public static class MarkupDocument } foreach (var el in root.Elements()) - AddElement(panel, el, binding, resolve, datFont); - return panel; - } - - private static void AddElement( - UiElement parent, - XElement el, - object binding, - Func resolve, - UiDatFont? datFont) - { - switch (el.Name.LocalName) { - case "group": - var group = new UiPanel - { - Left = F(el, "x"), - Top = F(el, "y"), - Width = F(el, "w"), - Height = F(el, "h"), - BackgroundColor = el.Attribute("background") is null - ? Vector4.Zero - : Color((string?)el.Attribute("background")), - BorderColor = el.Attribute("border") is null - ? Vector4.Zero - : Color((string?)el.Attribute("border")), - BorderThickness = el.Attribute("border") is null ? 0f : 1f, - // Transparent layout groups do not claim empty space, while - // their interactive descendants remain hittable. - ClickThrough = true, - }; - ApplyCommon(group, el, binding); - parent.AddChild(group); - foreach (XElement child in el.Elements()) - AddElement(group, child, binding, resolve, datFont); - break; - - case "meter": + switch (el.Name.LocalName) + { + case "meter": var cur = BindUint((string?)el.Attribute("cur"), binding); var max = BindUint((string?)el.Attribute("max"), binding); - var meter = new UiMeter + panel.AddChild(new UiMeter { Left = F(el, "x"), Top = F(el, "y"), @@ -137,12 +97,10 @@ public static class MarkupDocument FrontLeft = Hex((string?)el.Attribute("frontleft")), FrontTile = Hex((string?)el.Attribute("fronttile")), FrontRight = Hex((string?)el.Attribute("frontright")), - }; - ApplyCommon(meter, el, binding); - parent.AddChild(meter); + }); break; - case "label": + case "label": // Text may be a literal or a {Binding}. Bound labels re-read // their property every frame through the Func, so a plugin // updates its status line by assigning a property rather @@ -156,11 +114,10 @@ public static class MarkupDocument }; if (el.Attribute("color") is not null) label.TextColor = Color((string?)el.Attribute("color")); - ApplyCommon(label, el, binding); - parent.AddChild(label); + panel.AddChild(label); break; - case "button": + case "button": // onclick binds to an Action property on the binding // object. Resolved once at build time: a button whose // handler silently failed to bind is a bug worth failing @@ -194,243 +151,13 @@ public static class MarkupDocument button.TextSource = BindString(caption, binding); if (el.Attribute("color") is not null) button.TextColor = Color((string?)el.Attribute("color")); - if (el.Attribute("background") is not null) - button.BackgroundColor = Color( - (string?)el.Attribute("background")); - if (el.Attribute("border") is not null) - button.BorderColor = Color( - (string?)el.Attribute("border")); - ApplyCommon(button, el, binding); if (onClick is not null) button.Click += onClick; - parent.AddChild(button); + panel.AddChild(button); break; - - case "tab": - string? tabClickName = (string?)el.Attribute("onclick"); - Action? tabClick = BindAction(tabClickName, binding); - if (tabClickName is not null && tabClick is null) - { - throw new FormatException( - $" did not resolve to an " - + $"Action property on {binding.GetType().Name}"); - } - - var tab = new UiMarkupTabButton - { - Left = F(el, "x"), - Top = F(el, "y"), - Width = F(el, "w"), - Height = F(el, "h"), - Text = (string?)el.Attribute("text") ?? string.Empty, - DatFont = datFont, - SelectedSource = BindRequiredBoolReader( - (string?)el.Attribute("selected"), - binding, - "tab selected"), - }; - ApplyCommon(tab, el, binding); - if (tabClick is not null) - tab.Click += tabClick; - parent.AddChild(tab); - break; - - case "toggle": - string? toggleClickName = (string?)el.Attribute("onclick"); - Action? toggleClick = BindAction(toggleClickName, binding); - if (toggleClickName is not null && toggleClick is null) - { - throw new FormatException( - $" did not resolve to an " - + $"Action property on {binding.GetType().Name}"); - } - - string? toggleCaption = (string?)el.Attribute("text"); - var toggle = new UiMarkupToggle - { - Left = F(el, "x"), - Top = F(el, "y"), - Width = F(el, "w"), - Height = F(el, "h"), - Text = toggleCaption ?? string.Empty, - TextSource = BindString(toggleCaption, binding), - CheckedSource = BindRequiredBoolReader( - (string?)el.Attribute("checked"), - binding, - "toggle checked"), - DatFont = datFont, - Toggle = toggleClick, - }; - if (el.Attribute("color") is not null) - toggle.TextColor = Color((string?)el.Attribute("color")); - ApplyCommon(toggle, el, binding); - parent.AddChild(toggle); - break; - - case "slider": - string? changeName = (string?)el.Attribute("onchange"); - Action? changed = BindFloatAction(changeName, binding); - if (changeName is not null && changed is null) - { - throw new FormatException( - $" did not resolve to an " - + $"Action property on {binding.GetType().Name}"); - } - - var slider = new UiScrollbar - { - Left = F(el, "x"), - Top = F(el, "y"), - Width = F(el, "w"), - Height = F(el, "h"), - Horizontal = true, - SpriteResolve = resolve, - ScalarPositionSource = BindFloat( - (string?)el.Attribute("value"), - binding), - ScalarChanged = changed, - }; - RetailScrollbarChrome.ApplyHorizontal(slider); - ApplyCommon(slider, el, binding); - parent.AddChild(slider); - break; - - case "field": - string? fieldChangeName = (string?)el.Attribute("onchange"); - Action? fieldChanged = BindStringAction( - fieldChangeName, - binding); - if (fieldChangeName is not null && fieldChanged is null) - { - throw new FormatException( - $" did not resolve to an " - + $"Action property on {binding.GetType().Name}"); - } - string? submitName = (string?)el.Attribute("onsubmit"); - Action? submitted = BindStringAction(submitName, binding); - if (submitName is not null && submitted is null) - { - throw new FormatException( - $" did not resolve to an " - + $"Action property on {binding.GetType().Name}"); - } - - var field = new UiField - { - Left = F(el, "x"), - Top = F(el, "y"), - Width = F(el, "w"), - Height = F(el, "h"), - DatFont = datFont, - BackgroundColor = el.Attribute("background") is null - ? new Vector4(0f, 0f, 0f, 0.9f) - : Color((string?)el.Attribute("background")), - TextColor = el.Attribute("color") is null - ? new Vector4(0.91f, 0.87f, 0.76f, 1f) - : Color((string?)el.Attribute("color")), - MaxCharacters = Math.Max(1, I(el, "maxlength", 128)), - ClearOnSubmit = B(el, "clearonsubmit", false), - RecordHistory = false, - OnTextChanged = fieldChanged, - OnSubmit = submitted, - }; - field.SetText(BindString((string?)el.Attribute("text"), binding)()); - ApplyCommon(field, el, binding); - parent.AddChild(field); - break; - - case "menu": - string? menuChangeName = (string?)el.Attribute("onchange"); - Action? menuChanged = BindStringAction( - menuChangeName, - binding); - if (menuChangeName is not null && menuChanged is null) - { - throw new FormatException( - $" did not resolve to an " - + $"Action property on {binding.GetType().Name}"); - } - Func> menuItems = BindStringList( - (string?)el.Attribute("items"), - binding, - "menu items"); - Func menuSelected = BindString( - (string?)el.Attribute("selected"), - binding); - var menu = new UiMenu - { - Left = F(el, "x"), - Top = F(el, "y"), - Width = F(el, "w"), - Height = F(el, "h"), - DatFont = datFont, - SpriteResolve = resolve, - RowsPerColumn = Math.Max(1, I(el, "rows", 7)), - RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)), - ColumnWidth = Math.Max(20f, F(el, "w")), - OpenUpward = B(el, "openupward", false), - TextIndent = 6f, - ButtonTextIndent = 6f, - NormalSprite = 0x06004D65u, - PressedSprite = 0x06004D66u, - PopupBgSprite = 0x0600124Cu, - ItemNormalSprite = 0x0600124Eu, - ItemHighlightSprite = 0x0600124Du, - ButtonLabelProvider = () => menuSelected() ?? string.Empty, - OnSelect = payload => - { - if (payload is string value) - menuChanged?.Invoke(value); - }, - }; - void RefreshMenu() - { - menu.Items = menuItems() - .Select(static value => new UiMenu.MenuItem(value, value)) - .ToArray(); - menu.Selected = menuSelected(); - } - RefreshMenu(); - menu.BeforeOpen = RefreshMenu; - ApplyCommon(menu, el, binding); - parent.AddChild(menu); - break; - - case "list": - string? listChangeName = (string?)el.Attribute("onchange"); - Action? listChanged = BindIntAction(listChangeName, binding); - if (listChangeName is not null && listChanged is null) - { - throw new FormatException( - $" did not resolve to an " - + $"Action property on {binding.GetType().Name}"); - } - var list = new UiMarkupList - { - Left = F(el, "x"), - Top = F(el, "y"), - Width = F(el, "w"), - Height = F(el, "h"), - RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)), - DatFont = datFont, - ItemsSource = BindStringList( - (string?)el.Attribute("items"), - binding, - "list items"), - ItemColorsSource = BindUintList( - (string?)el.Attribute("colors"), - binding, - "list colors"), - SelectedIndexSource = BindRequiredIntReader( - (string?)el.Attribute("selected"), - binding, - "list selected"), - SelectionChanged = listChanged, - }; - ApplyCommon(list, el, binding); - parent.AddChild(list); - break; + } } + return panel; } /// @@ -470,197 +197,13 @@ public static class MarkupDocument return () => (property.GetValue(binding) as Action)?.Invoke(); } - private static Action? BindFloatAction( - string? attribute, - object binding) - { - if (attribute is null || !IsBinding(attribute)) - return null; - - string name = attribute[1..^1]; - PropertyInfo? property = binding.GetType().GetProperty(name); - if (property is null - || !typeof(Action).IsAssignableFrom(property.PropertyType)) - return null; - return value => (property.GetValue(binding) as Action)?.Invoke(value); - } - - private static Action? BindStringAction( - string? attribute, - object binding) - { - if (attribute is null || !IsBinding(attribute)) - return null; - - string name = attribute[1..^1]; - PropertyInfo? property = binding.GetType().GetProperty(name); - if (property is null - || !typeof(Action).IsAssignableFrom(property.PropertyType)) - { - return null; - } - return value => (property.GetValue(binding) as Action)?.Invoke(value); - } - - private static Action? BindIntAction(string? attribute, object binding) - { - if (attribute is null || !IsBinding(attribute)) - return null; - PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]); - if (property is null - || !typeof(Action).IsAssignableFrom(property.PropertyType)) - { - return null; - } - return value => (property.GetValue(binding) as Action)?.Invoke(value); - } - - private static Func> BindStringList( - string? expression, - object binding, - string context) - { - if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression)) - throw new FormatException($"{context} must be a string-list binding"); - PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); - if (property is null - || !typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) - { - throw new FormatException( - $"{expression} did not resolve to an IEnumerable property on " - + binding.GetType().Name); - } - return () => property.GetValue(binding) is IEnumerable values - ? values.ToArray() - : Array.Empty(); - } - - private static Func> BindUintList( - string? expression, - object binding, - string context) - { - if (string.IsNullOrWhiteSpace(expression)) - return static () => Array.Empty(); - if (!IsBinding(expression)) - throw new FormatException($"{context} must be a uint-list binding"); - PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); - if (property is null - || !typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) - { - throw new FormatException( - $"{expression} did not resolve to an IEnumerable property on " - + binding.GetType().Name); - } - return () => property.GetValue(binding) is IEnumerable values - ? values.ToArray() - : Array.Empty(); - } - private static bool IsBinding(string value) => value.Length > 2 && value[0] == '{' && value[^1] == '}'; - private static void ApplyCommon( - UiElement element, - XElement source, - object binding) - { - element.Name = (string?)source.Attribute("name") - ?? (string?)source.Attribute("id"); - BindBool((string?)source.Attribute("visible"), binding, - value => element.Visible = value, - sourceReader => element.VisibleSource = sourceReader); - BindBool((string?)source.Attribute("enabled"), binding, - value => element.Enabled = value, - sourceReader => element.EnabledSource = sourceReader); - - string? tooltip = (string?)source.Attribute("tooltip"); - if (!string.IsNullOrWhiteSpace(tooltip)) - { - element.RuntimeTooltipTextSource = BindString(tooltip, binding); - element.AuthoredTooltipRootElementId = RuntimeTooltipRootElementId; - element.AuthoredTooltipLayoutDid = RuntimeTooltipLayoutDid; - element.AuthoredTooltipEnabled = true; - } - } - - private static void BindBool( - string? expression, - object binding, - Action setLiteral, - Action> setSource) - { - if (string.IsNullOrWhiteSpace(expression)) - return; - if (!IsBinding(expression)) - { - if (bool.TryParse(expression, out bool literal)) - setLiteral(literal); - return; - } - - PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); - if (property is null || property.PropertyType != typeof(bool)) - { - throw new FormatException( - $"{expression} did not resolve to a bool property on " - + binding.GetType().Name); - } - setSource(() => property.GetValue(binding) is true); - } - - private static Func BindRequiredBoolReader( - string? expression, - object binding, - string context) - { - if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression)) - throw new FormatException($"{context} must be a bool binding"); - - PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); - if (property is null || property.PropertyType != typeof(bool)) - { - throw new FormatException( - $"{expression} did not resolve to a bool property on " - + binding.GetType().Name); - } - return () => property.GetValue(binding) is true; - } - - private static Func BindRequiredIntReader( - string? expression, - object binding, - string context) - { - if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression)) - throw new FormatException($"{context} must be an int binding"); - PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); - if (property is null || property.PropertyType != typeof(int)) - { - throw new FormatException( - $"{expression} did not resolve to an int property on " - + binding.GetType().Name); - } - return () => property.GetValue(binding) is int value ? value : -1; - } - private static float F(XElement e, string attr) => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0f; - private static float FOr(XElement e, string attr, float fallback) - => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, - CultureInfo.InvariantCulture, out float value) ? value : fallback; - - private static int I(XElement e, string attr, int fallback) - => int.TryParse((string?)e.Attribute(attr), NumberStyles.Integer, - CultureInfo.InvariantCulture, out int value) ? value : fallback; - - private static bool B(XElement e, string attr, bool fallback) - => bool.TryParse((string?)e.Attribute(attr), out bool value) - ? value - : fallback; - /// /// Parses #AARRGGBB → RGBA (alpha first, matching /// controls.ini convention). Falls back to opaque white on bad input. diff --git a/src/AcDream.App/UI/PluginSidePanel.cs b/src/AcDream.App/UI/PluginSidePanel.cs deleted file mode 100644 index 364839a2..00000000 --- a/src/AcDream.App/UI/PluginSidePanel.cs +++ /dev/null @@ -1,355 +0,0 @@ -using System.Numerics; -using AcDream.Plugin.Abstractions; - -namespace AcDream.App.UI; - -/// -/// Host-owned shelf for running gameplay plugins. A shelf button changes only -/// presentation visibility; it never touches plugin enable/session lifetime. -/// -public sealed class PluginSidePanel : UiPanel, IDisposable -{ - private const float OuterPadding = 4f; - private const float ButtonExtent = 28f; - private const float ButtonGap = 4f; - private const float DefaultTop = 116f; - - private readonly RetailWindowManager _windows; - private readonly Func _resolve; - private readonly UiDatFont? _font; - private readonly Dictionary _entries = []; - private bool _disposed; - private float _lastLayoutHeight = -1f; - - public PluginSidePanel( - RetailWindowManager windows, - Func resolve, - UiDatFont? font) - { - _windows = windows ?? throw new ArgumentNullException(nameof(windows)); - _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); - _font = font; - - Width = ButtonExtent + OuterPadding * 2f; - Height = OuterPadding * 2f; - Top = DefaultTop; - Anchors = AnchorEdges.None; - Draggable = false; - Resizable = false; - BackgroundColor = new Vector4(0f, 0f, 0f, 0.88f); - BorderColor = new Vector4(0.62f, 0.48f, 0.16f, 1f); - BorderThickness = 1f; - Visible = false; - - _windows.WindowUnregistered += OnWindowUnregistered; - } - - /// Number of live plugin-window entries, exposed for gates. - public int EntryCount => _entries.Count; - - /// - /// Adds one manifest-scoped plugin window and its minimize affordance. - /// Duplicate handles are idempotent. - /// - public void Add( - PluginUiOwner owner, - PluginPanelDescriptor descriptor, - RetailWindowHandle handle) - { - ObjectDisposedException.ThrowIf(_disposed, this); - ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id); - ArgumentNullException.ThrowIfNull(descriptor); - ArgumentNullException.ThrowIfNull(handle); - if (_entries.ContainsKey(handle)) - return; - - // Plugin windows are ordinary retained windows, but unlike imported - // retail windows they have no authored MoveTo override. Keep their - // chrome reachable at the minimum 800x600 canvas and after a display - // resize. An oversized window follows retail's top-left-priority rule: - // pin to zero rather than stranding the title/minimize controls. - handle.OuterFrame.ConstrainDragToParent = true; - handle.OuterFrame.ConstrainResizeToParent = true; - KeepWindowReachable(handle); - - var button = new PluginShelfButton( - descriptor, - owner.DisplayName, - handle, - _resolve, - _font) - { - Width = ButtonExtent, - Height = ButtonExtent, - }; - button.Click += () => - { - if (handle.IsVisible) - handle.Hide(); - else - handle.Show(); - }; - - var minimize = new PluginMinimizeButton(handle, _font) - { - Left = MathF.Max(8f, handle.OuterFrame.Width - 23f), - Top = 3f, - Width = 18f, - Height = 17f, - Anchors = AnchorEdges.Top | AnchorEdges.Right, - }; - handle.OuterFrame.AddChild(minimize); - - _entries.Add(handle, new ShelfEntry(button, minimize)); - AddChild(button); - Reflow(); - } - - protected override void OnTick(double deltaSeconds) - { - base.OnTick(deltaSeconds); - - // Screen-edge dock: root bounds become authoritative at draw time, so - // compute this from the live parent rather than capturing an anchor - // margin while the pre-first-frame root still measures 0x0. - if (Parent is { } parent) - { - float availableHeight = MathF.Max( - ButtonExtent + OuterPadding * 2f, - parent.Height - Top - OuterPadding); - if (MathF.Abs(availableHeight - _lastLayoutHeight) > 0.5f) - { - _lastLayoutHeight = availableHeight; - Reflow(availableHeight); - } - Left = MathF.Max(0f, parent.Width - Width - OuterPadding); - } - - foreach (RetailWindowHandle handle in _entries.Keys) - KeepWindowReachable(handle); - - // The shelf remains reachable even after ordinary windows are raised. - if (Parent is { } root) - { - int highest = 0; - foreach (UiElement sibling in root.Children) - { - if (!ReferenceEquals(sibling, this)) - highest = Math.Max(highest, sibling.ZOrder); - } - if (ZOrder <= highest) - ZOrder = highest == int.MaxValue ? highest : highest + 1; - } - } - - private void OnWindowUnregistered(RetailWindowHandle handle) - { - if (!_entries.Remove(handle, out ShelfEntry entry)) - return; - - RemoveChild(entry.Button); - if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame)) - handle.OuterFrame.RemoveChild(entry.Minimize); - entry.Button.DisposeSubscriptions(); - Reflow(); - } - - private static void KeepWindowReachable(RetailWindowHandle handle) - { - if (handle.OuterFrame.Parent is not { } parent - || parent.Width <= 0f - || parent.Height <= 0f) - { - return; - } - - float left = Math.Clamp( - handle.Left, - 0f, - MathF.Max(0f, parent.Width - handle.Width)); - float top = Math.Clamp( - handle.Top, - 0f, - MathF.Max(0f, parent.Height - handle.Height)); - if (left != handle.Left || top != handle.Top) - handle.MoveTo(left, top); - } - - private void Reflow(float maximumHeight = float.PositiveInfinity) - { - int maximumRows = float.IsPositiveInfinity(maximumHeight) - ? Math.Max(1, _entries.Count) - : Math.Max( - 1, - (int)MathF.Floor( - (maximumHeight - OuterPadding * 2f + ButtonGap) - / (ButtonExtent + ButtonGap))); - int index = 0; - foreach (ShelfEntry entry in _entries.Values) - { - int column = index / maximumRows; - int row = index % maximumRows; - entry.Button.Left = OuterPadding - + column * (ButtonExtent + ButtonGap); - entry.Button.Top = OuterPadding - + row * (ButtonExtent + ButtonGap); - index++; - } - - int rows = Math.Min(index, maximumRows); - int columns = index == 0 ? 1 : (index + maximumRows - 1) / maximumRows; - Width = OuterPadding * 2f - + columns * ButtonExtent - + Math.Max(0, columns - 1) * ButtonGap; - Height = OuterPadding * 2f - + rows * ButtonExtent - + Math.Max(0, rows - 1) * ButtonGap; - Visible = index > 0; - } - - public void Dispose() - { - if (_disposed) - return; - _disposed = true; - _windows.WindowUnregistered -= OnWindowUnregistered; - - foreach ((RetailWindowHandle handle, ShelfEntry entry) in _entries) - { - entry.Button.DisposeSubscriptions(); - if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame)) - handle.OuterFrame.RemoveChild(entry.Minimize); - } - _entries.Clear(); - Visible = false; - } - - private readonly record struct ShelfEntry( - PluginShelfButton Button, - PluginMinimizeButton Minimize); - - private sealed class PluginShelfButton : UiSimpleButton - { - private static readonly Vector4 HiddenBackground = - new(0.025f, 0.025f, 0.02f, 0.96f); - private static readonly Vector4 VisibleBackground = - new(0.09f, 0.19f, 0.055f, 0.96f); - private static readonly Vector4 HiddenBorder = - new(0.48f, 0.38f, 0.14f, 1f); - private static readonly Vector4 VisibleBorder = - new(0.76f, 0.64f, 0.25f, 1f); - - private readonly RetailWindowHandle _handle; - private readonly Func _resolve; - private readonly uint _iconSurfaceId; - private readonly string _tooltip; - - internal PluginShelfButton( - PluginPanelDescriptor descriptor, - string ownerDisplayName, - RetailWindowHandle handle, - Func resolve, - UiDatFont? font) - { - _handle = handle; - _resolve = resolve; - _iconSurfaceId = descriptor.IconSurfaceId; - _tooltip = string.Equals(descriptor.Title, ownerDisplayName, - StringComparison.Ordinal) - ? descriptor.Title - : $"{ownerDisplayName} — {descriptor.Title}"; - Text = _iconSurfaceId == 0 - ? Initials(descriptor.IconText, descriptor.Title) - : string.Empty; - DatFont = font; - Outline = true; - BorderThickness = 1f; - _handle.Shown += OnVisibilityChanged; - _handle.Hidden += OnVisibilityChanged; - RefreshPresentation(); - } - - public override string? GetTooltipText() => _tooltip; - - protected override void OnTick(double deltaSeconds) - { - base.OnTick(deltaSeconds); - RefreshPresentation(); - } - - protected override void OnDraw(UiRenderContext ctx) - { - base.OnDraw(ctx); - if (_iconSurfaceId == 0) - return; - - (uint texture, int width, int height) = _resolve(_iconSurfaceId); - if (texture == 0 || width <= 0 || height <= 0) - return; - float extent = MathF.Min(Width - 6f, Height - 6f); - ctx.DrawSprite( - texture, - (Width - extent) * 0.5f, - (Height - extent) * 0.5f, - extent, - extent, - 0f, - 0f, - 1f, - 1f, - Vector4.One); - } - - internal void DisposeSubscriptions() - { - _handle.Shown -= OnVisibilityChanged; - _handle.Hidden -= OnVisibilityChanged; - } - - private void OnVisibilityChanged(RetailWindowHandle _) => - RefreshPresentation(); - - private void RefreshPresentation() - { - BackgroundColor = _handle.IsVisible - ? VisibleBackground - : HiddenBackground; - BorderColor = _handle.IsVisible ? VisibleBorder : HiddenBorder; - } - - private static string Initials(string? requested, string title) - { - if (!string.IsNullOrWhiteSpace(requested)) - return requested.Trim()[..Math.Min(3, requested.Trim().Length)]; - - string[] words = title.Split( - ' ', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (words.Length == 0) - return "?"; - if (words.Length == 1) - return words[0][..Math.Min(2, words[0].Length)].ToUpperInvariant(); - return string.Concat(words.Take(2).Select(static word => - char.ToUpperInvariant(word[0]))); - } - } - - private sealed class PluginMinimizeButton : UiSimpleButton - { - private readonly RetailWindowHandle _handle; - - internal PluginMinimizeButton(RetailWindowHandle handle, UiDatFont? font) - { - _handle = handle; - Text = "–"; - DatFont = font; - Outline = true; - BackgroundColor = new Vector4(0.02f, 0.02f, 0.015f, 0.94f); - BorderColor = new Vector4(0.58f, 0.46f, 0.17f, 1f); - BorderThickness = 1f; - Click += () => _handle.Hide(); - } - - public override string? GetTooltipText() => "Minimize to plugin sidepanel"; - } -} diff --git a/src/AcDream.App/UI/RetailScrollbarChrome.cs b/src/AcDream.App/UI/RetailScrollbarChrome.cs deleted file mode 100644 index 0dc3623d..00000000 --- a/src/AcDream.App/UI/RetailScrollbarChrome.cs +++ /dev/null @@ -1,108 +0,0 @@ -namespace AcDream.App.UI; - -/// -/// The base scrollbar skin from LayoutDesc 0x2100003E, seated the way -/// retail seats it at runtime. UIElement_Scrollbar::UpdateScrollingArea -/// @0x00470AA0 ignores the buttons' authored positions: it moves the -/// INCREMENT button (attribute 0x77) to the top/left corner -/// (MoveTo(0,0)) and the DECREMENT button (attribute 0x78) to -/// the bottom/right corner. The vertical skin (element 0x10000455) -/// designates 0x77=0x10000072 (the UP-arrow art, authored at Y=32) -/// and 0x78=0x10000071 (the DOWN-arrow art, authored at Y=0) — so -/// seating by authored Y renders both arrows upside down (the 2026-08-24 -/// owner report). Each button and thumb slice authors three states: -/// Normal (red center gem / dark navy thumb), Normal_rollover (amber gem / -/// bright blue thumb), Normal_pressed (gold highlight gem / dark thumb) — -/// all live-DAT probed 2026-08-24. -/// -internal static class RetailScrollbarChrome -{ - // ── Vertical skin (element 0x10000455, 16 px) ──────────────────────── - internal const uint Track = 0x06004C5Fu; - - /// Top button = the INCREMENT designee 0x10000072 (up arrow). - internal const uint UpNormal = 0x06004C6Cu; - internal const uint UpRollover = 0x06004C6Du; - internal const uint UpPressed = 0x06004C6Eu; - - /// Bottom button = the DECREMENT designee 0x10000071 (down arrow). - internal const uint DownNormal = 0x06004C69u; - internal const uint DownRollover = 0x06004C6Au; - internal const uint DownPressed = 0x06004C6Bu; - - internal const uint ThumbTopNormal = 0x06004C60u; - internal const uint ThumbTopRollover = 0x06004C61u; - internal const uint ThumbTopPressed = 0x06004C62u; - internal const uint ThumbMidNormal = 0x06004C63u; - internal const uint ThumbMidRollover = 0x06004C64u; - internal const uint ThumbMidPressed = 0x06004C65u; - internal const uint ThumbBotNormal = 0x06004C66u; - internal const uint ThumbBotRollover = 0x06004C67u; - internal const uint ThumbBotPressed = 0x06004C68u; - - // ── Horizontal skin (element 0x1000036D, 16 px) ────────────────────── - internal const uint HTrack = 0x06004C7Fu; - - /// Left button = the INCREMENT designee 0x1000036C. - internal const uint LeftNormal = 0x06004C8Cu; - internal const uint LeftRollover = 0x06004C8Du; - internal const uint LeftPressed = 0x06004C8Eu; - - /// Right button = the DECREMENT designee 0x1000036B. - internal const uint RightNormal = 0x06004C89u; - internal const uint RightRollover = 0x06004C8Au; - internal const uint RightPressed = 0x06004C8Bu; - - internal const uint HThumbTopNormal = 0x06004C80u; - internal const uint HThumbTopRollover = 0x06004C81u; - internal const uint HThumbTopPressed = 0x06004C82u; - internal const uint HThumbMidNormal = 0x06004C83u; - internal const uint HThumbMidRollover = 0x06004C84u; - internal const uint HThumbMidPressed = 0x06004C85u; - internal const uint HThumbBotNormal = 0x06004C86u; - internal const uint HThumbBotRollover = 0x06004C87u; - internal const uint HThumbBotPressed = 0x06004C88u; - - /// Wires the full retail vertical skin onto . - internal static void ApplyVertical(UiScrollbar bar) - { - bar.TrackSprite = Track; - bar.UpSprite = UpNormal; - bar.UpRolloverSprite = UpRollover; - bar.UpPressedSprite = UpPressed; - bar.DownSprite = DownNormal; - bar.DownRolloverSprite = DownRollover; - bar.DownPressedSprite = DownPressed; - bar.ThumbTopSprite = ThumbTopNormal; - bar.ThumbTopRolloverSprite = ThumbTopRollover; - bar.ThumbTopPressedSprite = ThumbTopPressed; - bar.ThumbSprite = ThumbMidNormal; - bar.ThumbRolloverSprite = ThumbMidRollover; - bar.ThumbPressedSprite = ThumbMidPressed; - bar.ThumbBotSprite = ThumbBotNormal; - bar.ThumbBotRolloverSprite = ThumbBotRollover; - bar.ThumbBotPressedSprite = ThumbBotPressed; - } - - /// Wires the full retail horizontal skin onto . - /// The leading () slot is the LEFT edge. - internal static void ApplyHorizontal(UiScrollbar bar) - { - bar.TrackSprite = HTrack; - bar.UpSprite = LeftNormal; - bar.UpRolloverSprite = LeftRollover; - bar.UpPressedSprite = LeftPressed; - bar.DownSprite = RightNormal; - bar.DownRolloverSprite = RightRollover; - bar.DownPressedSprite = RightPressed; - bar.ThumbTopSprite = HThumbTopNormal; - bar.ThumbTopRolloverSprite = HThumbTopRollover; - bar.ThumbTopPressedSprite = HThumbTopPressed; - bar.ThumbSprite = HThumbMidNormal; - bar.ThumbRolloverSprite = HThumbMidRollover; - bar.ThumbPressedSprite = HThumbMidPressed; - bar.ThumbBotSprite = HThumbBotNormal; - bar.ThumbBotRolloverSprite = HThumbBotRollover; - bar.ThumbBotPressedSprite = HThumbBotPressed; - } -} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 2683d01e..62b8c251 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -12,7 +12,6 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; -using AcDream.Core.Properties; using AcDream.Core.Selection; using AcDream.Core.Spells; using AcDream.Runtime; @@ -25,7 +24,6 @@ using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Vitals; using AcDream.UI.Abstractions.Input; -using AcDream.Plugin.Abstractions; using DatReaderWriter; using Silk.NET.Input; @@ -158,22 +156,7 @@ public sealed record ToolbarRuntimeBindings( // vendor-owned split-exempt-seed predicate (research doc §C.1). Func IsVendorSplitExempt); -/// The sheet + raise-request flow (pre-existing). -/// Campaign CT slice CT2's RuntimeCharacterTitleState -/// owner — CT3's binds -/// directly against it. -/// CT2's DAT id->string chain -/// (CharacterTitleTable::GetCharacterTitleFromID). Constructed once at -/// composition time — its own constructor does no DAT I/O. -/// Retail TitleSet (0x002C) — wire only, no -/// local mutation (CT2's IRuntimeCharacterCommands.SetTitle via the -/// late-bound game-runtime command seam, same shape as every fellowship/ -/// allegiance command above). -public sealed record CharacterRuntimeBindings( - CharacterSheetProvider Provider, - RuntimeCharacterTitleState Titles, - CharacterTitleResolver TitleResolver, - Func SendSetTitle); +public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider); /// /// Campaign OP slice OP3 (2026-08-11): bindings the retail Options panel @@ -413,15 +396,7 @@ public sealed record ConfirmationRuntimeBindings( public sealed record AppraisalRuntimeBindings( Func PlayerName, Action SendSetInscription, - Action DisplaySystemMessage, - // Campaign AS slice AS4 (gap G6): the LOCAL player's own Faction1Bits - // (PropertyInt 281), feeding the examination window's Society row color - // rule (CreatureAppraisalRows.BuildExtra). Sourced from the SAME - // LocalPlayerState reference CharacterSheetProvider is built from - // (d.Character.LocalPlayer in InteractionRetainedUiComposition) — no new - // state path, just another read of the existing PlayerDescription-backed - // property snapshot. - Func LocalFactionBits); + Action DisplaySystemMessage); public sealed record VendorRuntimeBindings( VendorState State, @@ -443,8 +418,7 @@ public sealed record VendorRuntimeBindings( /// Campaign OP slice OP8: the Configure Keyboard screen's live read/write seam — /// the ONE live (Bindings for reads, /// SetBindings+BeginCapture for writes/capture) plus the portable -/// keybinds.json mirror path. Retail *.keymap profiles live in -/// Documents/Asheron's Call and the selected profile is reloaded at startup. Null +/// keybinds.json path (D4 — no .keymap file interchange). Null /// (headless/no-window hosts, or before the graphical /// input stack finishes constructing) degrades to "Configure Keyboard has no /// live effect" exactly like every other null-dependency Options-panel seam. @@ -467,10 +441,11 @@ public sealed record KeyboardRuntimeBindings( /// (RecvNotice_CloseDialog@0x004ed760 case 1) retail queues UI mode /// 0x10000009 (gmEpilogueUI) rather than exiting immediately — /// out of scope here. This is a plain host action, not a generation-gated -/// Runtime command. It closes through d.Window.Close, so status events -/// disconnected/exited still fire through -/// GameWindow.OnClosingCompleteShutdown. In-world Escape does -/// not use this path; retail clears selection or toggles Gameplay Options. +/// Runtime command: it is the SAME window-close path +/// GameplayWindowCommands/IGameplayWindowCommands.Close already +/// use for the in-world Escape fallback (d.Window.Close at +/// composition), so status events disconnected/exited still +/// fire through GameWindow.OnClosingCompleteShutdown. /// public sealed record CharacterSelectionRuntimeBindings( Func View, @@ -531,10 +506,7 @@ public sealed record RetailUiRuntimeBindings( KeyboardRuntimeBindings? Keyboard = null, CharacterSelectionRuntimeBindings? CharacterSelection = null, // Campaign CC slice CC4: sibling of CharacterSelection above. - CharacterCreationRuntimeBindings? CharacterCreation = null, - Action? CaptureScreenshot = null, - Func>? - ProjectileDebugSamples = null); + CharacterCreationRuntimeBindings? CharacterCreation = null); /// /// Composition owner for the production retained gameplay UI. GameWindow supplies @@ -556,13 +528,10 @@ public sealed class RetailUiRuntime : IDisposable private UiShortcutDigitGraphics? _shortcutDigitGraphics; private ItemCooldownUiController? _itemCooldownController; private VividTargetIndicatorController? _vividTargetIndicator; - private ProjectileDebugOverlayController? _projectileDebugOverlay; private Layout.VitalsSideBySideController? _vitalsSideBySide; private CharacterManagementUiMountCoordinator? _characterManagementMount; private CharacterCreationUiMountCoordinator? _characterCreationMount; - private PluginSidePanel? _pluginSidePanel; private IDisposable? _characterSheetSubscription; - private Layout.CharacterTitlesController? _characterTitlesController; private ResourceShutdownTransaction? _shutdown; private bool _disposed; @@ -607,7 +576,6 @@ public sealed class RetailUiRuntime : IDisposable RetailUiRuntimeBindings bindings = _bindings; MountFpsDisplay(); MountVividTargetIndicator(); - MountProjectileDebugOverlay(); MountVitals(); MountRadar(); MountChat(); @@ -750,7 +718,6 @@ public sealed class RetailUiRuntime : IDisposable public VendorUiController? VendorController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; } - private CharacterStatController.Binding? _characterStatBinding; /// Campaign QT slice QT5 — the three-tab Journal panel. public Layout.JournalPanelController? JournalPanelController { get; private set; } @@ -941,7 +908,6 @@ public sealed class RetailUiRuntime : IDisposable Layout.UiMediaClock.Advance(deltaSeconds); FpsController?.Tick(); _vividTargetIndicator?.Tick(); - _projectileDebugOverlay?.Tick(); _vitalsSideBySide?.Tick(); SpellbookWindowController?.Tick(); AppraisalController?.Tick(deltaSeconds); @@ -1016,278 +982,56 @@ public sealed class RetailUiRuntime : IDisposable { if (SpellcastingUiController?.Handle(action) == true) return true; - - switch (action) + if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel) { - case AcDream.UI.Abstractions.Input.InputAction.CaptureScreenshot: - _bindings.CaptureScreenshot?.Invoke(); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleHelp: - // EoR delegates this to the separately shipped ACHelpPlugin. - // That binary is not part of acdream; consume the retail action - // and report the unavailable external surface honestly. - _bindings.Options.DisplaySystemMessage( - "In-game help is unavailable because the retail help plugin is not installed."); - return true; - case AcDream.UI.Abstractions.Input.InputAction.TogglePluginManager: - _bindings.Options.DisplaySystemMessage( - "The retail plugin manager is not available in acdream."); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleAbuseReportingPanel: - _bindings.Options.DisplaySystemMessage(OptionsPanelText.ReportAbuseUnavailable); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleUrgentAssistancePanel: - _bindings.Options.DisplaySystemMessage(OptionsPanelText.UrgentAssistanceUnavailable); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ChatReply: - _chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastIncomingTellSender); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ChatMonarchReply: - _chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastMonarchSender); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ChatPatronReply: - _chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastPatronSender); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ChatStartCommand: - _chatWindowController?.StartCommand(); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ChatTellToSelected: - { - uint selected = _bindings.Toolbar.Selection.SelectedObjectId ?? 0u; - if (selected is >= 0x50000001u and <= 0x6FFFFFFFu) - { - string? name = _bindings.Toolbar.ResolveName(selected); - if (!string.IsNullOrEmpty(name)) - _chatWindowController?.StartTell(name); - } - return true; - } - case AcDream.UI.Abstractions.Input.InputAction.EnterChatMode: - _chatWindowController?.EnterChatMode( - _bindings.Keyboard?.Dispatcher?.CurrentPhysicalChord); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry: - _chatWindowController?.ToggleChatEntry( - _bindings.Keyboard?.Dispatcher?.CurrentPhysicalChord); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterInfoPanel: - ToggleWindow(WindowNames.CharacterInformation); - return true; - case AcDream.UI.Abstractions.Input.InputAction.TogglePositiveMagicPanel: - ToggleWindow(WindowNames.PositiveEffects); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleNegativeMagicPanel: - ToggleWindow(WindowNames.NegativeEffects); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleLinkStatusPanel: - ToggleWindow(WindowNames.LinkStatus); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleVitaePanel: - ToggleWindow(WindowNames.Vitae); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleSocialPanel: - ToggleWindow(WindowNames.SocialPanel); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel: - OpenSocialPanel(SocialPanelPage.Allegiance); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel: - OpenSocialPanel(SocialPanelPage.Fellowship); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleFriendsPage: - OpenSocialPanel(SocialPanelPage.Friends); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellManagementPanel: - ToggleWindow(WindowNames.Spellbook); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel: - OpenSpellbook(SpellbookWindowPage.Spells); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel: - OpenSpellbook(SpellbookWindowPage.Components); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterDetailPanel: - ToggleWindow(WindowNames.Character); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleAttributesPanel: - OpenCharacterPanel(CharacterStatController.CharacterStatTab.Attributes); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleSkillsPanel: - OpenCharacterPanel(CharacterStatController.CharacterStatTab.Skills); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterTitlesPage: - OpenCharacterPanel(CharacterStatController.CharacterStatTab.Titles); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleWorldPanel: - ToggleWindow(WindowNames.MapHouse); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleMapPage: - OpenWorldPanel(showHouse: false); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleHousePage: - OpenWorldPanel(showHouse: true); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel: - ToggleWindow(WindowNames.Options); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleGameplayOptionsPage: - OpenOptionsPage(OptionsPanelPage.Gameplay); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterSettingsPage: - OpenOptionsPage(OptionsPanelPage.Character); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleConfigurationPage: - OpenOptionsPage(OptionsPanelPage.Configuration); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleCompass: - Host.ToggleWindow(WindowNames.Radar); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleKeyboardConfiguration: - ToggleWindow(WindowNames.KeyboardConfig); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleQuestJournalPage: - OpenJournalPanel(JournalPanelPage.Notes); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleQuestDetailPanel: - // EoR's quest-detail action addresses the quest-management - // surface. The current authored Journal host's server-backed - // Contracts page is that surface in acdream. - OpenJournalPanel(JournalPanelPage.Contracts); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleJournalPageList: - OpenJournalPanel(JournalPanelPage.PageList); - return true; - case AcDream.UI.Abstractions.Input.InputAction.ToggleContractsPage: - OpenJournalPanel(JournalPanelPage.Contracts); - return true; + OpenSpellbook(SpellbookWindowPage.Spells); + return true; + } + if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel) + { + OpenSpellbook(SpellbookWindowPage.Components); + return true; + } + // Campaign FA slice FA3: F3/F4 — keyboard-only open paths (lane A + // §6.1: neither action authors a toolbar button). Both share the + // one social panel (RetailPanelCatalog.SocialPanel) and switch to + // their own tab; the panel participates in the SAME gmPanelUI + // one-active-panel exclusivity every sibling panel gets from + // RetailPanelUiController.RegisterMainPanel. + if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel) + { + OpenSocialPanel(showAllegiance: true); + return true; + } + if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel) + { + OpenSocialPanel(showAllegiance: false); + return true; } - return ToolbarInputController?.Handle(action) == true; } - private void OpenCharacterPanel(CharacterStatController.CharacterStatTab tab) - { - bool visible = Host.IsWindowVisible(WindowNames.Character); - bool onTargetTab = _characterStatBinding?.CurrentTab() == tab; - if (visible && onTargetTab) - { - CloseWindow(WindowNames.Character); - return; - } - - _characterStatBinding?.ShowTab(tab); - _panelUi.SetPanelVisibility(RetailPanelCatalog.Character, visible: true); - } - - private void OpenWorldPanel(bool showHouse) - { - bool visible = Host.IsWindowVisible(WindowNames.MapHouse); - bool onTargetTab = showHouse - ? MapHousePanelController?.IsShowingHouse == true - : MapHousePanelController?.IsShowingMap == true; - if (visible && onTargetTab) - { - CloseWindow(WindowNames.MapHouse); - return; - } - - if (showHouse) - MapHousePanelController?.ShowHouse(); - else - MapHousePanelController?.ShowMap(); - _panelUi.SetPanelVisibility(RetailPanelCatalog.MapHouse, visible: true); - } - - private enum OptionsPanelPage { Gameplay, Character, Configuration } - - private void OpenOptionsPage(OptionsPanelPage page) - { - bool visible = Host.IsWindowVisible(WindowNames.Options); - bool onTargetTab = page switch - { - OptionsPanelPage.Gameplay => OptionsPanelController?.IsShowingGameplay == true, - OptionsPanelPage.Character => OptionsPanelController?.IsShowingCharacter == true, - OptionsPanelPage.Configuration => OptionsPanelController?.IsShowingConfiguration == true, - _ => false, - }; - if (visible && onTargetTab) - { - CloseWindow(WindowNames.Options); - return; - } - - switch (page) - { - case OptionsPanelPage.Gameplay: OptionsPanelController?.ShowGameplay(); break; - case OptionsPanelPage.Character: OptionsPanelController?.ShowCharacter(); break; - case OptionsPanelPage.Configuration: OptionsPanelController?.ShowConfiguration(); break; - } - _panelUi.SetPanelVisibility(RetailPanelCatalog.Options, visible: true); - } - - /// - /// Retail Escape's final fallback: toggle action 0x1000001B, - /// whose installed-DAT ActionMap label is "Show/Hide Gameplay Options - /// Page". Reuses the authored Options tab and panel owners. - /// - public void ToggleGameplayOptionsPage() - => OpenOptionsPage(OptionsPanelPage.Gameplay); - - /// Semantic/rebound form of retail Enter/Tab chat activation. - public void FocusChatEntry() - { - if (Host.Root.DefaultTextInput is { } input) - Host.Root.SetKeyboardFocus(input); - } - - /// - /// Shift+Escape's retail LOGOUT action: no confirmation dialog; the - /// normal grounded/airborne/no-player gate still applies. - /// - public void LogOutCharacter() => EndCharacterSessionWithRetailGates(); - /// Shared F3/F4 handler — same "toggle closes on a repeat press /// of the SAME tab, otherwise show + switch" shape as . - private enum SocialPanelPage { Friends, Allegiance, Fellowship } - - private void OpenSocialPanel(SocialPanelPage page) + private void OpenSocialPanel(bool showAllegiance) { bool visible = Host.IsWindowVisible(WindowNames.SocialPanel); - bool onTargetTab = page switch - { - SocialPanelPage.Friends => SocialPanelController?.IsShowingFriends == true, - SocialPanelPage.Allegiance => SocialPanelController?.IsShowingAllegiance == true, - SocialPanelPage.Fellowship => SocialPanelController?.IsShowingFellowship == true, - _ => false, - }; + bool onTargetTab = showAllegiance + ? SocialPanelController?.IsShowingAllegiance == true + : SocialPanelController?.IsShowingFellowship == true; if (visible && onTargetTab) { CloseWindow(WindowNames.SocialPanel); return; } - switch (page) - { - case SocialPanelPage.Friends: SocialPanelController?.ShowFriends(); break; - case SocialPanelPage.Allegiance: SocialPanelController?.ShowAllegiance(); break; - case SocialPanelPage.Fellowship: SocialPanelController?.ShowFellowship(); break; - } + if (showAllegiance) + SocialPanelController?.ShowAllegiance(); + else + SocialPanelController?.ShowFellowship(); _panelUi.SetPanelVisibility(RetailPanelCatalog.SocialPanel, visible: true); } - private enum JournalPanelPage { Contracts, Notes, PageList } - - private void OpenJournalPanel(JournalPanelPage page) - { - switch (page) - { - case JournalPanelPage.Contracts: JournalPanelController?.ShowContracts(); break; - case JournalPanelPage.Notes: JournalPanelController?.ShowNotes(); break; - case JournalPanelPage.PageList: JournalPanelController?.ShowPageList(); break; - } - _panelUi.SetPanelVisibility(RetailPanelCatalog.Journal, visible: true); - } - private void OpenSpellbook(SpellbookWindowPage page) { bool visible = Host.IsWindowVisible(WindowNames.Spellbook); @@ -1662,18 +1406,6 @@ public sealed class RetailUiRuntime : IDisposable : "[D.2b] vivid target indicator mounted from client-enum category 0x10000009."); } - private void MountProjectileDebugOverlay() - { - if (_bindings.ProjectileDebugSamples is not { } samples) - return; - _projectileDebugOverlay = ProjectileDebugOverlayController.Mount( - Host.Root, - samples, - _bindings.VividTarget.Camera); - Console.WriteLine( - "[PluginUI] projectile collision debug overlay mounted."); - } - private void MountVitals() { ImportedLayout? layout = Import(0x2100006Cu); @@ -1863,12 +1595,7 @@ public sealed class RetailUiRuntime : IDisposable return null; string? name = _bindings.Toolbar.ResolveName(selected); return string.IsNullOrWhiteSpace(name) ? null : name; - }, - // Authored talk-focus labels (StringTable 0x23000001, ID_Chat_* - // keys via compute_str_hash — the retail "Gen"/"Tell to X" set). - chatStrings: key => new DatStringResolver(_bindings.Assets.Dats) - .Resolve(0x23000001u, DatStringResolver.ComputeHash(key)), - resolveFont: _bindings.Assets.ResolveFont); + }); if (controller is null) { Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F."); @@ -2097,10 +1824,7 @@ public sealed class RetailUiRuntime : IDisposable StackSplitQuantity, handler => b.Objects.ObjectUpdated += handler, handler => b.Objects.ObjectUpdated -= handler, - b.IsVendorSplitExempt, - isCoinstack: guid => b.Objects.Get(guid)?.WeenieClassId == 273u, - coinTotal: () => b.Objects.Get(b.PlayerGuid())?.Properties.GetInt( - (uint)PropertyInt.CoinValue) ?? 0); + b.IsVendorSplitExempt); UiElement root = layout.Root; RetailWindowHandle handle = RetailWindowFrame.Mount( @@ -2368,20 +2092,6 @@ public sealed class RetailUiRuntime : IDisposable return; } - // AS2 (Campaign AS, gap G2): the examination header's title line - // (element 0x10000151) reuses the SAME CharacterTitleResolver - // instance the D.2b Character panel already owns - // (_bindings.Character.TitleResolver, constructed once per - // composition in InteractionRetainedUiComposition — see - // MountCharacter's identical TitleResolver local function). Resolved - // PER CALL under DatLock, never captured once, per the secure-trade - // deferred-Func lesson. - string? ResolveCharacterTitle(uint titleId) - { - lock (_bindings.Assets.DatLock) - return _bindings.Character.TitleResolver.Resolve(titleId); - } - AppraisalUiController? controller = AppraisalUiController.Bind( layout, _bindings.Inventory.Objects, @@ -2401,9 +2111,7 @@ public sealed class RetailUiRuntime : IDisposable resolveComponentIcon: _bindings.Magic.ResolveComponentIcon, spellComponents: _bindings.Magic.SpellComponents, magicSkill: _bindings.Magic.MagicSkill, - spellComponentTemplates: spellComponentTemplates, - resolveCharacterTitle: ResolveCharacterTitle, - localFactionBits: _bindings.Appraisal.LocalFactionBits); + spellComponentTemplates: spellComponentTemplates); if (controller is null) { Console.WriteLine( @@ -3319,88 +3027,12 @@ public sealed class RetailUiRuntime : IDisposable string unmappedPath = UnmappedKeyBindingsPath(keyboard.KeyBindingsFilePath); var unmapped = RetailUnmappedKeyBindings.LoadOrEmpty(unmappedPath); - var keymaps = new RetailKeymapProfileStore(keyboard.KeyBindingsFilePath); - string? ResolveKeymapTemplate(string key, string fileName) - { - // The localized templates use one named filename variable. Keep - // the common retail spellings populated; ResolveTemplate selects - // only the hash actually authored by the DAT entry. - var variables = new Dictionary - { - [DatStringResolver.ComputeHash("LABEL")] = fileName, - [DatStringResolver.ComputeHash("KEYMAP")] = fileName, - [DatStringResolver.ComputeHash("FILENAME")] = fileName, - [DatStringResolver.ComputeHash("NAME")] = fileName, - [DatStringResolver.ComputeHash("VALUE")] = fileName, - }; - lock (_bindings.Assets.DatLock) - return strings.ResolveTemplate(0x23000004u, key, variables); - } - - void ShowKeymapMessage(string? message) - { - if (!string.IsNullOrWhiteSpace(message) && DialogFactory is not null) - DialogFactory.MakeMessage(message, queueKey: 0x10000001u, priority: true); - } - - void SaveMirrors() - { - dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath); - unmapped.SaveToFile(unmappedPath); - } - - void HandleSaveResult( - RetailKeymapSaveResult result, - string requestedName, - Action onSaved) - { - switch (result.Status) - { - case RetailKeymapSaveStatus.Saved: - try - { - SaveMirrors(); - } - catch (Exception failure) - { - Console.WriteLine($"keyboard config: JSON mirror save failed: {failure.Message}"); - } - // The retail .keymap is the canonical save. A failure in - // acdream's compatibility JSON mirror must not leave the - // authored filename label showing the previous profile. - onSaved(); - return; - - case RetailKeymapSaveStatus.Exists: - string? overwrite = ResolveKeymapTemplate( - "ID_KeyMapOverwriteKeymap_Label", result.FileName); - if (overwrite is null || DialogFactory is null) return; - DialogFactory.MakeConfirmation( - overwrite, - data => - { - if (!data.GetBoolean(RetailDialogProperty.ConfirmationResult)) return; - HandleSaveResult( - keymaps.Save(requestedName, dispatcher.Bindings, overwrite: true), - requestedName, - onSaved); - }, - queueKey: 0x10000001u, - priority: true); - return; - - case RetailKeymapSaveStatus.ReadOnly: - ShowKeymapMessage(ResolveKeymapTemplate( - "ID_KeyMapCantOverwriteReadOnlyKeymap_Label", result.FileName)); - return; - - default: - Console.WriteLine( - $"keyboard config: keymap save failed ({result.Status}): {result.Error}"); - return; - } - } + // ID_KeyMapCantOverwriteReadOnlyKeymap_Label — table 0x23000004, byte- + // verified 2026-08-11 (live probe): "Could not overwrite ". Falls back + // to silence (no invented English) if the DAT string is ever missing. + string? refusalText = strings.Resolve( + 0x23000004u, DatStringResolver.ComputeHash("ID_KeyMapCantOverwriteReadOnlyKeymap_Label")); Layout.KeyboardConfigController? controller = Layout.KeyboardConfigController.Bind( layout, @@ -3443,12 +3075,15 @@ public sealed class RetailUiRuntime : IDisposable chord => onResult(chord == default ? null : chord)), Save: () => { + // S3 (2026-08-11 review): match the existing keybinds.json + // writer's own discipline (RuntimeKeyBindingTarget.Apply) — + // an IO failure is reported, not thrown out of UiButton.OnClick + // into the input/render loop, and does not roll back the + // already-accepted live binding. try { - HandleSaveResult( - keymaps.SaveActive(dispatcher.Bindings), - keymaps.CurrentFileName, - static () => { }); + dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath); + unmapped.SaveToFile(unmappedPath); } catch (Exception failure) { @@ -3456,23 +3091,11 @@ public sealed class RetailUiRuntime : IDisposable } }, Toggle: () => ToggleWindow(WindowNames.KeyboardConfig), - ResolveTemplate: (key, variables) => + DisplaySystemMessage: text => { - lock (_bindings.Assets.DatLock) - { - return strings.ResolveTemplate(0x23000004u, key, variables); - } - }, - // UIOption_ActionKeyMap::OpenCantOverwriteBindingDialog - // @0x00489300: type 3, keyboard queue 0x10000001, priority. - ShowMessage: message => - { - if (DialogFactory is null) return; - DialogFactory.MakeMessage( - message, - queueKey: 0x10000001u, - priority: true); + if (!string.IsNullOrEmpty(text)) _bindings.Options.DisplaySystemMessage(text); }, + NonBindableRefusalText: refusalText ?? string.Empty, // M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — // confirm through the SAME RetailDialogFactory/MakeConfirmation // seam GameplayConfirmationController already uses, before @@ -3485,9 +3108,7 @@ public sealed class RetailUiRuntime : IDisposable if (DialogFactory is null) { onResult(false); return; } DialogFactory.MakeConfirmation( message, - data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult)), - queueKey: 0x10000001u, - priority: true); + data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult))); }, // OP8 re-gate (2026-08-14): retail's capture-instruction dialog // (InitiateBinding @ 0x004899D0 → OpenMapWarnDialog @ 0x00488A00): @@ -3513,10 +3134,7 @@ public sealed class RetailUiRuntime : IDisposable // `text` arrives with real line breaks. try { - return DialogFactory.MakeWait( - text, - queueKey: 0x10000001u, - priority: true); + return DialogFactory.MakeWait(text, queueKey: 0x10000001u); } catch (Exception failure) { @@ -3533,64 +3151,7 @@ public sealed class RetailUiRuntime : IDisposable } }, CloseCaptureInstructions: context => - DialogFactory?.CloseDialog(context), - CurrentKeymapFilename: () => keymaps.CurrentFileName, - OpenLoadKeymap: onLoaded => - { - if (DialogFactory is null) return; - IReadOnlyList files = keymaps.ListFiles(); - int selected = files - .Select(static (name, index) => (name, index)) - .FirstOrDefault( - pair => string.Equals( - pair.name, - keymaps.CurrentFileName, - StringComparison.OrdinalIgnoreCase), - (name: string.Empty, index: 0)).index; - DialogFactory.MakeConfirmationMenu( - files, - selected, - data => - { - int choice = data.GetInt32(RetailDialogProperty.MenuSelection, -1); - if (choice < 0 || choice >= files.Count) return; - if (!keymaps.TryLoad( - files[choice], - dispatcher.Bindings, - out KeyBindings loaded, - out string? error)) - { - Console.WriteLine($"keyboard config: keymap load failed: {error}"); - return; - } - dispatcher.SetBindings(loaded); - try { SaveMirrors(); } - catch (Exception failure) - { - Console.WriteLine( - $"keyboard config: loaded profile JSON mirror failed: {failure.Message}"); - } - onLoaded(); - }, - queueKey: 0x10000001u); - }, - OpenSaveKeymap: onSaved => - { - if (DialogFactory is null) return; - DialogFactory.MakeConfirmationTextInput( - string.Empty, - data => - { - string name = data.GetString(RetailDialogProperty.TextInputResult) - ?? string.Empty; - if (name.Length == 0) return; - HandleSaveResult( - keymaps.Save(name, dispatcher.Bindings, overwrite: false), - name, - onSaved); - }, - queueKey: 0x10000001u); - }), + DialogFactory?.CloseDialog(context)), resolveTemplateFont: (templateLayoutId, templateElementId) => { lock (_bindings.Assets.DatLock) @@ -4456,106 +4017,16 @@ public sealed class RetailUiRuntime : IDisposable } CharacterSheetProvider provider = _bindings.Character.Provider; CharacterSheet currentSheet = provider.BuildSheet(); - // CT5 (AP-235): resolve per-attribute/per-vital row icon DIDs through - // the live DBObj::GetDIDByEnum chain (RetailDataIdResolver.Resolve) - // instead of leaving CharacterStatController's hardcoded fallback as - // the only source. DatCollection is documented not thread-safe, so - // this closure takes the same DatLock every other dat-touching - // delegate below (TitleTemplateResolver/TitleResolver) already uses. - uint IconDidResolve(uint enumValue, uint category) - { - lock (_bindings.Assets.DatLock) - return RetailDataIdResolver.Resolve(_bindings.Assets.Dats, enumValue, category); - } - _characterStatBinding = CharacterStatController.Bind( + _characterSheetSubscription = provider.SubscribeChanged( + () => currentSheet = provider.BuildSheet()); + CharacterStatController.Bind( layout, () => currentSheet, _bindings.Assets.DefaultFont, _bindings.Assets.ResolveFont(0x40000001u) ?? _bindings.Assets.DefaultFont, _bindings.Assets.ResolveSprite, (request, completed) => HandleCharacterRaise(provider, request, completed), - () => CloseWindow(WindowNames.Character), - IconDidResolve); - // #431-CA5 gate fix: rebuild the ROWS on every authoritative sheet - // change, not only on clicks — a train's skill record must move the - // row to the trained section (and un-ghost the raise controls) the - // moment it lands, mirroring retail's quality-change broadcast - // (InfoRegion::OnQualityChanged @ 0x004F0EB0). - _characterSheetSubscription = provider.SubscribeChanged(() => - { - currentSheet = provider.BuildSheet(); - _characterStatBinding?.Refresh(); - }); - - // CT3 (2026-08-24): the Titles page's row template lives in a - // SEPARATE LayoutDesc (0x2100005E, element 0x10000536 — CT1 ground - // truth §3), reachable only through the targeted single-root - // ImportInfos overload — same caching resolver shape the social - // panel's Friends/Fellowship/Allegiance row families already use. - var titleRowTemplates = new Layout.RowTemplateResolver( - (layoutId, elementId) => LayoutImporter.ImportInfos( - _bindings.Assets.Dats, layoutId, elementId), - info => - { - var strings = new DatStringResolver(_bindings.Assets.Dats); - return LayoutImporter.Build( - info, - _bindings.Assets.ResolveSprite, - _bindings.Assets.DefaultFont, - _bindings.Assets.ResolveFont, - strings.Resolve).Root; - }); - UiElement? TitleTemplateResolver(uint templateLayoutId, uint templateElementId) - { - lock (_bindings.Assets.DatLock) - return titleRowTemplates.Resolve(templateLayoutId, templateElementId); - } - // CT3 fix round: CharacterTitleResolver.Resolve reads the SAME - // IDatReaderWriter (EnumMapper + StringTable lookups) as the row - // template resolver just above — DatCollection is documented not - // thread-safe, so this delegate needs the identical DatLock scope, - // not just the template resolver. - string? TitleResolver(uint titleId) - { - lock (_bindings.Assets.DatLock) - return _bindings.Character.TitleResolver.Resolve(titleId); - } - _characterTitlesController = Layout.CharacterTitlesController.Bind( - layout.Root, - _bindings.Character.Titles, - TitleResolver, - TitleTemplateResolver, - _bindings.Character.SendSetTitle); - - // CT6 (2026-08-25 live probe): 0x2100002E's own content root - // (0x10000227) authors NO min/max (CT1 correction (a)) — retail's - // actual resize target is the SHARED gmPanelUI host (0x100005FE in - // LayoutDesc 0x2100006E). UIElement_Resizebar::StartMouseResizing - // @0x0046B7E0 calls UIElement::StartResizing(this->GetParent(), ...) - // and stashes the drag state (m_DragStart*/m_currentBorder) ON THAT - // PARENT; UIElement::MouseResizeElement @0x00461130 then reads - // GetAttribute_Int(this, 0x3C..0x3F) off the SAME element every - // mouse-move. The bottom Resizebar (0x10000660) is a DIRECT CHILD of - // the host, not of the content parent (0x10000180) — confirmed live: - // host 0x100005FE authors MinWidth=310 MinHeight=372 MaxWidth=310 - // MaxHeight=1000 (Min==Max width: no horizontal Resizebar is - // authored, matching ResizeX=false below). - // - // CT6 fix round (2026-08-25, BLOCKER B1): host 0x100005FE is not a - // content element our NineSlice wrapper adds chrome to — it IS - // retail's own outer window frame (5px bevel + 300x362 content parent - // 0x10000180 + 5px = 310x372, verified by PanelHost_ - // AuthorsFixedWidthAndBottomOnlyResizeContract). Its authored - // 0x3C..0x3F values already include that bevel. Without - // DatConstraintSourceIsOuterFrame, RetailWindowFrame.ResolveConstraint - // added the wrapper's OWN 10px chrome inset on top — double-counting - // the bevel and clamping the mounted window's MinWidth to 320 while - // its actual outer width stayed 310, silently below its own minimum. - ElementInfo? hostConstraint; - lock (_bindings.Assets.DatLock) - hostConstraint = LayoutImporter.ImportInfos( - _bindings.Assets.Dats, 0x2100006Eu, 0x100005FEu); - + () => CloseWindow(WindowNames.Character)); RetailWindowHandle handle = RetailWindowFrame.Mount( Host.Root, layout.Root, @@ -4566,25 +4037,10 @@ public sealed class RetailUiRuntime : IDisposable Chrome = RetailWindowChrome.NineSlice, Left = 540f, Top = 18f, - // CT6 fix round (2026-08-25, S4 ruling): mount at retail's - // authored DEFAULT — the host's own content parent - // (0x10000180) is 300x362, not 0x2100002E's own 300x600 - // authoring canvas (which retail scroll-clips into that - // slot). 362 + the 10px NineSlice inset = 372, exactly the - // host's own authored MinHeight — matching the ground-truth - // finding that retail's Character/Skills window opens AT its - // resize floor and can only be dragged taller. The 9 - // attribute/vital rows (180px) overflow the resulting 160px - // list at this default; that is retail-correct, not a bug — - // the list's own scrollbar (0x1000023E, hide-when-disabled) - // activates immediately. - ContentHeight = 362f, ResizeX = false, ResizeY = true, ResizableEdges = ResizeEdges.Bottom, ConstrainResizeToParent = true, - DatConstraintSource = hostConstraint, - DatConstraintSourceIsOuterFrame = true, Visible = false, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, ContentClickThrough = false, @@ -4627,64 +4083,16 @@ public sealed class RetailUiRuntime : IDisposable { try { - string xml = panel.MarkupContent - ?? File.ReadAllText(panel.MarkupPath); - UiNineSlicePanel element = MarkupDocument.Build( + string xml = File.ReadAllText(panel.MarkupPath); + UiElement element = MarkupDocument.Build( xml, panel.Binding, _bindings.Assets.ResolveSprite, _bindings.Assets.Controls, _bindings.Assets.DefaultFont); - - if (Host.WindowManager.TryGet(panel.WindowName, out _)) - { - throw new InvalidOperationException( - $"Plugin window '{panel.WindowName}' is already registered. " - + "Window ids must be unique within one plugin."); - } - - // Markup's root visibility is an availability gate (for example, - // a world-only panel), while the descriptor/persisted state is the - // user's minimize choice. Keep those two axes independent so an - // availability transition never disables the running plugin or - // forgets that the user wanted its window open. - Func? availability = element.VisibleSource; - var visibility = new PluginWindowVisibilityController( - availability, - panel.Descriptor.StartVisible); - element.VisibleSource = visibility.ShouldBeVisible; - element.Visible = visibility.ShouldBeVisible(); - Host.Root.AddChild(element); - // Publish ownership immediately after the tree mutation. Any - // later registration/sidepanel failure then rolls the mounted - // subtree back through FailMount instead of leaking it. _bindings.Plugins.CompleteMount(panel, Host.Root, element); - RetailWindowHandle handle = Host.WindowManager.Register( - panel.WindowName, - element, - element, - visibility); - _bindings.Plugins.CompleteWindowMount( - panel, - () => Host.WindowManager.Unregister(panel.WindowName)); - - if (panel.Descriptor.ShowInSidePanel) - { - if (_pluginSidePanel is null) - { - _pluginSidePanel = new PluginSidePanel( - Host.WindowManager, - _bindings.Assets.ResolveSprite, - _bindings.Assets.DefaultFont); - Host.Root.AddChild(_pluginSidePanel); - } - _pluginSidePanel.Add(panel.Owner, panel.Descriptor, handle); - } - - Console.WriteLine( - $"[D.2b] plugin UI window loaded: {panel.WindowName} " - + $"({panel.MarkupPath})"); + Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}"); } catch (Exception ex) { @@ -5378,8 +4786,6 @@ public sealed class RetailUiRuntime : IDisposable () => { _characterSheetSubscription?.Dispose(); - _characterTitlesController?.Dispose(); - _pluginSidePanel?.Dispose(); Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; WindowLockPresentation.Dispose(); WindowOpacity.Dispose(); @@ -5407,36 +4813,6 @@ public sealed class RetailUiRuntime : IDisposable _disposed = _shutdown.IsComplete; } - /// - /// Separates plugin availability from the user's minimized/open choice. - /// Window-manager callbacks update only the latter; a false availability - /// predicate hides temporarily without forgetting the requested state. - /// - private sealed class PluginWindowVisibilityController( - Func? availability, - bool startVisible) : IRetainedPanelController - { - private bool _requestedVisible = startVisible; - - internal bool ShouldBeVisible() => - _requestedVisible && (availability?.Invoke() ?? true); - - public void OnShown() => _requestedVisible = true; - - public void OnHidden() - { - // Hidden because the markup's availability gate went false is - // temporary. Hidden while available is a real minimize/restore- - // persistence transition and changes the requested state. - if (availability?.Invoke() ?? true) - _requestedVisible = false; - } - - public void Dispose() - { - } - } - internal static ResourceShutdownTransaction CreateShutdownTransaction( Action disposeAutomation, Action disposePersistence, diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 0d4cc3d5..be2a82bf 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -857,20 +857,16 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful text, font.MeasureWidth, font.LineHeight, boxX, boxY, boxWidth, boxHeight, align, leftOffset); - // A multi-line result (an authored '\n') clips to its own box — an oversized - // wrapped caption (e.g. the Skills credits button's own tight 28px height) - // should be cut off at the box edge rather than spill into whatever sits - // below the button. CT-GF1 fix round (S2) audit: this clip STAYS (unlike - // UiText.DrawText's and UiField.DrawMultiLine's now-deleted self-clips) — - // (boxX,boxY,boxWidth,boxHeight) is LabelBox/ValueBox, an authored INNER - // sub-rect that can be smaller than and offset from the button's own full - // (0,0,Width,Height) (see LabelBox's/ValueBox's own docs and the - // ValueBox-narrows-boxWidth branch above), so it is not redundant with the - // ambient (0,0,Width,Height) clip DrawSelfAndChildren now applies by default - // one level up. Single-line captions — the overwhelming majority, and - // (post-R3-2) EVERY caption with no authored newline — never pay this cost; - // see this method's own doc for why a single line is deliberately left - // unclipped even when boxWidth was narrowed. + // A multi-line result (an authored '\n') clips to its own box — the + // button's normal draw has no ambient clip, and an oversized + // wrapped caption (e.g. the Skills credits button's own tight 28px + // height) should be cut off at the box edge rather than spill into + // whatever sits below the button, matching every other clipped + // Type-12 text box in this codebase (UiText.DrawText's own + // PushClip). Single-line captions — the overwhelming majority, + // and (post-R3-2) EVERY caption with no authored newline — never + // pay this cost; see this method's own doc for why a single line + // is deliberately left unclipped even when boxWidth was narrowed. bool clip = lines.Count > 1; if (clip) ctx.PushClip(boxX, boxY, boxWidth, boxHeight); diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index e0c3874f..0a295d14 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -55,7 +55,7 @@ public abstract class UiElement public uint DatElementId { get; internal set; } /// Human-readable name for debugging / FindByName. - public string? Name { get; set; } + public string? Name { get; init; } /// /// GF-13 (Campaign CC gate round 1, Batch A): mirrors @@ -274,12 +274,6 @@ public abstract class UiElement /// public Func? VisibleSource { get; set; } - /// - /// Optional live enabled reader. Declarative plugin controls use this to - /// expose unavailable/busy state without retaining presentation objects. - /// - public Func? EnabledSource { get; set; } - /// /// If true, will set focus here on click, /// routing WM_KEYDOWN / WM_CHAR to as @@ -550,60 +544,11 @@ public abstract class UiElement protected virtual void OnDrawOverlay(UiRenderContext ctx) { } /// - /// Whether THIS element's own draw AND its descendants' drawing/hit-testing are - /// clipped to this element's local bounds. THIS IS THE DEFAULT (true) FOR EVERY - /// ELEMENT — CT-GF1 port of retail's ancestor-clip chain: UIRegion::DrawHere - /// @0x0069FA30 takes the element's screen Box2D plus a - /// SmartArray<Box2D> of inherited clip rects, intersects them (the - /// min/max clamp loop @0x0069FAA7..0x0069FB82), and draws — EraseSelf/ - /// DrawChildren/DrawSelf ALL receive the intersected rect — ONLY - /// when the intersection is non-empty (the var_24 gate @0x0069FB8E). An - /// element positioned outside its parent's box therefore silently disappears in - /// retail, exactly like / - /// now does for every element by default — - /// 's fix-round shape (S2) pushes right after - /// PushAlpha and wraps OnDraw + the children walk + - /// OnDrawAfterChildren in ONE block, the literal DrawHere shape - /// (retail clips the element's OWN DrawSelf too, not just its children — - /// UIElement_Text::DrawSelf @0x00467AA0 locks glyph blits to its own - /// clipped surface rect; UIRegion::DrawSelf @0x0069F1A0 blits per clip - /// rect). Not just the scrollable listboxes that opted in before this default - /// flipped (owner gate finding: the Titles page's authored divider 0x10000530 - /// escaped the Character window at the CT6-correct 372px mounted default — - /// retail clips it away; acdream drew it floating above the window). - /// - /// - /// Override to ONLY for a widget that must draw or accept - /// input beyond its own bounds by deliberate design — today just - /// (whose popup, and its own out-of-bounds OnHitTest - /// override, stands in for retail's separate top-level popup region — see - /// for the drawing half of that opt-out and the - /// divergence register row it cites) and (whose own region - /// IS the screen — the viewport itself already scissors it, so narrowing to - /// (0,0,Width,Height) here would blank the whole UI the moment the root's - /// own tracked size is ever momentarily zero, e.g. before the first resize event - /// lands). - /// + /// When true, descendant drawing and hit-testing are clipped to this element's + /// local bounds. Scrollable listboxes use this so edge rows can remain visible + /// at arbitrary pixel offsets without painting or receiving input outside the viewport. /// - protected virtual bool ClipsChildren => true; - - /// - /// True when this element's content must ignore the - /// standard ancestor clip chain that now threads through - /// every element by default (CT-GF1). Retail spawns popups/dropdowns as SEPARATE - /// top-level regions (UIElement_Menu::MakePopup), so only the SCREEN clips - /// them — never an intervening window or panel's own client rect. Ours draws a - /// popup INLINE from its owning widget instead of reparenting to a new root (see - /// 's own doc comment — that second traversal already - /// exists so popups composite "regardless of this element's position in the - /// tree"), so without this escape hatch the new default clip would wrongly cut off - /// a popup that legitimately extends outside its owning window — e.g. a dropdown - /// opened near the bottom of a short window. Default false (ordinary overlay - /// content, if any is ever added beyond , stays clipped like - /// everything else). See the divergence register row this property's introducing - /// commit adds for the seam it stands in for. - /// - protected virtual bool ExpandsClipForPopup => false; + protected virtual bool ClipsChildren => false; /// Per-frame tick (animations, timers, caret blink). protected virtual void OnTick(double deltaSeconds) { } @@ -648,19 +593,7 @@ public abstract class UiElement /// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString" /// (vtable +0x88) to render the tooltip body. /// - /// - /// Runtime-created/plugin-markup widgets have no LayoutDesc property bag from - /// which to import P0x49. They use this live source instead; the markup host - /// still supplies retail's shared tooltip-popup locator, so presentation stays - /// inside the common retained tooltip pipeline rather than becoming plugin UI. - /// - public Func? RuntimeTooltipTextSource { get; set; } - - public virtual string? GetTooltipText() - { - string? text = RuntimeTooltipTextSource?.Invoke(); - return string.IsNullOrWhiteSpace(text) ? null : text; - } + public virtual string? GetTooltipText() => null; // ── Framework entry points (internal, called by UiRoot) ───────────── @@ -675,49 +608,39 @@ public abstract class UiElement // surface, chrome and glyphs together, not text-stays-sharp over a translucent panel). ctx.PushTransform(Left, Top); ctx.PushAlpha(Opacity); - // CT-GF1 fix round (S2): the clip now wraps OnDraw + children + - // OnDrawAfterChildren — the LITERAL UIRegion::DrawHere @0x0069FA30 shape, - // which clips the element's OWN DrawSelf to the intersected rect, not just its - // children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph blits to arg3; - // UIRegion::DrawSelf @0x0069F1A0 blits per clip rect). Pushed right after - // PushAlpha, popped in the one finally below, so it is balanced regardless of - // which branch below runs. - bool clipsChildren = ClipsChildren; - if (clipsChildren) - ctx.PushClip(0f, 0f, Width, Height); try { - // N2 fix round: retail's var_24 gate @0x0069FB8E — an EMPTY intersected - // clip skips EraseSelf/DrawChildren/DrawSelf outright for the whole - // subtree. DrawOverlays (the popup's SEPARATE second traversal) does not - // share this walk or its clip-stack state, so an open UiMenu popup nested - // here keeps drawing there regardless of this cull — see - // UiAncestorClipTests' menu-inside-a-fully-clipped-window coverage. - if (!ctx.CurrentClipIsEmpty) + OnDraw(ctx); + + // Anchor layout: reflow children to this element's current size. + for (int i = 0; i < _children.Count; i++) + _children[i].ApplyAnchor(Width, Height); + + // Children painted back-to-front (lowest ZOrder first). + if (_children.Count > 0) { - OnDraw(ctx); - - // Anchor layout: reflow children to this element's current size. - for (int i = 0; i < _children.Count; i++) - _children[i].ApplyAnchor(Width, Height); - - // Children painted back-to-front (lowest ZOrder first). - if (_children.Count > 0) + bool clipsChildren = ClipsChildren; + if (clipsChildren) + ctx.PushClip(0f, 0f, Width, Height); + try { UiElement[] ordered = ChildrenBackToFrontSnapshot(); for (int i = 0; i < ordered.Length; i++) ordered[i].DrawSelfAndChildren(ctx); } - - // Foreground pass for this element (e.g. a window frame's border drawn - // OVER its content's edges). Default no-op for ordinary elements. - OnDrawAfterChildren(ctx); + finally + { + if (clipsChildren) + ctx.PopClip(); + } } + + // Foreground pass for this element (e.g. a window frame's border drawn + // OVER its content's edges). Default no-op for ordinary elements. + OnDrawAfterChildren(ctx); } finally { - if (clipsChildren) - ctx.PopClip(); ctx.PopAlpha(); ctx.PopTransform(); } @@ -735,19 +658,7 @@ public abstract class UiElement ctx.PushAlpha(Opacity); try { - // ExpandsClipForPopup (CT-GF1): a popup drawn here must ignore whatever - // ancestor clip the walk down to this element accumulated — see the - // property's own doc comment for the retail-parity rationale. - if (ExpandsClipForPopup) - { - ctx.PushClipUnbounded(); - try { OnDrawOverlay(ctx); } - finally { ctx.PopClip(); } - } - else - { - OnDrawOverlay(ctx); - } + OnDrawOverlay(ctx); if (_children.Count > 0) { bool clipsChildren = ClipsChildren; @@ -781,8 +692,6 @@ public abstract class UiElement if (VisibleSource is { } visibility) Visible = visibility(); if (!Visible) return; - if (EnabledSource is { } enabled) - Enabled = enabled(); OnTick(dt); for (int i = 0; i < _children.Count; i++) _children[i].TickSelfAndChildren(dt); diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index 57f502b3..60636d11 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -87,22 +87,6 @@ public sealed class UiField : UiElement public Func? SpriteResolve { get; set; } /// Unfocused/default state sprite imported from the DAT. public uint BackgroundSprite { get; set; } - /// - /// The 1px vertical end-cap rails the chat input authors as consumed - /// Type-3 CHILDREN (0x10000017 left-anchored at X=0, 0x10000018 - /// right-anchored — both media 0x06004D67, authored ONLY for the - /// Normal_focussed state, live-DAT probed 2026-08-24). Retail lights - /// them with the field: the left one is the gold separator between the - /// channel button and the text (the owner-reported missing bar). - /// is true for fields, so - /// the factory folds the rails into these properties and the field - /// draws them itself while focused. Zero ids draw nothing. - /// - public uint FocusRailLeftSprite { get; set; } - public float FocusRailLeftWidth { get; set; } = 1f; - public uint FocusRailRightSprite { get; set; } - public float FocusRailRightWidth { get; set; } = 1f; - /// Gold "lit" field background drawn when focused (retail Normal_focussed /// state, RenderSurface 0x060011AB). 0 = no focus sprite. public uint FocusFieldSprite { get; set; } @@ -110,13 +94,6 @@ public sealed class UiField : UiElement public Action? OnSubmit { get; set; } public Action? OnFocusGained { get; set; } public Action? OnFocusLost { get; set; } - /// - /// Live text mutation callback used by retained plugin markup. This is - /// deliberately separate from submit/focus-loss: editors need their - /// binding model to track typing so an adjacent button can consume the - /// current value without reaching into the widget tree. - /// - public Action? OnTextChanged { get; set; } private string _textValue = ""; @@ -134,11 +111,8 @@ public sealed class UiField : UiElement get => _textValue; set { - if (string.Equals(_textValue, value, StringComparison.Ordinal)) - return; _textValue = value; _textVersion++; - OnTextChanged?.Invoke(value); } } @@ -445,26 +419,6 @@ public sealed class UiField : UiElement if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); else lit = false; } - if (_focused && SpriteResolve is not null) - { - // The authored focus rails (see FocusRailLeftSprite's doc): 1px - // vertical gold end caps, lit only while focused, exactly the - // rails' own Normal_focussed authoring. - if (FocusRailLeftSprite != 0) - { - var (tex, tw, _) = SpriteResolve(FocusRailLeftSprite); - if (tex != 0 && tw > 0) - ctx.DrawSprite(tex, 0, 0, FocusRailLeftWidth, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - if (FocusRailRightSprite != 0) - { - var (tex, tw, _) = SpriteResolve(FocusRailRightSprite); - if (tex != 0 && tw > 0) - ctx.DrawSprite( - tex, Width - FocusRailRightWidth, 0, - FocusRailRightWidth, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - } if (!lit && SpriteResolve is not null && BackgroundSprite != 0) { var (tex, tw, th) = SpriteResolve(BackgroundSprite); @@ -567,56 +521,57 @@ public sealed class UiField : UiElement Scroll.SetScrollY((int)MathF.Ceiling(caretBottom - visibleHeight)); } - // CT-GF1 fix round (S2): this used to push its OWN (0,0,Width,Height) clip - // here. That is now REDUNDANT and deleted — UiElement.DrawSelfAndChildren - // (DrawMultiLine is called from OnDraw) wraps this whole method in exactly - // that same (0,0,Width,Height) ambient clip by default, matching retail's - // DrawHere shape one level up instead of duplicating it here. The lines below - // still rely on SOME clip being active (a partially visible row must still be - // rejected, not drawn full-size) — that clip is now the ambient one. - var (selectionLow, selectionHigh) = SelSpan(); - for (int i = 0; i < lines.Count; i++) + ctx.PushClip(0f, 0f, Width, Height); + try { - WrappedLine line = lines[i]; - float y = Padding + (i * lineHeight) - Scroll.ScrollY; - if (y + lineHeight <= Padding || y >= Height - Padding) - continue; - - int lineEnd = line.Start + line.Length; - int highlightLow = Math.Max(selectionLow, line.Start); - int highlightHigh = Math.Min(selectionHigh, lineEnd); - if (HasSelection && highlightHigh > highlightLow) + var (selectionLow, selectionHigh) = SelSpan(); + for (int i = 0; i < lines.Count; i++) { - float x0 = Padding + MeasureRange( - line.Start, - highlightLow - line.Start); - float x1 = Padding + MeasureRange( - line.Start, - highlightHigh - line.Start); - ctx.DrawFill( - x0, - y, - MathF.Max(0f, x1 - x0), - lineHeight, - SelectionColor); + WrappedLine line = lines[i]; + float y = Padding + (i * lineHeight) - Scroll.ScrollY; + if (y + lineHeight <= Padding || y >= Height - Padding) + continue; + + int lineEnd = line.Start + line.Length; + int highlightLow = Math.Max(selectionLow, line.Start); + int highlightHigh = Math.Min(selectionHigh, lineEnd); + if (HasSelection && highlightHigh > highlightLow) + { + float x0 = Padding + MeasureRange( + line.Start, + highlightLow - line.Start); + float x1 = Padding + MeasureRange( + line.Start, + highlightHigh - line.Start); + ctx.DrawFill( + x0, + y, + MathF.Max(0f, x1 - x0), + lineHeight, + SelectionColor); + } + + if (DatFont is { } dat) + ctx.DrawStringDat(dat, line.Text, Padding, y, TextColor, Outline, OutlineColor); + else if (Font is { } bitmap) + ctx.DrawString(line.Text, Padding, y, TextColor, bitmap); } - if (DatFont is { } dat) - ctx.DrawStringDat(dat, line.Text, Padding, y, TextColor, Outline, OutlineColor); - else if (Font is { } bitmap) - ctx.DrawString(line.Text, Padding, y, TextColor, bitmap); + if (_focused && lines.Count > 0) + { + WrappedLine line = lines[caretLine]; + int lineColumn = Math.Clamp( + _caret - line.Start, + 0, + line.Length); + float x = Padding + MeasureRange(line.Start, lineColumn); + float y = Padding + (caretLine * lineHeight) - Scroll.ScrollY; + ctx.DrawFill(x, y, 1f, lineHeight, TextColor); + } } - - if (_focused && lines.Count > 0) + finally { - WrappedLine line = lines[caretLine]; - int lineColumn = Math.Clamp( - _caret - line.Start, - 0, - line.Length); - float x = Padding + MeasureRange(line.Start, lineColumn); - float y = Padding + (caretLine * lineHeight) - Scroll.ScrollY; - ctx.DrawFill(x, y, 1f, lineHeight, TextColor); + ctx.PopClip(); } } diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index 2473b969..d96b3654 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -358,13 +358,7 @@ public sealed class UiItemList : UiElement } } - // CT-GF1 fix round (S3): the former `ClipsChildren => CellWidth > 0f` override is - // DELETED. It made sense under the PRE-CT-GF1 opt-in default (false): clip only - // once CellWidth is configured. Under the new client-wide default (true), that - // same expression is INVERTED — an unconfigured list (CellWidth<=0, e.g. before - // LayoutCells first runs) would evaluate to false and stop clipping, the opposite - // of every other element's new default. Deleting the override restores the - // uniform default (always clip to this element's own (0,0,Width,Height)). + protected override bool ClipsChildren => CellWidth > 0f; public void Flush() { diff --git a/src/AcDream.App/UI/UiMarkupList.cs b/src/AcDream.App/UI/UiMarkupList.cs deleted file mode 100644 index 6e9d0125..00000000 --- a/src/AcDream.App/UI/UiMarkupList.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// Lightweight, data-bound string list for plugin markup. It owns only row -/// selection and scroll position; the plugin binding remains the sole owner of -/// rows and selected index. This deliberately avoids exposing App widget types -/// through the BCL plugin contract. -/// -public sealed class UiMarkupList : UiElement -{ - public Func> ItemsSource { get; set; } = - static () => Array.Empty(); - public Func> ItemColorsSource { get; set; } = - static () => Array.Empty(); - public Func SelectedIndexSource { get; set; } = static () => -1; - public Action? SelectionChanged { get; set; } - public UiDatFont? DatFont { get; set; } - public float RowHeight { get; set; } = 18f; - public float Padding { get; set; } = 3f; - public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.92f); - public Vector4 BorderColor { get; set; } = new(0.46f, 0.37f, 0.16f, 1f); - public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f); - public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f); - - private int _topRow; - - public override bool HandlesClick => true; - - protected override void OnDraw(UiRenderContext context) - { - IReadOnlyList items = ItemsSource(); - IReadOnlyList itemColors = ItemColorsSource(); - int visibleRows = VisibleRows; - int selected = SelectedIndexSource(); - if (selected >= 0 && selected < items.Count) - { - if (selected < _topRow) - _topRow = selected; - else if (selected >= _topRow + visibleRows) - _topRow = selected - visibleRows + 1; - } - ClampTop(items.Count, visibleRows); - - context.DrawFill(0f, 0f, Width, Height, BackgroundColor); - context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f); - int end = Math.Min(items.Count, _topRow + visibleRows); - for (int index = _topRow; index < end; index++) - { - float y = (index - _topRow) * RowHeight; - if (index == selected) - context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor); - string text = items[index]; - Vector4 textColor = index < itemColors.Count - ? Rgb(itemColors[index]) - : TextColor; - float textY = y + MathF.Max(0f, - (RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f); - if (DatFont is { } font) - context.DrawStringDat(font, text, Padding, textY, textColor, true); - else - context.DrawString(text, Padding, textY, textColor); - } - } - - public override bool OnEvent(in UiEvent e) - { - IReadOnlyList items = ItemsSource(); - if (e.Type == UiEventType.Scroll) - { - _topRow -= Math.Sign(e.Data0); - ClampTop(items.Count, VisibleRows); - return true; - } - if (e.Type != UiEventType.MouseDown || !Enabled) - return false; - int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight)); - int index = _topRow + row; - if (row >= 0 && row < VisibleRows && index >= 0 && index < items.Count) - SelectionChanged?.Invoke(index); - return true; - } - - private int VisibleRows => Math.Max(1, (int)MathF.Floor( - Height / MathF.Max(1f, RowHeight))); - - private void ClampTop(int count, int visibleRows) => - _topRow = Math.Clamp(_topRow, 0, Math.Max(0, count - visibleRows)); - - private static Vector4 Rgb(uint value) => new( - ((value >> 16) & 0xFFu) / 255f, - ((value >> 8) & 0xFFu) / 255f, - (value & 0xFFu) / 255f, - 1f); -} diff --git a/src/AcDream.App/UI/UiMarkupTabButton.cs b/src/AcDream.App/UI/UiMarkupTabButton.cs deleted file mode 100644 index c6ef28f8..00000000 --- a/src/AcDream.App/UI/UiMarkupTabButton.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// Compact KSML tab used by plugin windows. It deliberately uses the retained -/// input/font path and VTank's text-strip presentation rather than introducing -/// a plugin-owned renderer. -/// -public sealed class UiMarkupTabButton : UiSimpleButton -{ - private static readonly Vector4 ActiveText = - new(0.94f, 0.76f, 0.18f, 1f); - private static readonly Vector4 NormalText = - new(0.78f, 0.76f, 0.67f, 1f); - private static readonly Vector4 DisabledText = - new(0.34f, 0.33f, 0.29f, 1f); - private static readonly Vector4 Underline = - new(0.77f, 0.59f, 0.12f, 1f); - - public Func? SelectedSource { get; set; } - - public bool IsSelected => SelectedSource?.Invoke() ?? false; - - public UiMarkupTabButton() - { - BackgroundColor = Vector4.Zero; - BorderColor = Vector4.Zero; - BorderThickness = 0f; - Outline = true; - } - - protected override void OnTick(double deltaSeconds) - { - base.OnTick(deltaSeconds); - TextColor = !Enabled - ? DisabledText - : IsSelected ? ActiveText : NormalText; - } - - protected override void OnDraw(UiRenderContext ctx) - { - base.OnDraw(ctx); - if (IsSelected) - ctx.DrawFill(2f, Height - 2f, MathF.Max(0f, Width - 4f), 1f, Underline); - } -} diff --git a/src/AcDream.App/UI/UiMarkupToggle.cs b/src/AcDream.App/UI/UiMarkupToggle.cs deleted file mode 100644 index bae133f0..00000000 --- a/src/AcDream.App/UI/UiMarkupToggle.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// KSML boolean toggle with the compact lamp-and-caption presentation used by -/// VTank. State and action remain reflected BCL bindings owned by the plugin. -/// -public sealed class UiMarkupToggle : UiElement -{ - private static readonly Vector4 CheckedOuter = - new(0.36f, 0.58f, 0.12f, 1f); - private static readonly Vector4 CheckedInner = - new(0.52f, 1f, 0.08f, 1f); - private static readonly Vector4 UncheckedOuter = - new(0.26f, 0.22f, 0.13f, 1f); - private static readonly Vector4 UncheckedInner = - new(0.38f, 0.34f, 0.23f, 1f); - - public string Text { get; set; } = string.Empty; - public Func? TextSource { get; set; } - public Func? CheckedSource { get; set; } - public UiDatFont? DatFont { get; set; } - public Vector4 TextColor { get; set; } = - new(0.86f, 0.84f, 0.74f, 1f); - public Action? Toggle { get; set; } - - public bool IsChecked => CheckedSource?.Invoke() ?? false; - - public override bool HandlesClick => true; - - public override bool OnEvent(in UiEvent e) - { - if (e.Type != UiEventType.Click || !Enabled) - return false; - Toggle?.Invoke(); - return true; - } - - protected override void OnDraw(UiRenderContext ctx) - { - Vector4 outer = IsChecked ? CheckedOuter : UncheckedOuter; - Vector4 inner = IsChecked ? CheckedInner : UncheckedInner; - DrawLamp(ctx, 1f, MathF.Max(1f, (Height - 11f) * 0.5f), outer, inner); - - string caption = TextSource?.Invoke() ?? Text; - Vector4 color = Enabled - ? TextColor - : new Vector4(TextColor.X, TextColor.Y, TextColor.Z, 0.42f); - float y = DatFont is { } font - ? (Height - font.LineHeight) * 0.5f - : 1f; - if (DatFont is { } dat) - ctx.DrawStringDat(dat, caption, 17f, y, color, outline: true); - else - ctx.DrawString(caption, 17f, y, color); - } - - private static void DrawLamp( - UiRenderContext ctx, - float x, - float y, - Vector4 outer, - Vector4 inner) - { - // Five bands form the small circular indicator without introducing a - // plugin bitmap or a new renderer primitive. - ctx.DrawFill(x + 3f, y, 5f, 1f, outer); - ctx.DrawFill(x + 1f, y + 1f, 9f, 2f, outer); - ctx.DrawFill(x, y + 3f, 11f, 5f, outer); - ctx.DrawFill(x + 1f, y + 8f, 9f, 2f, outer); - ctx.DrawFill(x + 3f, y + 10f, 5f, 1f, outer); - ctx.DrawFill(x + 3f, y + 3f, 5f, 5f, inner); - } -} diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index f2b7237b..5cff6a3c 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -74,9 +74,7 @@ public sealed class UiMenu : UiElement string? live = TooltipTextProvider?.Invoke(); if (!string.IsNullOrWhiteSpace(live)) return live; - return string.IsNullOrWhiteSpace(TooltipText) - ? base.GetTooltipText() - : TooltipText; + return string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText; } public int RowsPerColumn { get; set; } = 7; // items per column (dat item template); @@ -198,23 +196,7 @@ public sealed class UiMenu : UiElement /// reason). public uint CurrentArrowCapSprite => _open ? ArrowCapOpenSprite : ArrowCapClosedSprite; - /// The face sprite would pick right now — - /// keyed on the momentary physical press (_facePressed), NOT on - /// open state (see _facePressed's doc). Test seam. - public uint CurrentFaceSpriteForTest => _facePressed ? PressedSprite : NormalSprite; - public UiDatFont? DatFont { get; set; } - - /// - /// Optional separate font for the BUTTON caption. The chat talk button's - /// authored label child (0x10000015) carries its own FontDid 0x40000002 — - /// different from the popup rows/transcript font — which is how retail - /// fits 'Chat'/'Gen' inside the authored 46x17 face (live-DAT probed - /// 2026-08-24, the owner-reported "button too big" delta). Null falls - /// back to . - /// - public UiDatFont? ButtonDatFont { get; set; } - public AcDream.App.Rendering.BitmapFont? Font { get; set; } /// Retail LayoutDesc property 0x21 (two-pass glyph outline, @@ -290,19 +272,6 @@ public sealed class UiMenu : UiElement private bool _open; - /// - /// True only while the pointer is physically pressed on the button FACE. - /// 2026-08-24 owner report: the chat channel button stayed green (pressed - /// art) the whole time the popup was open — retail's pressed face - /// (0x06004D66) is the ordinary momentary button press ("flicks green"), - /// while the OPEN state drives only the arrow-cap child's state swap - /// (UIElement_Menu::UpdateState @0x0046cad0 writes attribute 0xe, which - /// the cap element's own StateDesc consumes — see - /// 's doc). The face must key on the - /// physical press, not on . - /// - private bool _facePressed; - /// Whether the popup is currently open (test/inspection seam, /// same rationale as /). public bool IsOpen => _open; @@ -398,31 +367,6 @@ public sealed class UiMenu : UiElement /// must NOT be built (an invisible label child would intercept the button click). public override bool ConsumesDatChildren => true; - /// - /// CT-GF1 opt-out: 's new client-wide default - /// (true) also gates 's early "am I even inside my - /// own bounds" check — which would return null for every popup click before ever - /// reaching 's own out-of-bounds union below (the popup - /// occupies ly < 0 when it opens upward, or ly >= Height when it - /// opens downward — see 's doc). UiMenu has no real dat - /// children ( is true), so this override changes - /// nothing about child drawing/hit-testing; it exists purely to keep this element's - /// OWN out-of-bounds popup region reachable, pairing with - /// below for the drawing half of the same escape. - /// - protected override bool ClipsChildren => false; - - /// - /// CT-GF1: the popup drawn in is retail's stand-in for a - /// SEPARATE top-level region (UIElement_Menu::MakePopup) — see - /// 's own doc comment for the full - /// rationale and the divergence register row it cites. Without this, the new - /// default ancestor clip would cut off a popup that legitimately opens outside its - /// owning window (e.g. a short chat window's channel dropdown, which draws its rows - /// ABOVE the button and can extend past the window's own top edge). - /// - protected override bool ExpandsClipForPopup => true; - protected override void OnDraw(UiRenderContext ctx) { var resolve = SpriteResolve; @@ -430,7 +374,7 @@ public sealed class UiMenu : UiElement // Button face (3-sliced so it can widen to fit the label) + the active-target label. if (resolve is not null) { - var (tex, tw, _) = resolve(_facePressed ? PressedSprite : NormalSprite); + var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite); if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw); } string caption = ButtonLabelProvider?.Invoke() ?? ""; @@ -438,17 +382,10 @@ public sealed class UiMenu : UiElement // label child spans the button MINUS the arrow-cap overlay's right // socket (0x10000355 is 100 wide of the 117 button, docked; the // arrow child overlays the last 17px — menuprobe3). - UiDatFont? captionFont = ButtonDatFont ?? DatFont; - float captionW = captionFont?.MeasureWidth(caption) - ?? Font?.MeasureWidth(caption) ?? caption.Length * 7f; - float captionLineH = captionFont?.LineHeight ?? Font?.LineHeight ?? 14f; float capX = ButtonTextCentered - ? MathF.Max(0f, (Width - (ArrowCapClosedSprite != 0 ? ArrowCapWidth : 0f) - captionW) * 0.5f) + ? MathF.Max(0f, (Width - (ArrowCapClosedSprite != 0 ? ArrowCapWidth : 0f) - MeasureText(caption)) * 0.5f) : ButtonTextIndent; - if (captionFont is { } cf) - ctx.DrawStringDat(cf, caption, capX, (Height - captionLineH) * 0.5f, TextColor, Outline, OutlineColor); - else - ctx.DrawString(caption, capX, (Height - captionLineH) * 0.5f, TextColor, Font); + DrawLabel(ctx, caption, capX, (Height - LineH()) * 0.5f, TextColor); // G6: the open/closed arrow-cap overlay — see ArrowCapClosedSprite's doc comment. if (resolve is not null) DrawArrowCap(ctx, resolve); @@ -488,8 +425,7 @@ public sealed class UiMenu : UiElement public float NaturalButtonWidth() { string text = ButtonLabelProvider?.Invoke() ?? ""; - UiDatFont? nf = ButtonDatFont ?? DatFont; - float textW = nf?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f; + float textW = DatFont?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f; return ButtonTextIndent + textW + 4f + FaceCapR; // text start (clears LED) + text + gap + arrow cap } @@ -724,14 +660,6 @@ public sealed class UiMenu : UiElement } } - if (e.Type is UiEventType.MouseUp - or UiEventType.HoverLeave - or UiEventType.CaptureChanged) - { - _facePressed = false; // the momentary face flick ends here - return false; - } - if (e.Type != UiEventType.MouseDown) return false; float lx = e.Data1, ly = e.Data2; @@ -772,7 +700,6 @@ public sealed class UiMenu : UiElement // Retail Open @0x0046cc42 refuses an empty list (gates on // m_listBox->m_listItems.m_num != 0) — a bare click on an itemless // menu is a no-op rather than an empty popup. - _facePressed = true; // momentary press flick if (!_open && Items.Count == 0) return true; SetOpen(!_open); // toggle on button click return true; diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index 926eb1e8..f346432f 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -26,35 +26,8 @@ public class UiPanel : UiElement /// Optional dat RenderSurface id for the panel background sprite, drawn /// in place of (or alongside) . 0 = none. - /// CT5 fix round (SHOULD-FIX 4): drawn at NATIVE SIZE — retail's - /// copy-or-tile blit, never scaled (see UiDatElement.OnDraw's own - /// doc comment, ~lines 273-360, for the full ground truth). The UV - /// rectangle below (Width / tw, Height / th) is deliberately - /// UV-REPEAT, not a fixed 0..1 a stretch would use — GL_REPEAT-wrapped - /// UI textures tile past their native pixel size rather than scaling. - /// Retail's generic sprite blit (Graphic::Draw 0x00693b20 / - /// Graphic::PutImage 0x00693a30) has exactly two behaviors, copy - /// or tile, and can never scale a source image up to fill a larger - /// destination. - /// Used by the character-panel attribute/skill row's Highlight-state - /// swap (sprite 0x06000F93, template 0x10000248's Highlight-state - /// media) and CT5's Normal-state row background (0x06004CC2). The two - /// sprites behave differently under this same tile formula: 0x06000F93 - /// is authored at exactly the row's own 282x20 native size (decoded - /// against the installed DAT, 2026-08-25: PFID_R8G8B8, 282x20), so it - /// draws as a plain COPY with no visible seam; 0x06004CC2 is a 48x48 - /// uniform-color tile (PFID_A8R8G8B8, single color (0,0,0,175)) that - /// visibly TILES across the wider row — both are the same code path, - /// just different source-vs-destination ratios. - /// Retail's own UIRegion::SetImageByDID (@0x0069F960) - /// confirms its third parameter is a BlitMode COLOR-BLEND - /// selector, not a resize flag — the decompiled body switches purely on - /// that value (param_2 == 2 -> Blit_3Alpha, - /// == 3 -> Blit_4Alpha, else Blit_Normal) and - /// never touches width/height at all. This answers CT1's open "icon - /// draw mode 3" question (ground-truth doc §2): mode 3 selects - /// Blit_4Alpha, an alpha-blend variant, not a resize. - /// + /// When set, the sprite is stretched to fill the panel rect. + /// Used by the attribute-list selected-row highlight (sprite 0x06001397 = Button state 6). public uint BackgroundSprite { get; set; } /// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height). @@ -117,31 +90,9 @@ public class UiLabel : UiElement public UiLabel() { ClickThrough = true; } - /// - /// CT7 gate regression (2026-08-25, MossTank plugin text vanished): a - /// markup <label> authors position only, so this element's - /// box is degenerate (0x0) — under CT-GF1's completed - /// UIRegion::DrawHere @0x0069FA30 port the self-clip cropped the - /// glyphs to nothing. A label must not self-clip: it is - /// pure text whose real clip is - /// its ancestors (the plugin panel/window, which ARE properly sized) — - /// the same effective containment retail gives a text region whose box - /// hugs its glyphs. below also keeps the box - /// truthful by measuring the current text, so layout/hit consumers see - /// real extents (converges the frame after a bound text change). - /// - protected override bool ClipsChildren => false; - protected override void OnDraw(UiRenderContext ctx) { string text = TextSource?.Invoke() ?? Text; - float w = DatFont is { } df - ? df.MeasureWidth(text) - : (ctx.DefaultFont?.MeasureWidth(text) ?? text.Length * 7f); - float h = DatFont?.LineHeight - ?? ctx.DefaultFont?.LineHeight ?? 14f; - if (w != Width) Width = w; - if (h != Height) Height = h; if (DatFont is { } dat) ctx.DrawStringDat(dat, text, 0, 0, TextColor, Outline); else @@ -240,25 +191,29 @@ public class UiSimpleButton : UiPanel /// parent window. In acdream we wire the equivalent via this action callback instead of /// the retail message bus. /// -/// Campaign CT slice CT5 (2026-08-25): the selected-row highlight draws through the -/// inherited full-panel -/// draw (native-size copy-or-tile — see that property's own doc comment for the CT5 fix -/// round's correction of the mechanism; NOT a stretch) — no override here. This class -/// previously had its own "selection bars" draw mode (a thin top/bottom-bar rendering -/// tuned to look like sprite 0x06001397's dark bars); CT1's ground-truth research found -/// that sprite belongs to a DIFFERENT retail mechanism entirely (the spellbook row's -/// overlay child), and the row's actual retail Highlight state (InfoRegion::SetState, -/// media 0x06000F93) is a plain full-row background SWAP — exactly what the inherited -/// already draws (0x06000F93 is authored at exactly the row's -/// own 282x20 native size, so the swap needs no scaling to look right). The bars mode was -/// therefore retired rather than reconfigured to a wrong sprite's geometry; no consumer -/// outside CharacterStatController ever set it. +/// When is true and +/// is non-zero, draws the sprite as a thin full-width bar at the TOP and BOTTOM edges of +/// the row (not stretched to fill). This matches retail's selection highlight which shows +/// a horizontal dark bar on both the top and bottom edge of the selected attribute row, +/// with NO left/right end-caps. Bar height is pixels +/// (default 3px). /// public class UiClickablePanel : UiPanel { /// Called when the user releases the left mouse button over this panel. public Action? OnClick { get; set; } + /// When true and is non-zero, draws + /// the sprite as a thin horizontal bar at the top AND bottom edges of the panel, + /// NOT as a full-height stretched fill. Matches retail's selected-row highlight + /// (sprite 0x06001397 — 300×32 px — shown as bars, not a block fill). + /// Default false (preserves legacy full-stretch behavior). + public bool UseSelectionBars { get; set; } + + /// Height in pixels of each selection bar (top and bottom). Default 3px. + /// Ignored when is false. + public float SelectionBarHeight { get; set; } = 3f; + /// Settable tooltip, surfaced through the shared /// hover pipeline (same pattern as /// / ). TS-85's @@ -295,4 +250,33 @@ public class UiClickablePanel : UiPanel } return false; } + + protected override void OnDraw(UiRenderContext ctx) + { + if (UseSelectionBars && BackgroundSprite != 0 && SpriteResolve is { } sr) + { + // Draw the selection highlight as a thin bar at the TOP and BOTTOM of the row. + // The sprite (0x06001397) is 300×32 px — we draw it as horizontal strips at + // native height (SelectionBarHeight), stretched to full panel width (UV tile + // horizontally). No left/right end-caps: u0=0, u1=Width/nativeW (UV repeat). + var (tex, tw, th) = sr(BackgroundSprite); + if (tex != 0 && tw > 0 && th > 0) + { + float barH = SelectionBarHeight; + float uTile = tw > 0 ? Width / tw : 1f; + // Top bar: shows the top barH px of the sprite (v = 0 → barH/th). + float vBot = th > 0 ? barH / th : 1f; + ctx.DrawSprite(tex, 0f, 0f, Width, barH, 0f, 0f, uTile, vBot, Vector4.One); + // Bottom bar: shows the bottom barH px of the sprite (v = 1−barH/th → 1). + float vTop2 = th > 0 ? 1f - barH / th : 0f; + ctx.DrawSprite(tex, 0f, Height - barH, Width, barH, 0f, vTop2, uTile, 1f, Vector4.One); + } + // Selection-bar mode draws no border (rows have BorderColor=Zero by design). + } + else + { + // Default UiPanel draw: handles BackgroundSprite, BackgroundColor, AND border. + base.OnDraw(ctx); + } + } } diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index a91ee7cb..d4ed5b11 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -115,37 +115,6 @@ public sealed class UiRenderContext _clipStack.RemoveAt(_clipStack.Count - 1); } - /// - /// True when the current accumulated clip is non-null and has zero (or negative) - /// area — CT-GF1 fix-round subtree cull, porting retail's - /// UIRegion::DrawHere var_24 gate @0x0069FB8E: an empty intersected - /// clip skips EraseSelf/DrawChildren/DrawSelf for the whole - /// subtree, not just individual draw calls (those already no-op against an empty - /// clip via / — this - /// additionally skips the WALK). A null clip (nothing pushed yet, or reset via - /// ) is NOT empty — it means unbounded, so this is - /// false in that case. - /// - public bool CurrentClipIsEmpty => _clip is { } c && c.IsEmpty; - - /// - /// Reset the accumulated clip to the full CANVAS rect (0,0,ScreenSize) for the - /// duration of one overlay draw — the escape hatch - /// uses so a popup drawn inline from its owning widget (see that property's doc - /// comment for the retail-parity rationale) is not wrongly clipped by the - /// ancestor chain the CT-GF1 default clip () - /// now threads through every other element. Retail's own popup region is still - /// SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region - /// bounded by the screen, not truly infinite) — this is the canvas rect, not - /// null/unbounded, matching that. Shares 's stack, so - /// pair the two exactly like . - /// - public void PushClipUnbounded() - { - _clipStack.Add(_clip); - _clip = new UiClipRect(0f, 0f, ScreenSize.X, ScreenSize.Y); - } - /// Route subsequent draws to the overlay layer (flushed on top of the whole /// UI). Used by the root for the popup/overlay traversal. Pair with . public void BeginOverlayLayer() => TextRenderer.OverlayMode = true; @@ -369,16 +338,7 @@ public sealed class UiRenderContext // digits never showed it because their bar baseline lands on an integer; chat text // does. Snapping the baseline once, then adding the integer offset, keeps the whole // line on one row and pixel-aligned. - // - // HALF-UP, not MathF.Round (2026-08-24 owner report: window-title and - // button labels "vibrate" while dragging a window): MathF.Round is - // banker's rounding — a centered label whose origin carries a constant - // .5 fraction (odd text width over /2) alternates round-up/round-down - // as the dragged window crosses successive integers, so the text - // double-steps then sticks while the background glides 1px per frame. - // Floor(v + 0.5) snaps every tie the same direction: constant fraction - // → uniform 1px steps in lock-step with the sprites. - float baseY = System.MathF.Floor(originY + 0.5f); + float baseY = System.MathF.Round(originY); float pen = originX; for (int i = 0; i < text.Length; i++) @@ -389,8 +349,7 @@ public sealed class UiRenderContext // Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its // true fractional advance). Vertical: integer baseline + integer per-glyph // offset — never an independent per-glyph round (see baseY's note above). - // Half-up for the same anti-vibration reason as baseY. - float gx = System.MathF.Floor(pen + g.HorizontalOffsetBefore + 0.5f); + float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore); float gy = baseY + g.VerticalOffsetBefore; float gw = g.Width; float gh = g.Height; diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 3f0752a2..f22005eb 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -33,19 +33,6 @@ public sealed class UiRoot : UiElement /// Single owner for named retained-window lifecycle and raise policy. public RetailWindowManager WindowManager { get; } - /// - /// CT-GF1 fix round (N1): the root's own region IS the screen — the viewport - /// itself already scissors everything drawn to it, so retail has no analog of - /// clipping the root to its OWN tracked (Width,Height) the way - /// 's new client-wide default would. - /// Overriding false here is a safety net, not a cosmetic choice: without it, a - /// root momentarily reporting a zero (or stale, pre-first-resize) size would - /// silently blank the ENTIRE UI tree — every top-level window culled by the new - /// ancestor-clip default's empty-intersection gate — rather than the intended - /// "root passes its full extent through to its children uninterpreted." - /// - protected override bool ClipsChildren => false; - /// /// Campaign LA gate round 2 (register AD-98): when set, the retained tree /// is laid out in this fixed authored canvas (the char-select screen's @@ -184,12 +171,6 @@ public sealed class UiRoot : UiElement /// Widget currently receiving keyboard events. public UiElement? KeyboardFocus { get; private set; } - // The dispatcher is attached before retained UI. A semantic binding can - // therefore focus chat before this tree receives the same native key. - // Suppress that exact key through KeyChar/KeyUp so it cannot immediately - // submit the newly-focused field or insert a rebound printable key. - private int? _suppressedPhysicalKey; - /// The edit control activated by Tab/Enter when nothing is focused — retail's /// chat input "write mode" toggle. Set by the host once the chat window is built. public UiElement? DefaultTextInput { get; set; } @@ -208,27 +189,8 @@ public sealed class UiRoot : UiElement /// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game /// actions (movement, world-pick) are suppressed while the user interacts with /// a retail window — mirrors ImGui's WantCaptureMouse. - /// - /// - /// CT-GF1 fix round (S4): also checks — an open UiMenu - /// dropdown's escaped region (the part of the popup that extends outside its - /// owning window's own ancestor-clipped bounds, e.g. a channel menu opened - /// upward past a short chat window's top edge) is reachable by - /// / through - /// specifically BECAUSE it bypasses the ordinary top-down - /// walk's ancestor clip gate (CT-GF1's new client-wide - /// default would otherwise reject that - /// point before ever reaching the popup's own out-of-bounds OnHitTest - /// union — see #374's popup routing doc above). Without this, - /// alone would report "no widget here" for a point - /// the pointer visibly sits over, letting a world click/movement action slip - /// through underneath the open popup. - /// /// - public bool WantsMouse => - Captured is not null - || PopupHit(MouseX, MouseY) is not null - || HitTestTopDown(MouseX, MouseY).element is not null; + public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null; /// True when a widget holds keyboard focus (e.g. a focused chat input). public bool WantsKeyboard => KeyboardFocus is not null; @@ -503,18 +465,7 @@ public sealed class UiRoot : UiElement internal void OnSubtreeRemoving(UiElement subtree) { - // Inventory/external-container lists rebuild procedurally when an - // authoritative object update arrives. That rebuild removes each old - // UIItem before adding its replacement. Once BeginDrag has promoted - // the gesture, however, retail's UIElementManager owns a separate - // root-level drag element (StartDragandDrop @ 0x0045E040) and transfers - // mouse capture to it; the source list cell is no longer the gesture's - // lifetime owner. Our drag ghost is likewise snapshotted/root-owned, - // so preserve it when the exact source leaf is replaced mid-drag and - // transfer capture to this root. Removing a containing subtree (window - // teardown) still cancels normally. - bool replacingActiveDragSource = ReferenceEquals(subtree, DragSource); - ClearSubtreeOwnership(subtree, preserveDetachedDrag: replacingActiveDragSource); + ClearSubtreeOwnership(subtree); WindowManager.OnSubtreeRemoving(subtree); } @@ -528,16 +479,13 @@ public sealed class UiRoot : UiElement internal void OnElementVisibilityChanged(UiElement element, bool visible) => ElementVisibilityChanged?.Invoke(element, visible); - internal void ClearSubtreeOwnership(UiElement subtree, bool preserveDetachedDrag = false) + internal void ClearSubtreeOwnership(UiElement subtree) { if (IsWithinSubtree(KeyboardFocus, subtree)) SetKeyboardFocus(null); if (IsWithinSubtree(Captured, subtree)) { - if (preserveDetachedDrag && ReferenceEquals(Captured, DragSource)) - SetCapture(this); - else - ReleaseCapture(); + ReleaseCapture(); _dragCandidate = false; } if (IsWithinSubtree(DefaultTextInput, subtree)) @@ -547,13 +495,10 @@ public sealed class UiRoot : UiElement if (IsWithinSubtree(DragSource, subtree)) { DragSource?.SetDragSourceActive(false, DragPayload); - if (!preserveDetachedDrag) - { - DragSource = null; - DragPayload = null; - _dragGhost = null; - _dragCandidate = false; - } + DragSource = null; + DragPayload = null; + _dragGhost = null; + _dragCandidate = false; } if (IsWithinSubtree(_hoverWidget, subtree)) { @@ -1113,15 +1058,13 @@ public sealed class UiRoot : UiElement public void OnKeyDown(int vk, uint lparam = 0) { - if (_suppressedPhysicalKey == vk) - return; - // Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat // input (retail's chat-activation hotkeys). Consumed so the same press doesn't // also fall through to a game hotkey. if (KeyboardFocus is null && DefaultTextInput is not null && (vk == (int)Silk.NET.Input.Key.Tab - || vk == (int)Silk.NET.Input.Key.Enter)) + || vk == (int)Silk.NET.Input.Key.Enter + || vk == (int)Silk.NET.Input.Key.KeypadEnter)) { SetKeyboardFocus(DefaultTextInput); return; @@ -1150,11 +1093,6 @@ public sealed class UiRoot : UiElement public void OnKeyUp(int vk, uint lparam = 0) { - if (_suppressedPhysicalKey == vk) - { - _suppressedPhysicalKey = null; - return; - } if (KeyboardFocus is not null) { var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyUp, @@ -1166,18 +1104,12 @@ public sealed class UiRoot : UiElement public void OnChar(int codepoint) { - if (_suppressedPhysicalKey is not null) - return; if (KeyboardFocus is null || !KeyboardFocus.IsEditControl) return; var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.Char, Data0: codepoint); BubbleEvent(KeyboardFocus, in e); } - /// Suppress the raw retained-UI tail of a semantic key action. - public void SuppressPhysicalKeyUntilRelease(Silk.NET.Input.Key key) - => _suppressedPhysicalKey = (int)key; - // ── Focus + capture ───────────────────────────────────────────────── public void SetKeyboardFocus(UiElement? e) diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 8f8fa702..3892bdac 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -12,13 +12,9 @@ namespace AcDream.App.UI; /// Dat element ids (chat LayoutDesc 0x2100006F, Campaign CH slice CH6a — retired the /// wrong 0x21000006 import): track 0x10000012 (X=384 Y=0 W=16 H=73 relative to the /// transcript panel). The track is instanced from base layout 0x2100003E which contains -/// the full scrollbar widget with distinct button children. IMPORTANT — retail seats -/// the buttons by DESIGNATION, not authored position: UpdateScrollingArea -/// @0x00470AA0 moves the INCREMENT designee (attribute 0x77 = 0x10000072, the -/// UP-arrow art 0x06004C6C, authored at Y=32) to the TOP and the DECREMENT designee -/// (attribute 0x78 = 0x10000071, the DOWN-arrow art 0x06004C69, authored at Y=0) to -/// the BOTTOM. Seating by authored Y renders both arrows upside down (2026-08-24 -/// owner report). holds the correctly-seated set. +/// the full scrollbar widget with distinct up/down button children: +/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69. +/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C. /// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat). /// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66. /// The widget reproduces referenced button children procedurally and uses their @@ -37,12 +33,6 @@ public sealed class UiScrollbar : UiElement /// public float ScalarPosition { get; private set; } public Action? ScalarChanged { get; set; } - /// - /// Optional live scalar reader used by plugin markup. It is sampled while - /// no thumb gesture is active so external/profile changes reach the widget - /// without fighting the value under the user's cursor. - /// - public Func? ScalarPositionSource { get; set; } public bool Horizontal { get; set; } /// True while a thumb drag is in progress (between a thumb-hit @@ -100,13 +90,6 @@ public sealed class UiScrollbar : UiElement public void SetScalarPosition(float position) => ScalarPosition = Math.Clamp(position, 0f, 1f); - protected override void OnTick(double deltaSeconds) - { - base.OnTick(deltaSeconds); - if (!_draggingThumb && ScalarPositionSource?.Invoke() is { } value) - SetScalarPosition(value); - } - /// Settable tooltip, surfaced through the shared /// hover pipeline — the SAME /// pattern already established @@ -118,9 +101,7 @@ public sealed class UiScrollbar : UiElement /// public override string? GetTooltipText() => - string.IsNullOrWhiteSpace(TooltipText) - ? base.GetTooltipText() - : TooltipText; + string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText; /// RenderSurface id → (GL tex, w, h). 0 id = skip. public Func? SpriteResolve { get; set; } @@ -137,31 +118,10 @@ public sealed class UiScrollbar : UiElement /// Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall). public uint ThumbBotSprite { get; set; } - /// - /// Thumb rollover/pressed media. Retail's thumb (the scrollbar's - /// structural child 1 — UIElement_Scrollbar::UpdateLayout - /// @0x004710d0 binds m_pWidget = GetChild(this, 1)) authors - /// three states per slice: Normal (dark navy), Normal_rollover (bright - /// blue highlight), Normal_pressed (dark again). Hovering the thumb - /// highlights it; holding a drag shows the pressed art, which is - /// authored to look like the resting color — the 2026-08-24 owner - /// report's "highlights on hover, returns to the original color while - /// you hold". Zero ids fall back to the Normal sprites. - /// - public uint ThumbRolloverSprite { get; set; } - public uint ThumbPressedSprite { get; set; } - public uint ThumbTopRolloverSprite { get; set; } - public uint ThumbTopPressedSprite { get; set; } - public uint ThumbBotRolloverSprite { get; set; } - public uint ThumbBotPressedSprite { get; set; } - - /// Top/leading button sprite id. Retail seats the INCREMENT - /// designee here (vertical base skin: 0x10000072's UP-arrow art - /// 0x06004C6C) — see the class remarks and . + /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071). public uint UpSprite { get; set; } - /// Bottom/trailing button sprite id. Retail seats the DECREMENT - /// designee here (vertical base skin: 0x10000071's DOWN-arrow art 0x06004C69). + /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072). public uint DownSprite { get; set; } /// Rollover and pressed media for the start/decrement button. @@ -195,7 +155,6 @@ public sealed class UiScrollbar : UiElement private const float CapH = 3f; private bool _draggingThumb; - private bool _hoveredThumb; private float _dragOffsetY; private float _dragOffsetX; private EndButton _hoveredButton; @@ -223,21 +182,8 @@ public sealed class UiScrollbar : UiElement internal bool IsPresentationVisible => !HideWhenDisabled || !IsModelDisabled; - /// - /// A content-fits (disabled) bar is still hit-testable and still - /// hover-highlights — retail's arrows and thumb are real child elements - /// whose Normal_rollover hot-tracking keeps running when the scrollbar - /// disables; the disabled state only hides the page-click regions - /// (UpdateLayout @0x004710d0's children 4-7) and, with attribute 0x79, - /// the whole bar. Scrolling input stays inert through geometry: with a - /// full-track thumb there is no travel and the line/page steps clamp to - /// nothing. The previous IsModelDisabled hit gate made the bar - /// hit-TRANSPARENT, which is why hovering it "did nothing" (2026-08-24 - /// live probe: hovers over the bar reported widget=<none>). - /// Only a presentation-hidden bar (0x79 + disabled) ignores the pointer. - /// protected override bool OnHitTest(float localX, float localY) - => IsPresentationVisible && base.OnHitTest(localX, localY); + => !IsModelDisabled && base.OnHitTest(localX, localY); /// /// Computes the thumb rectangle (local y origin and height) within the track area @@ -299,7 +245,7 @@ public sealed class UiScrollbar : UiElement } float travel = MathF.Max(0f, Width - thumbWidth); float x = travel * ScalarPosition; - DrawSprite(ctx, resolve, ActiveThumbSprite, x, 0f, thumbWidth, Height); + DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height); return; } @@ -323,24 +269,20 @@ public sealed class UiScrollbar : UiElement DrawSprite(ctx, resolve, ActiveEndSprite, 0f, Height - incrementExtent, Width, incrementExtent); - // Thumb — drawn even with nothing to scroll: retail's proportion - // attribute 0x88 defaults to 1.0 (UpdateLayout @0x004710d0), so a - // content-fits scrollbar shows a thumb FILLING the whole track - // (ThumbRatio clamps to 1 → full trackLen); only the page-click - // regions and input go away with the disabled state. Retail 3-slice: - // top cap + tiled middle + bottom cap (base layout 0x2100003E thumb - // sub-elements 0x10000364/65/66), each with hover/pressed state - // media. Falls back to a single tiled middle if the caps are unset + // Thumb — only when content overflows the view. Retail 3-slice: top cap + + // tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements + // 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset // or the thumb is too short to hold both caps. + if (m.HasOverflow) { float trackTop = decrementExtent; float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent); var (ty, th) = ThumbRect(m, trackTop, trackLen); if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH) { - DrawSprite(ctx, resolve, ActiveThumbTopSprite, 0f, ty, Width, CapH); - DrawTiled(ctx, resolve, ActiveThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH); - DrawSprite(ctx, resolve, ActiveThumbBotSprite, 0f, ty + th - CapH, Width, CapH); + DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH); + DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH); + DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH); } else { @@ -354,29 +296,11 @@ public sealed class UiScrollbar : UiElement // (~9 repeats on Summary's overview bar, ~2 on Skills, per // the live capture). DrawThumbMarker draws exactly ONE // instance at its own native size. - DrawThumbMarker(ctx, resolve, ActiveThumbSprite, 0f, ty, Width, th, vertical: true); + DrawThumbMarker(ctx, resolve, ThumbSprite, 0f, ty, Width, th, vertical: true); } } } - /// - /// Picks a thumb slice's sprite for the current interaction state: - /// dragging → pressed media, hovered → rollover media, else normal — - /// mirroring retail's authored Normal/Normal_rollover/Normal_pressed - /// state machine on the thumb's slice children. Zero-id media fall - /// back to the normal sprite. - /// - private uint ActiveThumb(uint normal, uint rollover, uint pressed) - => _draggingThumb && pressed != 0u - ? pressed - : _hoveredThumb && !_draggingThumb && rollover != 0u - ? rollover - : normal; - - private uint ActiveThumbSprite => ActiveThumb(ThumbSprite, ThumbRolloverSprite, ThumbPressedSprite); - private uint ActiveThumbTopSprite => ActiveThumb(ThumbTopSprite, ThumbTopRolloverSprite, ThumbTopPressedSprite); - private uint ActiveThumbBotSprite => ActiveThumb(ThumbBotSprite, ThumbBotRolloverSprite, ThumbBotPressedSprite); - /// /// R4-2 (Campaign CC gate round 1 re-test 3): draws ONE instance of a /// single-sprite scrollbar thumb at its own native size, centered @@ -427,22 +351,21 @@ public sealed class UiScrollbar : UiElement DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, decrementExtent, Height); DrawSprite(ctx, resolve, ActiveEndSprite, Width - incrementExtent, 0f, incrementExtent, Height); + if (!model.HasOverflow) return; - // Content-fits bars draw a FULL-track thumb, same as the vertical - // path (retail proportion attribute 0x88 defaults to 1.0). float trackLeft = decrementExtent; float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent); var (tx, tw) = ThumbRect(model, trackLeft, trackLength); if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH) { - DrawSprite(ctx, resolve, ActiveThumbTopSprite, tx, 0f, CapH, Height); - DrawTiled(ctx, resolve, ActiveThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height); - DrawSprite(ctx, resolve, ActiveThumbBotSprite, tx + tw - CapH, 0f, CapH, Height); + DrawSprite(ctx, resolve, ThumbTopSprite, tx, 0f, CapH, Height); + DrawTiled(ctx, resolve, ThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height); + DrawSprite(ctx, resolve, ThumbBotSprite, tx + tw - CapH, 0f, CapH, Height); } else { // R4-2: horizontal counterpart of the vertical fallback above. - DrawThumbMarker(ctx, resolve, ActiveThumbSprite, tx, 0f, tw, Height, vertical: false); + DrawThumbMarker(ctx, resolve, ThumbSprite, tx, 0f, tw, Height, vertical: false); } } @@ -467,7 +390,7 @@ public sealed class UiScrollbar : UiElement float thumbHeight = ScalarThumbExtent(resolve, Height); float travel = MathF.Max(0f, Height - thumbHeight); float y = travel * ScalarPosition; - DrawSprite(ctx, resolve, ActiveThumbSprite, 0f, y, Width, thumbHeight); + DrawSprite(ctx, resolve, ThumbSprite, 0f, y, Width, thumbHeight); } /// Draw a sprite stretched 1:1 to the dest rect. @@ -548,14 +471,9 @@ public sealed class UiScrollbar : UiElement return false; // informational — never consumes } - // Only a presentation-HIDDEN bar ignores input (see OnHitTest's own - // doc): a visible disabled bar keeps hover/pressed visuals exactly - // like retail's still-hot-tracking button/thumb children, while its - // scroll operations no-op through zero travel. - if (!IsPresentationVisible) + if (IsModelDisabled) { _draggingThumb = false; - _hoveredThumb = false; _hoveredButton = EndButton.None; _pressedButton = EndButton.None; return false; @@ -564,20 +482,15 @@ public sealed class UiScrollbar : UiElement if (e.Type == UiEventType.HoverEnter) { _hoveredButton = ButtonAt(e.Data1, e.Data2); - _hoveredThumb = ThumbAt(e.Data1, e.Data2); return true; } if (e.Type == UiEventType.HoverLeave) { _hoveredButton = EndButton.None; - _hoveredThumb = false; return true; } if (e.Type == UiEventType.MouseMove) - { _hoveredButton = ButtonAt(e.Data1, e.Data2); - _hoveredThumb = ThumbAt(e.Data1, e.Data2); - } // Fix round F11: retail's chargen shade scrollbar (0x10000321) is // authored VERTICAL (measured against the installed dat), but a @@ -845,53 +758,6 @@ public sealed class UiScrollbar : UiElement ScalarChanged?.Invoke(ScalarPosition); } - /// - /// Whether the point sits on the thumb, for hover-state tracking — - /// covers all four modes (model/scalar × vertical/horizontal) using the - /// SAME geometry the matching draw and MouseDown paths compute. - /// - private bool ThumbAt(float x, float y) - { - if (x < 0f || x >= Width || y < 0f || y >= Height) - return false; - - if (ScalarChanged is not null) - { - if (Horizontal) - { - float thumbWidth = ScalarThumbWidth(SpriteResolve); - float thumbX = MathF.Max(0f, Width - thumbWidth) * ScalarPosition; - return x >= thumbX && x <= thumbX + thumbWidth; - } - float thumbHeight = ScalarThumbExtent(SpriteResolve, Height); - float thumbY = MathF.Max(0f, Height - thumbHeight) * ScalarPosition; - return y >= thumbY && y <= thumbY + thumbHeight; - } - - if (Model is not { } m) return false; - - if (Horizontal) - { - float trackLeft = AxisExtent(DecrementButtonExtent, Width); - float trackLength = MathF.Max( - 0f, - Width - - AxisExtent(DecrementButtonExtent, Width) - - AxisExtent(IncrementButtonExtent, Width)); - var (tx, tw) = ThumbRect(m, trackLeft, trackLength); - return x >= tx && x <= tx + tw; - } - - float trackTop = AxisExtent(DecrementButtonExtent, Height); - float trackLen = MathF.Max( - 0f, - Height - - AxisExtent(DecrementButtonExtent, Height) - - AxisExtent(IncrementButtonExtent, Height)); - var (ty, th) = ThumbRect(m, trackTop, trackLen); - return y >= ty && y <= ty + th; - } - private EndButton ButtonAt(float x, float y) { if (x < 0f || x >= Width || y < 0f || y >= Height) @@ -933,9 +799,6 @@ public sealed class UiScrollbar : UiElement internal uint ActiveStartSpriteForTest => ActiveStartSprite; internal uint ActiveEndSpriteForTest => ActiveEndSprite; - internal uint ActiveThumbSpriteForTest => ActiveThumbSprite; - internal uint ActiveThumbTopSpriteForTest => ActiveThumbTopSprite; - internal uint ActiveThumbBotSpriteForTest => ActiveThumbBotSprite; private static float AxisExtent(float authoredExtent, float axisLength) => Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength)); diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 71fc7202..0c8ce38c 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -584,17 +584,17 @@ public sealed class UiText : UiElement, IUiDatStateful // visible surface as arg3 and clips each glyph blit to that rectangle. This is // observable in LayoutDesc 0x21000033: the owned component count is a 15px-high // text element using a 16px DAT font, so rejecting a partially visible line makes - // the value disappear entirely. - // - // CT-GF1 fix round (S2): this used to push its OWN (0,0,Width,Height) clip - // here. That is now REDUNDANT and deleted — UiElement.DrawSelfAndChildren - // wraps OnDraw/OnDrawAfterChildren (where DrawText is called from) in exactly - // that same (0,0,Width,Height) ambient clip by default, intersected with any - // list/window ancestor clip, matching retail's DrawHere shape one level up - // instead of duplicating it here. DrawClippedText still relies on SOME clip - // being active for correctness (a partially visible line must still be - // rejected, not drawn full-size) — that clip is now the ambient one. - DrawClippedText(ctx); + // the value disappear entirely. The shared render context clips both DAT and + // bitmap glyph quads and composes this bound with any list/window ancestor clip. + ctx.PushClip(0f, 0f, Width, Height); + try + { + DrawClippedText(ctx); + } + finally + { + ctx.PopClip(); + } } private void DrawClippedText(UiRenderContext ctx) diff --git a/src/AcDream.App/World/LiveEntityDeletionController.cs b/src/AcDream.App/World/LiveEntityDeletionController.cs index 95021483..bebeecfd 100644 --- a/src/AcDream.App/World/LiveEntityDeletionController.cs +++ b/src/AcDream.App/World/LiveEntityDeletionController.cs @@ -21,13 +21,15 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink private readonly ILiveEntityTeardownCoordinator _teardown; private readonly ILocalPlayerIdentitySource _identity; private readonly DormantLiveEntityStore _dormant; + private readonly Action? _diagnostic; public LiveEntityDeletionController( LiveEntityRuntime runtime, RuntimeEntityObjectLifetime entityObjects, ILiveEntityTeardownCoordinator teardown, ILocalPlayerIdentitySource identity, - DormantLiveEntityStore? dormant = null) + DormantLiveEntityStore? dormant = null, + Action? diagnostic = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _entityObjects = entityObjects @@ -35,6 +37,7 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink _teardown = teardown ?? throw new ArgumentNullException(nameof(teardown)); _identity = identity ?? throw new ArgumentNullException(nameof(identity)); _dormant = dormant ?? new DormantLiveEntityStore(); + _diagnostic = diagnostic; } /// @@ -61,25 +64,17 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink if (removed) { _dormant.RemoveExact(delete); + _diagnostic?.Invoke( + $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); + } + else if (removedDormant) + { + _diagnostic?.Invoke( + $"live: delete dormant guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); } return removed || removedDormant; } - /// - /// VTank ghost cleanup: synthesize the exact current incarnation delete, - /// then use the same complete teardown transaction as a server DeleteObject. - /// - public bool DeleteClientGhost(uint serverGuid) - { - if (serverGuid == 0u - || serverGuid == _identity.ServerGuid - || !_runtime.TryGetRecord(serverGuid, out LiveEntityRecord record)) - { - return false; - } - return Delete(new DeleteObject.Parsed(serverGuid, record.Generation)); - } - public bool Prune(LiveEntityPruneCandidate candidate) { if (!_runtime.TryGetRecord( @@ -100,6 +95,8 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink return false; _dormant.Retain(snapshot); + _diagnostic?.Invoke( + $"live: dormant guid=0x{candidate.ServerGuid:X8} instSeq={candidate.Generation}"); return true; } } diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index a21404a0..c23c6e45 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -180,6 +180,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded private readonly ILocalPlayerIdentitySource _identity; private readonly LiveEntityDeletionController _deletion; private readonly DormantLiveEntityStore _dormant; + private readonly Action? _diagnostic; /// /// C3c: the graphical first-entry drive pump — pumped at the end of each /// Create transaction so a fresh residence drives its conductor @@ -218,6 +219,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded ILocalPlayerIdentitySource identity, LiveEntityDeletionController deletion, DormantLiveEntityStore? dormant = null, + Action? diagnostic = null, RuntimeFirstEntryDriveController? firstEntry = null, RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) { @@ -234,6 +236,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded _identity = identity ?? throw new ArgumentNullException(nameof(identity)); _deletion = deletion ?? throw new ArgumentNullException(nameof(deletion)); _dormant = dormant ?? new DormantLiveEntityStore(); + _diagnostic = diagnostic; _firstEntry = firstEntry; _acceptedPositionDrive = acceptedPositionDrive; } @@ -462,6 +465,11 @@ AppearanceSynchronization: ChildUnparentDisposition disposition = _relationships.OnChildBecameUnparented(update.Guid); bool accepted = disposition is not ChildUnparentDisposition.Superseded; + if (accepted) + { + _diagnostic?.Invoke( + $"live: pickup guid=0x{update.Guid:X8} instSeq={update.InstanceSequence} posSeq={update.PositionSequence}"); + } return accepted; } @@ -593,6 +601,7 @@ AppearanceSynchronization: if (candidates.Count == 0) return; + int projected = 0; lock (_datLock) { foreach (RuntimeEntityRecord candidate in candidates) @@ -614,21 +623,26 @@ AppearanceSynchronization: candidate.Snapshot, LiveProjectionPurpose.CreateSupersessionRecovery)) { + projected++; projectedCanonicalAppearance = true; } } else if (record?.WorldEntity is not null && record.InitialHydrationCompleted) { - _runtime.RebucketLiveEntity( - record.ServerGuid, - record.ProjectionCellId); + if (_runtime.RebucketLiveEntity( + record.ServerGuid, + record.ProjectionCellId)) + { + projected++; + } } else if (ProjectExact( candidate, candidate.Snapshot, LiveProjectionPurpose.SpatialRecovery)) { + projected++; projectedCanonicalAppearance = true; } @@ -644,14 +658,21 @@ AppearanceSynchronization: if (record is not null && _runtime.IsCurrentRecord(record) - && record.AppearanceProjectionSynchronizationPending) - { - SynchronizeAppearance( + && record.AppearanceProjectionSynchronizationPending + && SynchronizeAppearance( record, - record.ObjDescAuthorityVersion); + record.ObjDescAuthorityVersion)) + { + projected++; } } } + + if (projected > 0) + { + _diagnostic?.Invoke( + $"live: re-projected {projected} server object(s) into landblock 0x{loadedLandblockId:X8}"); + } } public bool EnsureWorldOrigin( diff --git a/src/AcDream.Bake/BakeRunner.cs b/src/AcDream.Bake/BakeRunner.cs index 557c8d13..6b60a2ea 100644 --- a/src/AcDream.Bake/BakeRunner.cs +++ b/src/AcDream.Bake/BakeRunner.cs @@ -1,7 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Numerics; -using System.Runtime; using AcDream.Content; using AcDream.Content.Pak; using AcDream.Core.Physics; @@ -45,7 +44,6 @@ public sealed record BakeReport public required int UniqueCellStructureCollisions { get; init; } public required int CellStructureCollisionAliases { get; init; } public required int EnvCellTopologyKeys { get; init; } - public required int TexturePayloadKeys { get; init; } public required int SideStagedKeys { get; init; } public int SideStagedDuplicateKeys { get; init; } public required int PhysicalBlobs { get; init; } @@ -56,9 +54,6 @@ public sealed record BakeReport public required long OutputBytes { get; init; } public required long PeakWorkingSetBytes { get; init; } public required long PeakPrivateBytes { get; init; } - public required long DecodedPayloadBytes { get; init; } - public required long StoredPayloadBytes { get; init; } - public required int CompressedBlobs { get; init; } public required IReadOnlyDictionary TypeCounts { get; @@ -67,9 +62,6 @@ public sealed record BakeReport public double EnvCellDedupRatio => UniqueEnvCellGeometries == 0 ? 0 : (double)EnvCellKeys / UniqueEnvCellGeometries; - - public double PayloadCompressionRatio => - StoredPayloadBytes == 0 ? 0 : (double)DecodedPayloadBytes / StoredPayloadBytes; } /// @@ -84,7 +76,6 @@ public static class BakeRunner /// Bounds peak decoded output while keeping workers busy. /// private const int BatchSize = 16; - private const int TransientCollectionStride = 2_048; public static int Run(BakeOptions options) { @@ -192,9 +183,6 @@ public static class BakeRunner ordinaryWork.AddRange( setupIds.Select( id => (PakKey.Compose(PakAssetType.SetupMesh, id), PakAssetType.SetupMesh, id))); - var scheduledMeshKeys = ordinaryWork - .Select(item => item.Key) - .ToHashSet(); ordinaryWork.Sort((a, b) => a.Key.CompareTo(b.Key)); Console.WriteLine( @@ -211,7 +199,6 @@ public static class BakeRunner var header = new PakHeader { - FormatVersion = PakFormat.CurrentFormatVersion, PortalIteration = (uint)dats.Portal.Iteration!.CurrentIteration, CellIteration = (uint)dats.Cell.Iteration!.CurrentIteration, HighResIteration = (uint)dats.HighRes.Iteration!.CurrentIteration, @@ -285,11 +272,7 @@ public static class BakeRunner sideStagedDuped += DrainSideStaged( sideStaged, sideStagedByKey, - writtenKeys, - scheduledMeshKeys); - CompactTransientBakeBuffersIfDue( - batchStart + batch.Length, - final: batchStart + BatchSize >= ordinaryWork.Count); + writtenKeys); ReportProgressIfDue( completed, totalExtractionJobs, @@ -390,11 +373,7 @@ public static class BakeRunner sideStagedDuped += DrainSideStaged( sideStaged, sideStagedByKey, - writtenKeys, - scheduledMeshKeys); - CompactTransientBakeBuffersIfDue( - batchStart + batch.Length, - final: batchStart + BatchSize >= envCatalog.Groups.Count); + writtenKeys); ReportProgressIfDue( completed, totalExtractionJobs, @@ -822,7 +801,6 @@ public static class BakeRunner [PakAssetType.CellStructureCollision] = cellStructureWritten, [PakAssetType.EnvCellTopology] = envCellTopologyWritten, - [PakAssetType.TexturePayload] = writer.TextureBlobCount, }; var report = new BakeReport { @@ -843,19 +821,23 @@ public static class BakeRunner CellStructureCollisionAliases = cellStructureAliasCount, EnvCellTopologyKeys = envCellTopologyWritten, - TexturePayloadKeys = writer.TextureBlobCount, SideStagedKeys = sideStagedWritten, - PhysicalBlobs = writer.PhysicalBlobCount, - TotalKeys = writer.EntryCount, + PhysicalBlobs = + gfxObjWritten + + setupWritten + + envCellGeometriesWritten + + sideStagedWritten + + uniqueGfxCollisions + + uniqueSetupCollisions + + uniqueCellStructures + + envCellTopologyWritten, + TotalKeys = writtenKeys.Count, Failures = failures.Count, ExtractionAndWriteElapsed = stopwatch.Elapsed, Elapsed = stopwatch.Elapsed, OutputBytes = new FileInfo(options.OutPath).Length, PeakWorkingSetBytes = Process.GetCurrentProcess().PeakWorkingSet64, PeakPrivateBytes = Process.GetCurrentProcess().PeakPagedMemorySize64, - DecodedPayloadBytes = writer.DecodedPayloadBytes, - StoredPayloadBytes = writer.StoredPayloadBytes, - CompressedBlobs = writer.CompressedBlobCount, TypeCounts = typeCounts, }; @@ -925,60 +907,19 @@ public static class BakeRunner private static int DrainSideStaged( ConcurrentQueue sideStaged, Dictionary sideStagedByKey, - HashSet writtenKeys, - HashSet scheduledMeshKeys) + HashSet writtenKeys) { int duplicates = 0; while (sideStaged.TryDequeue(out var staged)) { uint fileId = (uint)(staged.ObjectId & 0xFFFF_FFFFu); ulong key = PakKey.Compose(PakAssetType.GfxObjMesh, fileId); - // A full bake already schedules every GfxObj. Retaining an early - // particle-preload copy until that later row was written pinned - // thousands of complete meshes and several GiB of texture arrays. - // Only filtered bakes need to retain a side-staged key that is not - // otherwise in their work list. - if (writtenKeys.Contains(key) - || scheduledMeshKeys.Contains(key) - || !sideStagedByKey.TryAdd(key, staged)) + if (writtenKeys.Contains(key) || !sideStagedByKey.TryAdd(key, staged)) duplicates++; } return duplicates; } - /// - /// Mesh decode and compression use large temporary pixel/serialization - /// arrays. A short offline bake otherwise finishes before workstation GC - /// decides to compact the LOH, leaving several GiB committed despite the - /// live set being bounded. Batch boundaries have no active workers and - /// are the deterministic safe point to reclaim those transients. The - /// stride keeps the CPU cost small while bounding peak memory. - /// - private static void CompactTransientBakeBuffersIfDue( - int completedItems, - bool final) - { - if (!final && completedItems % TransientCollectionStride != 0) - return; - - GCLargeObjectHeapCompactionMode previous = - GCSettings.LargeObjectHeapCompactionMode; - try - { - GCSettings.LargeObjectHeapCompactionMode = - GCLargeObjectHeapCompactionMode.CompactOnce; - GC.Collect( - GC.MaxGeneration, - GCCollectionMode.Forced, - blocking: true, - compacting: true); - } - finally - { - GCSettings.LargeObjectHeapCompactionMode = previous; - } - } - private static void ReportProgressIfDue( long done, int total, @@ -1036,18 +977,11 @@ public static class BakeRunner $"{report.CellStructureCollisionAliases:N0} aliases"); Console.WriteLine( $" Cell topologies: {report.EnvCellTopologyKeys:N0}"); - Console.WriteLine( - $" texture payloads: {report.TexturePayloadKeys:N0}"); Console.WriteLine( $" side-staged keys: {report.SideStagedKeys:N0} " + $"({report.SideStagedDuplicateKeys:N0} duplicate keys)"); Console.WriteLine($" physical blobs: {report.PhysicalBlobs:N0}"); Console.WriteLine($" total keys: {report.TotalKeys:N0}"); - Console.WriteLine( - $" compressed blobs: {report.CompressedBlobs:N0}; " + - $"payload {report.DecodedPayloadBytes / 1024.0 / 1024.0:F1} -> " + - $"{report.StoredPayloadBytes / 1024.0 / 1024.0:F1} MB " + - $"({report.PayloadCompressionRatio:F2}x)"); Console.WriteLine($" failures: {report.Failures:N0}"); Console.WriteLine( $" extract + write: {report.ExtractionAndWriteElapsed.TotalSeconds:F1} s"); diff --git a/src/AcDream.Content/IPreparedAssetSource.cs b/src/AcDream.Content/IPreparedAssetSource.cs index 468e9857..f9f58659 100644 --- a/src/AcDream.Content/IPreparedAssetSource.cs +++ b/src/AcDream.Content/IPreparedAssetSource.cs @@ -93,24 +93,14 @@ public readonly record struct PreparedAssetCatalogIdentity( uint BakeToolVersion) { public static PreparedAssetCatalogIdentity From(IDatReaderWriter dats) - => From(dats, PakFormat.CurrentBakeToolVersion); - - public static PreparedAssetCatalogIdentity From( - IDatReaderWriter dats, - uint bakeToolVersion) { ArgumentNullException.ThrowIfNull(dats); - if (bakeToolVersion == 0) - { - throw new ArgumentOutOfRangeException(nameof(bakeToolVersion)); - } - return new( checked((uint)dats.PortalIteration), checked((uint)dats.CellIteration), checked((uint)dats.HighResIteration), checked((uint)dats.LanguageIteration), - bakeToolVersion); + PakFormat.CurrentBakeToolVersion); } } diff --git a/src/AcDream.Content/LayeredPreparedAssetSource.cs b/src/AcDream.Content/LayeredPreparedAssetSource.cs deleted file mode 100644 index 1192c76a..00000000 --- a/src/AcDream.Content/LayeredPreparedAssetSource.cs +++ /dev/null @@ -1,239 +0,0 @@ -using AcDream.Content.Pak; -using AcDream.Core.Physics; - -namespace AcDream.Content; - -/// -/// One cumulative overlay in front of one complete base package. Missing keys -/// fall through; an overlay key that exists but is corrupt is authoritative and -/// never hides its corruption behind older base bytes. Render and collision -/// payloads use the exact same rule and the two package owners are disposed as -/// one content set. -/// -public sealed class LayeredPreparedAssetSource : - IPreparedAssetSource, - IPreparedCollisionSource -{ - private IPreparedAssetSource? _baseAssets; - private IPreparedAssetSource? _overlayAssets; - private IPreparedCollisionSource? _baseCollision; - private IPreparedCollisionSource? _overlayCollision; - - public LayeredPreparedAssetSource( - IPreparedAssetSource baseSource, - IPreparedAssetSource overlaySource) - { - ArgumentNullException.ThrowIfNull(baseSource); - ArgumentNullException.ThrowIfNull(overlaySource); - if (ReferenceEquals(baseSource, overlaySource)) - { - throw new ArgumentException( - "The base and overlay must have independent owners.", - nameof(overlaySource)); - } - - _baseCollision = baseSource as IPreparedCollisionSource - ?? throw new ArgumentException( - "The base source must expose prepared collision payloads.", - nameof(baseSource)); - _overlayCollision = overlaySource as IPreparedCollisionSource - ?? throw new ArgumentException( - "The overlay source must expose prepared collision payloads.", - nameof(overlaySource)); - _baseAssets = baseSource; - _overlayAssets = overlaySource; - } - - public PreparedAssetSourceStats Stats - { - get - { - IPreparedAssetSource baseSource = Require(_baseAssets); - IPreparedAssetSource overlay = Require(_overlayAssets); - PreparedAssetSourceStats left = baseSource.Stats; - PreparedAssetSourceStats right = overlay.Stats; - return new( - left.Probes + right.Probes, - left.Reads + right.Reads, - left.Loaded + right.Loaded, - left.Missing + right.Missing, - left.Corrupt + right.Corrupt); - } - } - - public PreparedCollisionSourceStats CollisionStats - { - get - { - IPreparedCollisionSource baseSource = Require(_baseCollision); - IPreparedCollisionSource overlay = Require(_overlayCollision); - PreparedCollisionSourceStats left = baseSource.CollisionStats; - PreparedCollisionSourceStats right = overlay.CollisionStats; - return new( - left.Probes + right.Probes, - left.Reads + right.Reads, - left.Loaded + right.Loaded, - left.Missing + right.Missing, - left.Corrupt + right.Corrupt); - } - } - - public CacheStats DecodedTextureCacheStats - { - get - { - CacheStats left = Require(_baseAssets).DecodedTextureCacheStats; - CacheStats right = Require(_overlayAssets).DecodedTextureCacheStats; - return new( - left.Hits + right.Hits, - left.Misses + right.Misses, - left.Evictions + right.Evictions); - } - } - - public long MappedVirtualBytes => - checked( - Require(_baseAssets).MappedVirtualBytes - + Require(_overlayAssets).MappedVirtualBytes); - - public PreparedAssetPresence Probe(PakAssetType type, uint sourceFileId) - { - PreparedAssetPresence overlay = - Require(_overlayAssets).Probe(type, sourceFileId); - return overlay == PreparedAssetPresence.Missing - ? Require(_baseAssets).Probe(type, sourceFileId) - : overlay; - } - - public PreparedAssetReadResult Read( - in PreparedAssetRequest request, - CancellationToken cancellationToken = default) - { - PreparedAssetReadResult overlay = - Require(_overlayAssets).Read(request, cancellationToken); - return overlay.Status == PreparedAssetReadStatus.Missing - ? Require(_baseAssets).Read(request, cancellationToken) - : overlay; - } - - public PreparedAssetPresence ProbeCollision( - PakAssetType type, - uint sourceFileId) - { - PreparedAssetPresence overlay = - Require(_overlayCollision).ProbeCollision(type, sourceFileId); - return overlay == PreparedAssetPresence.Missing - ? Require(_baseCollision).ProbeCollision(type, sourceFileId) - : overlay; - } - - public PreparedCollisionReadResult - ReadGfxObjCollision( - uint sourceFileId, - CancellationToken cancellationToken = default) - { - PreparedCollisionReadResult overlay = - Require(_overlayCollision).ReadGfxObjCollision( - sourceFileId, - cancellationToken); - return overlay.Status == PreparedAssetReadStatus.Missing - ? Require(_baseCollision).ReadGfxObjCollision( - sourceFileId, - cancellationToken) - : overlay; - } - - public PreparedCollisionReadResult - ReadSetupCollision( - uint sourceFileId, - CancellationToken cancellationToken = default) - { - PreparedCollisionReadResult overlay = - Require(_overlayCollision).ReadSetupCollision( - sourceFileId, - cancellationToken); - return overlay.Status == PreparedAssetReadStatus.Missing - ? Require(_baseCollision).ReadSetupCollision( - sourceFileId, - cancellationToken) - : overlay; - } - - public PreparedCollisionReadResult - ReadCellStructureCollision( - uint sourceFileId, - CancellationToken cancellationToken = default) - { - PreparedCollisionReadResult overlay = - Require(_overlayCollision).ReadCellStructureCollision( - sourceFileId, - cancellationToken); - return overlay.Status == PreparedAssetReadStatus.Missing - ? Require(_baseCollision).ReadCellStructureCollision( - sourceFileId, - cancellationToken) - : overlay; - } - - public PreparedCollisionReadResult - ReadEnvCellTopology( - uint sourceFileId, - CancellationToken cancellationToken = default) - { - PreparedCollisionReadResult overlay = - Require(_overlayCollision).ReadEnvCellTopology( - sourceFileId, - cancellationToken); - return overlay.Status == PreparedAssetReadStatus.Missing - ? Require(_baseCollision).ReadEnvCellTopology( - sourceFileId, - cancellationToken) - : overlay; - } - - public void Dispose() - { - IPreparedAssetSource? overlay = Interlocked.Exchange( - ref _overlayAssets, - null); - IPreparedAssetSource? baseSource = Interlocked.Exchange( - ref _baseAssets, - null); - _overlayCollision = null; - _baseCollision = null; - - List? failures = null; - DisposeOne(overlay, ref failures); - DisposeOne(baseSource, ref failures); - if (failures is { Count: > 0 }) - { - throw new AggregateException( - "One or more prepared-content layers failed to dispose.", - failures); - } - } - - private static T Require(T? value) - where T : class => - value ?? throw new ObjectDisposedException( - nameof(LayeredPreparedAssetSource)); - - private static void DisposeOne( - IDisposable? value, - ref List? failures) - { - if (value is null) - { - return; - } - - try - { - value.Dispose(); - } - catch (Exception exception) - { - (failures ??= []).Add(exception); - } - } -} diff --git a/src/AcDream.Content/MagicCatalog.cs b/src/AcDream.Content/MagicCatalog.cs index 0992ea9d..f6a8f52a 100644 --- a/src/AcDream.Content/MagicCatalog.cs +++ b/src/AcDream.Content/MagicCatalog.cs @@ -15,15 +15,7 @@ public sealed record SpellComponentDescriptor( uint WeenieClassId, string Name, uint Category, - uint IconId) -{ - public uint SpellComponentId { get; init; } - public double BurnRate { get; init; } - public uint GestureId { get; init; } - public double GestureSpeed { get; init; } - public string Type { get; init; } = string.Empty; - public string Word { get; init; } = string.Empty; -} + uint IconId); /// /// Process-shareable projection of retail's spell, component, and @@ -150,15 +142,7 @@ public sealed class MagicCatalog wcid, pair.Value.Name.Value, pair.Value.Category, - pair.Value.Icon.DataId) - { - SpellComponentId = pair.Key, - BurnRate = pair.Value.CDM, - GestureId = pair.Value.Gesture, - GestureSpeed = pair.Value.Time, - Type = pair.Value.Type.ToString(), - Word = pair.Value.Text.Value, - }; + pair.Value.Icon.DataId); } } diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs index 0791423d..f3cba889 100644 --- a/src/AcDream.Content/MeshExtractor.cs +++ b/src/AcDream.Content/MeshExtractor.cs @@ -433,18 +433,7 @@ public sealed class MeshExtractor { isClipMap, surface.Type.HasFlag(SurfaceType.Additive)); - if (CanPreserveCompressedTexture( - renderSurface.Format, - isClipMap, - surface.Translucency)) { - // Vulkan requires textureCompressionBC, so an unedited - // DAT DXT surface can stay byte-exact all the way to the - // GPU. Decoding it here inflated pak, CPU, and GPU - // residency by up to eight times. - textureFormat = ToTextureFormat(renderSurface.Format); - textureData = renderSurface.SourceData; - } - else if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { + if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; textureFormat = TextureFormat.RGBA8; uploadPixelFormat = UploadPixelFormat.Rgba; @@ -833,14 +822,7 @@ public sealed class MeshExtractor { isClipMap, surface.Type.HasFlag(SurfaceType.Additive)); - if (CanPreserveCompressedTexture( - renderSurface.Format, - isClipMap, - surface.Translucency)) { - textureData = renderSurface.SourceData; - textureFormat = ToTextureFormat(renderSurface.Format); - } - else if (_decodedTextureCache.TryGet(decodedTextureKey, out var cachedData)) { + if (_decodedTextureCache.TryGet(decodedTextureKey, out var cachedData)) { textureData = cachedData; textureFormat = TextureFormat.RGBA8; uploadPixelFormat = UploadPixelFormat.Rgba; @@ -1144,26 +1126,6 @@ public sealed class MeshExtractor { return textureData; } - private static bool CanPreserveCompressedTexture( - DatReaderWriter.Enums.PixelFormat format, - bool isClipMap, - float translucency) => - TextureHelpers.IsCompressedFormat(format) - && !isClipMap - && translucency <= 0.0f; - - private static TextureFormat ToTextureFormat( - DatReaderWriter.Enums.PixelFormat format) => format switch - { - DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => TextureFormat.DXT1, - DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => TextureFormat.DXT3, - DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => TextureFormat.DXT5, - _ => throw new ArgumentOutOfRangeException( - nameof(format), - format, - "The source is not a supported block-compressed texture."), - }; - private void BuildPolygonIndices(Polygon poly, GfxObj gfxObj, Vector3 scale, Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup, List vertices, List indices, bool useNegSurface, ref bool hasWrappingUVs) { diff --git a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs index 277b0a70..0fe0732d 100644 --- a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs +++ b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs @@ -36,63 +36,34 @@ namespace AcDream.Content.Pak; public static class ObjectMeshDataSerializer { public static void Write(ObjectMeshData data, Stream stream) { using var bw = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true); - WriteObjectMeshData(bw, data, externalTextures: null); - } - - public static void WriteExternalTextures( - ObjectMeshData data, - Stream stream, - Func registerTexture) { - ArgumentNullException.ThrowIfNull(registerTexture); - using var bw = new BinaryWriter( - stream, - System.Text.Encoding.UTF8, - leaveOpen: true); - WriteObjectMeshData(bw, data, registerTexture); + WriteObjectMeshData(bw, data); } /// Deserializes directly over — no defensive copy (PakReader's single-pass read reuses its CRC buffer here). public static ObjectMeshData Read(byte[] bytes) { using var ms = new MemoryStream(bytes, writable: false); using var br = new BinaryReader(ms, System.Text.Encoding.UTF8, leaveOpen: true); - return ReadObjectMeshData(br, externalTextures: null); + return ReadObjectMeshData(br); } public static ObjectMeshData Read(ReadOnlySpan bytes) => Read(bytes.ToArray()); - public static ObjectMeshData ReadExternalTextures( - byte[] bytes, - Func resolveTexture) { - ArgumentNullException.ThrowIfNull(resolveTexture); - using var ms = new MemoryStream(bytes, writable: false); - using var br = new BinaryReader( - ms, - System.Text.Encoding.UTF8, - leaveOpen: true); - return ReadObjectMeshData(br, resolveTexture); - } - // ---- ObjectMeshData ----------------------------------------------------- - private static void WriteObjectMeshData( - BinaryWriter w, - ObjectMeshData data, - Func? externalTextures) { + private static void WriteObjectMeshData(BinaryWriter w, ObjectMeshData data) { w.Write(data.ObjectId); w.Write(data.IsSetup); WriteVertexArray(w, data.Vertices); w.Write(data.Batches.Count); - foreach (var batch in data.Batches) - WriteMeshBatchData(w, batch, externalTextures); + foreach (var batch in data.Batches) WriteMeshBatchData(w, batch); w.Write(data.UploadAttempts); // EnvCellGeometry: recursive nested block. w.Write(data.EnvCellGeometry is not null); - if (data.EnvCellGeometry is not null) - WriteObjectMeshData(w, data.EnvCellGeometry, externalTextures); + if (data.EnvCellGeometry is not null) WriteObjectMeshData(w, data.EnvCellGeometry); w.Write(data.SetupParts.Count); foreach (var (gfxObjId, transform) in data.SetupParts) { @@ -103,7 +74,7 @@ public static class ObjectMeshDataSerializer { w.Write(data.ParticleEmitters.Count); foreach (var emitter in data.ParticleEmitters) WriteStagedEmitter(w, emitter); - WriteTextureBatches(w, data.TextureBatches, externalTextures); + WriteTextureBatches(w, data.TextureBatches); WriteBoundingBox(w, data.BoundingBox); WriteVector3(w, data.SortCenter); @@ -115,9 +86,7 @@ public static class ObjectMeshDataSerializer { WriteVector3Array(w, data.EdgeLines); } - private static ObjectMeshData ReadObjectMeshData( - BinaryReader r, - Func? externalTextures) { + private static ObjectMeshData ReadObjectMeshData(BinaryReader r) { var data = new ObjectMeshData { ObjectId = r.ReadUInt64(), IsSetup = r.ReadBoolean(), @@ -127,16 +96,13 @@ public static class ObjectMeshDataSerializer { int batchCount = r.ReadInt32(); var batches = new List(batchCount); - for (int i = 0; i < batchCount; i++) - batches.Add(ReadMeshBatchData(r, externalTextures)); + for (int i = 0; i < batchCount; i++) batches.Add(ReadMeshBatchData(r)); data.Batches = batches; data.UploadAttempts = r.ReadInt32(); bool hasEnvCellGeometry = r.ReadBoolean(); - data.EnvCellGeometry = hasEnvCellGeometry - ? ReadObjectMeshData(r, externalTextures) - : null; + data.EnvCellGeometry = hasEnvCellGeometry ? ReadObjectMeshData(r) : null; int setupPartCount = r.ReadInt32(); var setupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(setupPartCount); @@ -152,7 +118,7 @@ public static class ObjectMeshDataSerializer { for (int i = 0; i < emitterCount; i++) emitters.Add(ReadStagedEmitter(r)); data.ParticleEmitters = emitters; - data.TextureBatches = ReadTextureBatches(r, externalTextures); + data.TextureBatches = ReadTextureBatches(r); data.BoundingBox = ReadBoundingBox(r); data.SortCenter = ReadVector3(r); @@ -168,37 +134,24 @@ public static class ObjectMeshDataSerializer { // ---- MeshBatchData ------------------------------------------------------- - private static void WriteMeshBatchData( - BinaryWriter w, - MeshBatchData batch, - Func? externalTextures) { + private static void WriteMeshBatchData(BinaryWriter w, MeshBatchData batch) { WriteUInt16Array(w, batch.Indices); WriteTextureFormatTuple(w, batch.TextureFormat); WriteTextureKey(w, batch.TextureKey); w.Write(batch.TextureIndex); - WriteTexturePayload( - w, - batch.TextureKey, - batch.TextureData, - externalTextures); + WriteByteArray(w, batch.TextureData); WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0); WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0); w.Write((int)batch.CullMode); } - private static MeshBatchData ReadMeshBatchData( - BinaryReader r, - Func? externalTextures) { - ushort[] indices = ReadUInt16Array(r); - (int Width, int Height, TextureFormat Format) format = - ReadTextureFormatTuple(r); - TextureKey key = ReadTextureKey(r); + private static MeshBatchData ReadMeshBatchData(BinaryReader r) { var batch = new MeshBatchData { - Indices = indices, - TextureFormat = format, - TextureKey = key, + Indices = ReadUInt16Array(r), + TextureFormat = ReadTextureFormatTuple(r), + TextureKey = ReadTextureKey(r), TextureIndex = r.ReadInt32(), - TextureData = ReadTexturePayload(r, externalTextures), + TextureData = ReadByteArray(r), }; batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v); batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v); @@ -208,12 +161,9 @@ public static class ObjectMeshDataSerializer { // ---- TextureBatchData / TextureBatches dictionary ------------------------- - private static void WriteTextureBatchData( - BinaryWriter w, - TextureBatchData batch, - Func? externalTextures) { + private static void WriteTextureBatchData(BinaryWriter w, TextureBatchData batch) { WriteTextureKey(w, batch.Key); - WriteTexturePayload(w, batch.Key, batch.TextureData, externalTextures); + WriteByteArray(w, batch.TextureData); WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0); WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0); WriteUInt16List(w, batch.Indices); @@ -224,13 +174,10 @@ public static class ObjectMeshDataSerializer { w.Write(batch.HasWrappingUVs); } - private static TextureBatchData ReadTextureBatchData( - BinaryReader r, - Func? externalTextures) { - TextureKey key = ReadTextureKey(r); + private static TextureBatchData ReadTextureBatchData(BinaryReader r) { var batch = new TextureBatchData { - Key = key, - TextureData = ReadTexturePayload(r, externalTextures), + Key = ReadTextureKey(r), + TextureData = ReadByteArray(r), }; batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v); batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v); @@ -251,8 +198,7 @@ public static class ObjectMeshDataSerializer { /// private static void WriteTextureBatches( BinaryWriter w, - Dictionary<(int Width, int Height, TextureFormat Format), List> batches, - Func? externalTextures) { + Dictionary<(int Width, int Height, TextureFormat Format), List> batches) { var sortedKeys = batches.Keys .OrderBy(k => k.Width) .ThenBy(k => k.Height) @@ -267,14 +213,11 @@ public static class ObjectMeshDataSerializer { var list = batches[key]; w.Write(list.Count); - foreach (var item in list) - WriteTextureBatchData(w, item, externalTextures); + foreach (var item in list) WriteTextureBatchData(w, item); } } - private static Dictionary<(int Width, int Height, TextureFormat Format), List> ReadTextureBatches( - BinaryReader r, - Func? externalTextures) { + private static Dictionary<(int Width, int Height, TextureFormat Format), List> ReadTextureBatches(BinaryReader r) { int groupCount = r.ReadInt32(); var result = new Dictionary<(int Width, int Height, TextureFormat Format), List>(groupCount); for (int i = 0; i < groupCount; i++) { @@ -284,32 +227,13 @@ public static class ObjectMeshDataSerializer { int listCount = r.ReadInt32(); var list = new List(listCount); - for (int j = 0; j < listCount; j++) - list.Add(ReadTextureBatchData(r, externalTextures)); + for (int j = 0; j < listCount; j++) list.Add(ReadTextureBatchData(r)); result[(width, height, format)] = list; } return result; } - private static void WriteTexturePayload( - BinaryWriter writer, - TextureKey key, - byte[] bytes, - Func? externalTextures) { - if (externalTextures is null) - WriteByteArray(writer, bytes); - else - writer.Write(externalTextures(key, bytes)); - } - - private static byte[] ReadTexturePayload( - BinaryReader reader, - Func? externalTextures) => - externalTextures is null - ? ReadByteArray(reader) - : externalTextures(reader.ReadUInt64()); - // ---- StagedEmitter / ParticleEmitter (DBObj) ------------------------------ private static void WriteStagedEmitter(BinaryWriter w, StagedEmitter emitter) { diff --git a/src/AcDream.Content/Pak/PakBlobCodec.cs b/src/AcDream.Content/Pak/PakBlobCodec.cs deleted file mode 100644 index ee420924..00000000 --- a/src/AcDream.Content/Pak/PakBlobCodec.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Buffers; -using System.Buffers.Binary; -using System.IO.Compression; - -namespace AcDream.Content.Pak; - -/// -/// Format-2 independent-blob compression. Each payload remains separately -/// addressable; incompressible and small payloads stay raw so a size win can -/// never turn into needless runtime CPU work. -/// -internal static class PakBlobCodec -{ - // The largest format-1 payload in the complete installed package is - // 19,683,674 bytes. Texture externalization makes format-2 mesh blobs - // smaller still. A fixed 64 MiB ceiling prevents a corrupt package from - // declaring a multi-gigabyte allocation before Brotli can reject it. - internal const int MaximumDecodedBytes = 64 * 1024 * 1024; - private const int DecodedLengthPrefixSize = sizeof(uint); - private const int MinimumCompressionBytes = 512; - private const int MinimumSavingsBytes = 64; - private const int BrotliQuality = 1; - private const int BrotliWindow = 22; - - internal readonly record struct Encoded(byte[] Bytes, bool Compressed); - - /// - /// Attempts a worthwhile compression into an ArrayPool-owned buffer. - /// The caller must return a non-null buffer. A false result owns no - /// buffer and should write the original bytes directly. - /// - public static bool TryCompress( - ReadOnlySpan decoded, - out byte[]? rented, - out int storedLength) - { - if (decoded.Length > MaximumDecodedBytes) - throw new InvalidDataException( - $"pak payload is {decoded.Length} bytes; maximum is {MaximumDecodedBytes}"); - - rented = null; - storedLength = decoded.Length; - if (decoded.Length < MinimumCompressionBytes) - return false; - - int maximum = checked( - DecodedLengthPrefixSize - + BrotliEncoder.GetMaxCompressedLength(decoded.Length)); - byte[] candidate = ArrayPool.Shared.Rent(maximum); - if (!BrotliEncoder.TryCompress( - decoded, - candidate.AsSpan(DecodedLengthPrefixSize, maximum - DecodedLengthPrefixSize), - out int compressedLength, - BrotliQuality, - BrotliWindow)) - { - ArrayPool.Shared.Return(candidate); - return false; - } - - storedLength = checked(DecodedLengthPrefixSize + compressedLength); - int requiredSavings = Math.Max( - MinimumSavingsBytes, - decoded.Length / 16); - if (decoded.Length - storedLength < requiredSavings) - { - ArrayPool.Shared.Return(candidate); - storedLength = decoded.Length; - return false; - } - - BinaryPrimitives.WriteUInt32LittleEndian( - candidate.AsSpan(0, DecodedLengthPrefixSize), - checked((uint)decoded.Length)); - rented = candidate; - return true; - } - - // Test/convenience wrapper. Production PakWriter uses TryCompress so it - // can write the pooled buffer directly without a compacting copy. - public static Encoded Encode(byte[] decoded) - { - ArgumentNullException.ThrowIfNull(decoded); - if (!TryCompress(decoded, out byte[]? rented, out int storedLength)) - return new Encoded(decoded, false); - - byte[] buffer = rented - ?? throw new InvalidOperationException("compressed buffer was not returned"); - try - { - return new Encoded(buffer.AsSpan(0, storedLength).ToArray(), true); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - public static byte[] Decode(ReadOnlySpan stored) - { - if (stored.Length < DecodedLengthPrefixSize) - throw new InvalidDataException( - "compressed pak blob is missing its decoded-length prefix"); - - uint decodedLength = BinaryPrimitives.ReadUInt32LittleEndian( - stored[..DecodedLengthPrefixSize]); - if (decodedLength > MaximumDecodedBytes) - throw new InvalidDataException( - $"compressed pak blob declares unsupported decoded length {decodedLength}"); - - var decoded = new byte[checked((int)decodedLength)]; - if (!BrotliDecoder.TryDecompress( - stored[DecodedLengthPrefixSize..], - decoded, - out int written) - || written != decoded.Length) - { - throw new InvalidDataException( - $"compressed pak blob did not decode to its declared {decoded.Length} bytes"); - } - - return decoded; - } -} diff --git a/src/AcDream.Content/Pak/PakFormat.cs b/src/AcDream.Content/Pak/PakFormat.cs index 68898031..590b7e6a 100644 --- a/src/AcDream.Content/Pak/PakFormat.cs +++ b/src/AcDream.Content/Pak/PakFormat.cs @@ -14,7 +14,7 @@ public static class PakFormat { /// template); PakReader refuses to open any other version. Bump ONLY /// with an accompanying reader migration path. /// - public const uint CurrentFormatVersion = 2; + public const uint CurrentFormatVersion = 1; /// /// Identity of the bake algorithm that produced the payloads. Version 2 @@ -28,12 +28,9 @@ public static class PakFormat { /// zero vertices on its positive side, so any pak baked by an older tool /// is missing those faces (e.g. the Holtburg windmill axle 0x010010CE, /// 8 polygons all NoPos + Base1Solid, extracted to a 0-vertex mesh). The - /// binary format remains version 1. Version 6 introduces pak format 2: - /// globally shared texture payloads plus independently Brotli-compressed - /// blobs with raw fallback. Mesh geometry and source texture bytes remain - /// exact; unedited DXT surfaces now retain their native BC blocks. + /// binary format remains version 1. /// - public const uint CurrentBakeToolVersion = 6; + public const uint CurrentBakeToolVersion = 5; } /// @@ -41,7 +38,7 @@ public static class PakFormat { /// /// offset size field /// 0 4 magic 'ACPK' (0x4B504341) -/// 4 4 formatVersion = 2 +/// 4 4 formatVersion = 1 /// 8 4 portalIteration (DatCollection.Portal.Iteration) /// 12 4 cellIteration /// 16 4 highResIteration @@ -51,9 +48,7 @@ public static class PakFormat { /// 36 4 bakeToolVersion /// 40 24 reserved (zero) /// -/// Format 1 is specified by docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md. -/// Format 2 retains this header and adds the TOC compression flag plus global -/// texture payload entries documented in docs/plans/2026-08-27-pak-v2-resource-campaign.md. +/// Spec: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1 (normative)". /// public struct PakHeader { public const int Size = 64; @@ -119,32 +114,19 @@ public struct PakHeader { } /// -/// One 24-byte TOC entry: key u64, offset u64, lengthAndFlags u32, crc32 u32. +/// One 24-byte TOC entry: key u64, offset u64, length u32, crc32 u32. /// Entries in a pak's TOC are sorted ascending by to allow /// binary-search lookup. is a corruption tripwire computed /// over the blob bytes; the reader verifies lazily on first access. /// public struct PakTocEntry { public const int Size = 24; - public const uint CompressionFlag = 0x8000_0000u; - public const uint StoredLengthMask = 0x7FFF_FFFFu; public ulong Key; public ulong Offset; public uint Length; public uint Crc32; - /// - /// Format-2 entries use the high bit of to mark an - /// independently Brotli-compressed blob. The remaining 31 bits are the - /// exact bytes stored in the file (including the compressed blob's - /// four-byte decoded-length prefix). Raw entries retain their historical - /// length representation and require no second copy on read. - /// - public readonly bool IsCompressed => (Length & CompressionFlag) != 0; - - public readonly uint StoredLength => Length & StoredLengthMask; - public void WriteTo(Span dest) { if (dest.Length < Size) throw new ArgumentException($"destination must be at least {Size} bytes", nameof(dest)); BinaryPrimitives.WriteUInt64LittleEndian(dest[0..8], Key); diff --git a/src/AcDream.Content/Pak/PakKey.cs b/src/AcDream.Content/Pak/PakKey.cs index 72a5931e..8d98d460 100644 --- a/src/AcDream.Content/Pak/PakKey.cs +++ b/src/AcDream.Content/Pak/PakKey.cs @@ -15,7 +15,6 @@ public enum PakAssetType : byte { SetupCollision = 5, CellStructureCollision = 6, EnvCellTopology = 7, - TexturePayload = 8, } /// @@ -35,14 +34,4 @@ public static class PakKey { var fileId = (uint)((key >> 24) & 0xFFFFFFFFu); return (type, fileId); } - - /// - /// Composes a content-addressed format-2 key whose lower 56 bits are an - /// opaque payload identity rather than the legacy file-id/reserved split. - /// - public static ulong ComposeOpaque(PakAssetType type, ulong payloadId) { - if ((payloadId & 0xFF00_0000_0000_0000ul) != 0) - throw new ArgumentOutOfRangeException(nameof(payloadId)); - return ((ulong)type << 56) | payloadId; - } } diff --git a/src/AcDream.Content/Pak/PakReader.cs b/src/AcDream.Content/Pak/PakReader.cs index adf84696..c6767e44 100644 --- a/src/AcDream.Content/Pak/PakReader.cs +++ b/src/AcDream.Content/Pak/PakReader.cs @@ -40,7 +40,6 @@ public sealed class PakReader : IDisposable { private readonly MemoryMappedViewAccessor _accessor; private readonly long _fileLength; private readonly PakTocEntry[] _toc; // sorted ascending by Key - private readonly PakTexturePayloadCache _texturePayloads = new(); /// Lazy per-entry verdict: absent = not yet judged, 0 = bad (bounds/crc/structure), 1 = ok. private readonly ConcurrentDictionary _entryVerdictByTocIndex = new(); @@ -101,7 +100,7 @@ public sealed class PakReader : IDisposable { bool invalid = !IsEntryRangeValid(entry); if (invalid) { _entryVerdictByTocIndex[i] = 0; - LogCorruptionOnce(i, $"TOC entry out of bounds (offset={entry.Offset}, storedLength={entry.StoredLength}, " + + LogCorruptionOnce(i, $"TOC entry out of bounds (offset={entry.Offset}, length={entry.Length}, " + $"file={_fileLength}, toc@{Header.TocOffset})"); } } @@ -159,9 +158,7 @@ public sealed class PakReader : IDisposable { return status; try { - data = ObjectMeshDataSerializer.ReadExternalTextures( - bytes, - ResolveTexturePayload); + data = ObjectMeshDataSerializer.Read(bytes); return PakObjectReadStatus.Loaded; } catch (Exception ex) { @@ -197,9 +194,8 @@ public sealed class PakReader : IDisposable { // Single pass (review finding 4): ONE copy out of the map; CRC and // deserialization both run over this same buffer. ref readonly var entry = ref _toc[index]; - uint storedLength = entry.StoredLength; - bytes = new byte[checked((int)storedLength)]; - _accessor.ReadArray((long)entry.Offset, bytes, 0, checked((int)storedLength)); + bytes = new byte[entry.Length]; + _accessor.ReadArray((long)entry.Offset, bytes, 0, (int)entry.Length); if (!judged) { uint actualCrc = Crc32.Compute(bytes); @@ -212,41 +208,9 @@ public sealed class PakReader : IDisposable { _entryVerdictByTocIndex[index] = 1; } - if (entry.IsCompressed) { - try { - bytes = PakBlobCodec.Decode(bytes); - } - catch (Exception ex) when (ex is InvalidDataException or OverflowException) { - _entryVerdictByTocIndex[index] = 0; - LogCorruptionOnce( - index, - $"decompression failed despite matching CRC: {ex.Message}"); - bytes = null; - return PakObjectReadStatus.Corrupt; - } - } - return PakObjectReadStatus.Loaded; } - private byte[] ResolveTexturePayload(ulong key) { - if (PakKey.Decompose(key).Type != PakAssetType.TexturePayload) { - throw new InvalidDataException( - $"mesh references non-texture pak key 0x{key:X16}"); - } - - if (_texturePayloads.TryGet(key, out byte[] cachedBytes)) - return cachedBytes; - - PakObjectReadStatus status = ReadBlobBytes(key, out byte[]? loadedBytes); - if (status != PakObjectReadStatus.Loaded || loadedBytes is null) { - throw new InvalidDataException( - $"mesh references {status.ToString().ToLowerInvariant()} texture payload 0x{key:X16}"); - } - - return _texturePayloads.AddOrGet(key, loadedBytes); - } - internal void MarkPayloadCorrupt(ulong key, string reason) { int index = BinarySearch(key); if (index < 0) @@ -305,7 +269,7 @@ public sealed class PakReader : IDisposable { if (!IsEntryRangeValid(entry)) { throw new InvalidDataException( $"pak TOC entry 0x{entry.Key:X16} has an invalid range " + - $"(offset={entry.Offset}, storedLength={entry.StoredLength}, " + + $"(offset={entry.Offset}, length={entry.Length}, " + $"file={_fileLength}, toc@{Header.TocOffset})"); } } @@ -324,9 +288,8 @@ public sealed class PakReader : IDisposable { if (_entryVerdictByTocIndex.TryGetValue(tocIndex, out var cached)) return cached; ref readonly var entry = ref _toc[tocIndex]; - uint storedLength = entry.StoredLength; - var bytes = new byte[checked((int)storedLength)]; - _accessor.ReadArray((long)entry.Offset, bytes, 0, checked((int)storedLength)); + var bytes = new byte[entry.Length]; + _accessor.ReadArray((long)entry.Offset, bytes, 0, (int)entry.Length); uint actualCrc = Crc32.Compute(bytes); bool ok = actualCrc == entry.Crc32; @@ -341,8 +304,7 @@ public sealed class PakReader : IDisposable { if (!_loggedCorruption.TryAdd(tocIndex, true)) return; ref readonly var entry = ref _toc[tocIndex]; Console.Error.WriteLine( - $"[pak-corrupt] key 0x{entry.Key:X16} at offset {entry.Offset} " + - $"(storedLength {entry.StoredLength}, compressed={entry.IsCompressed}): " + + $"[pak-corrupt] key 0x{entry.Key:X16} at offset {entry.Offset} (length {entry.Length}): " + $"{reason} — treating as missing"); } @@ -367,11 +329,8 @@ public sealed class PakReader : IDisposable { return false; } - if (entry.StoredLength > PakBlobCodec.MaximumDecodedBytes) - return false; - - return entry.StoredLength <= Header.TocOffset - entry.Offset && - entry.StoredLength <= fileLength - entry.Offset; + return entry.Length <= Header.TocOffset - entry.Offset && + entry.Length <= fileLength - entry.Offset; } public void Dispose() { diff --git a/src/AcDream.Content/Pak/PakTexturePayloadCache.cs b/src/AcDream.Content/Pak/PakTexturePayloadCache.cs deleted file mode 100644 index 328bfcaf..00000000 --- a/src/AcDream.Content/Pak/PakTexturePayloadCache.cs +++ /dev/null @@ -1,98 +0,0 @@ -namespace AcDream.Content.Pak; - -/// -/// Bounded shared-byte owner for format-2 texture payloads. Mesh objects keep -/// ordinary array references, so evicting a cache row is safe while preventing -/// a long travel session from pinning every texture ever observed. -/// -internal sealed class PakTexturePayloadCache -{ - // Payloads are staging data on the way to GPU atlases. A 256 MiB cache - // could retain a second world-sized texture working set after the atlas - // already owned it; native BC makes 64 MiB ample for revisit sharing. - internal const long DefaultMaximumBytes = 64L * 1024 * 1024; - internal const int DefaultMaximumEntries = 1_024; - - private readonly long _maximumBytes; - private readonly int _maximumEntries; - private readonly Dictionary _entries = []; - private readonly LinkedList _lru = []; - private readonly Lock _gate = new(); - private long _bytes; - - internal int Count - { - get { lock (_gate) return _entries.Count; } - } - - internal long Bytes - { - get { lock (_gate) return _bytes; } - } - - private sealed record Entry(byte[] Bytes, LinkedListNode Node); - - public PakTexturePayloadCache( - long maximumBytes = DefaultMaximumBytes, - int maximumEntries = DefaultMaximumEntries) - { - ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1); - ArgumentOutOfRangeException.ThrowIfLessThan(maximumEntries, 1); - _maximumBytes = maximumBytes; - _maximumEntries = maximumEntries; - } - - public bool TryGet(ulong key, out byte[] bytes) - { - lock (_gate) - { - if (!_entries.TryGetValue(key, out Entry? entry)) - { - bytes = null!; - return false; - } - - _lru.Remove(entry.Node); - _lru.AddLast(entry.Node); - bytes = entry.Bytes; - return true; - } - } - - public byte[] AddOrGet(ulong key, byte[] bytes) - { - ArgumentNullException.ThrowIfNull(bytes); - lock (_gate) - { - if (_entries.TryGetValue(key, out Entry? existing)) - { - _lru.Remove(existing.Node); - _lru.AddLast(existing.Node); - return existing.Bytes; - } - - // A single unusually large texture must not defeat the global - // bound. Its caller still owns the returned array; it simply is - // not retained for a later mesh. - if (bytes.LongLength > _maximumBytes) - return bytes; - - LinkedListNode node = _lru.AddLast(key); - _entries.Add(key, new Entry(bytes, node)); - _bytes = checked(_bytes + bytes.LongLength); - Trim(); - return bytes; - } - } - - private void Trim() - { - while ((_bytes > _maximumBytes || _entries.Count > _maximumEntries) - && _lru.First is { } oldest) - { - _lru.RemoveFirst(); - if (_entries.Remove(oldest.Value, out Entry? removed)) - _bytes -= removed.Bytes.LongLength; - } - } -} diff --git a/src/AcDream.Content/Pak/PakWriter.cs b/src/AcDream.Content/Pak/PakWriter.cs index decb548b..672bbfcd 100644 --- a/src/AcDream.Content/Pak/PakWriter.cs +++ b/src/AcDream.Content/Pak/PakWriter.cs @@ -1,9 +1,7 @@ -using System.Buffers; using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Security.Cryptography; namespace AcDream.Content.Pak; @@ -21,16 +19,8 @@ public sealed class PakWriter : IDisposable { private readonly List _tocEntries = new(); private readonly HashSet _seenKeys = new(); private readonly Dictionary _receiptByKey = new(); - private readonly Dictionary _texturePayloadByKey = new(); private bool _finished; - public int TextureBlobCount { get; private set; } - public int EntryCount => _tocEntries.Count; - public int PhysicalBlobCount { get; private set; } - public long DecodedPayloadBytes { get; private set; } - public long StoredPayloadBytes { get; private set; } - public int CompressedBlobCount { get; private set; } - public PakWriter(string path, PakHeader headerTemplate) { _stream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None); _headerTemplate = headerTemplate; @@ -59,103 +49,42 @@ public sealed class PakWriter : IDisposable { ReserveKey(key); using var ms = new MemoryStream(); - ObjectMeshDataSerializer.WriteExternalTextures( - data, - ms, - RegisterTexturePayload); - if (!ms.TryGetBuffer(out ArraySegment buffer)) - throw new InvalidOperationException("mesh serializer did not expose its write buffer"); - AddReservedBlob(key, buffer.AsSpan(0, checked((int)ms.Length))); + ObjectMeshDataSerializer.Write(data, ms); + var bytes = ms.ToArray(); + AddReservedBlob(key, bytes); } /// /// Adds an already serialized immutable payload. This is the package seam /// used by typed non-render assets such as flat collision records. /// - public void AddBlob(ulong key, byte[] bytes) { - ArgumentNullException.ThrowIfNull(bytes); + public void AddBlob(ulong key, ReadOnlySpan bytes) { ThrowIfFinished(); ReserveKey(key); AddReservedBlob(key, bytes); } private void AddReservedBlob(ulong key, ReadOnlySpan bytes) { - bool compressed = PakBlobCodec.TryCompress( - bytes, - out byte[]? rented, - out int storedLength); - try - { - ReadOnlySpan stored = compressed - ? rented.AsSpan(0, storedLength) - : bytes; - long offset = _stream.Position; - _stream.Write(stored); - PadToAlignment(); + if ((ulong)bytes.Length > uint.MaxValue) { + throw new ArgumentException( + "one pak payload cannot exceed UInt32.MaxValue bytes", + nameof(bytes)); + } - var receipt = new PakTocEntry { - Key = key, - Offset = (ulong)offset, - Length = checked((uint)stored.Length) - | (compressed ? PakTocEntry.CompressionFlag : 0u), - Crc32 = Content.Pak.Crc32.Compute(stored), - }; - _tocEntries.Add(receipt); - _receiptByKey.Add(key, receipt); - PhysicalBlobCount++; - DecodedPayloadBytes = checked(DecodedPayloadBytes + bytes.Length); - StoredPayloadBytes = checked(StoredPayloadBytes + stored.Length); - if (compressed) - CompressedBlobCount++; - } - finally - { - if (rented is not null) - ArrayPool.Shared.Return(rented); - } + long offset = _stream.Position; + _stream.Write(bytes); + PadToAlignment(); + + var receipt = new PakTocEntry { + Key = key, + Offset = (ulong)offset, + Length = checked((uint)bytes.Length), + Crc32 = Content.Pak.Crc32.Compute(bytes), + }; + _tocEntries.Add(receipt); + _receiptByKey.Add(key, receipt); } - private ulong RegisterTexturePayload(TextureKey _, byte[] bytes) - { - Span digest = stackalloc byte[SHA256.HashSizeInBytes]; - SHA256.HashData(bytes, digest); - ulong payloadId = - ((ulong)digest[0] << 48) - | ((ulong)digest[1] << 40) - | ((ulong)digest[2] << 32) - | ((ulong)digest[3] << 24) - | ((ulong)digest[4] << 16) - | ((ulong)digest[5] << 8) - | digest[6]; - ulong key = PakKey.ComposeOpaque( - PakAssetType.TexturePayload, - payloadId); - - if (_texturePayloadByKey.TryGetValue(key, out TextureIdentity existing)) - { - if (existing.Length != bytes.Length - || !CryptographicOperations.FixedTimeEquals( - existing.Sha256, - digest)) - { - throw new InvalidDataException( - $"texture payload identity collision at key 0x{key:X16}"); - } - - return key; - } - - ReserveKey(key); - AddReservedBlob(key, bytes); - _texturePayloadByKey.Add( - key, - new TextureIdentity(bytes.Length, digest.ToArray())); - TextureBlobCount++; - return key; - } - - private readonly record struct TextureIdentity(int Length, byte[] Sha256); - private void ReserveKey(ulong key) { if (!_seenKeys.Add(key)) { throw new ArgumentException($"duplicate pak key 0x{key:X16}", nameof(key)); diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index f34d9723..81d3160f 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -138,15 +138,7 @@ public static class GameEventWiring // in GameEventType since the wire catalog with nothing behind them, // so until this wiring the bytes arrived and were dropped. Action>? onContractTable = null, - Action? onContractUpdate = null, - // Campaign CT slice CT2 (2026-08-24): the title-table (0x0029) and - // add/set-display (0x002B) delegate holes. RuntimeCharacterState. - // Titles is an AcDream.Runtime type — Core.Net cannot reference - // AcDream.Runtime directly, so these are delegate holes exactly like - // every other Runtime-owned sink above. Optional/nullable so every - // existing caller compiles unchanged. - Action /*titleIds*/>? onCharacterTitleTable = null, - Action? onUpdateTitle = null) + Action? onContractUpdate = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -482,24 +474,6 @@ public static class GameEventWiring }); } - // ── Character titles (Campaign CT slice CT2, 2026-08-24) ──────── - if (onCharacterTitleTable is not null) - { - registrar.Register(GameEventType.CharacterTitle, e => - { - var p = GameEvents.ParseCharacterTitleTable(e.Payload.Span); - if (p is not null) onCharacterTitleTable(p.Value.DisplayTitleId, p.Value.TitleIds); - }); - } - if (onUpdateTitle is not null) - { - registrar.Register(GameEventType.UpdateTitle, e => - { - var p = GameEvents.ParseUpdateTitle(e.Payload.Span); - if (p is not null) onUpdateTitle(p.Value.TitleId, p.Value.SetAsDisplay); - }); - } - if (onConfirmationRequest is not null) { registrar.Register(GameEventType.CharacterConfirmationRequest, e => @@ -848,6 +822,13 @@ public static class GameEventWiring registrar.Register(GameEventType.ApproachVendor, e => { var p = VendorApproach.TryParse(e.Payload.Span); + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] ApproachVendor(0x0062) inbound parsed={p is not null} " + + $"vendorGuid={(p is null ? "n/a" : $"0x{p.Value.VendorGuid:X8}")} " + + $"itemCount={(p is null ? "n/a" : p.Value.Items.Count.ToString())}"); + } if (p is null) return; var profile = new VendorShopProfile( @@ -865,6 +846,14 @@ public static class GameEventWiring for (int i = 0; i < shopItems.Length; i++) { VendorApproach.ItemProfile item = p.Value.Items[i]; + if (VendorDiagnostics.DumpVendorEnabled && i < 5) + { + Console.WriteLine( + $"[vendor-diag] ApproachVendor wire-item[{i}] guid=0x{item.ItemGuid:X8} " + + $"name={item.Desc.Name ?? "null"} " + + $"descStackSize={(item.Desc.StackSize is { } ds ? ds.ToString() : "null")} " + + $"stackSizeMax={(item.Desc.StackSizeMax is { } sm ? sm.ToString() : "null")}"); + } shopItems[i] = new VendorShopItem( item.ItemGuid, item.StackSize, @@ -961,6 +950,12 @@ public static class GameEventWiring registrar.Register(GameEventType.UseDone, e => { uint? err = GameEvents.ParseUseDone(e.Payload.Span); + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] UseDone(0x01C7) inbound parsed={err is not null} " + + $"err={(err is null ? "n/a" : $"0x{err.Value:X4}")}"); + } if (err is null) return; // Already the diagnostics-only log line SHOULD-FIX 4 asks for — // it fires unconditionally, so an unmapped code below stays @@ -1004,14 +999,9 @@ public static class GameEventWiring { var p = AppraiseInfoParser.TryParse(e.Payload.Span); if (p is null) return; - // Retain the property tables and the item's own spell manifest as - // one projection. VTank consults the latter for proc weapons. + // Merge parsed properties into the item if we know about it. if (p.Value.Success && items.Get(p.Value.Guid) is not null) - items.UpdateAppraisal( - p.Value.Guid, - p.Value.Properties, - p.Value.SpellBook, - clientTime()); + items.UpdateProperties(p.Value.Guid, p.Value.Properties); if (p.Value.CreatureProfile is { HealthMax: > 0u } creature) combat.OnUpdateHealth( p.Value.Guid, @@ -1025,8 +1015,8 @@ public static class GameEventWiring // spellbook arrives via PlayerDescription (0x0013), which uses // a different wire format (see WorldSession + LocalPlayerState // — feeds vitals from PrivateUpdateVital instead). - // The appraised spellbook now belongs to that item. The local - // player's learned manifest arrives only in PlayerDescription. + // The appraised spellbook belongs to that item. The local player's + // learned spell manifest arrives only in PlayerDescription. }); // ── Player ──────────────────────────────────────────────── @@ -1048,9 +1038,12 @@ public static class GameEventWiring // player this is the authoritative source (the per-item // SpellBook flag in IdentifyObjectResponse is for caster // items / scrolls only). + bool dumpPd = Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1"; registrar.Register(GameEventType.PlayerDescription, e => { var p = PlayerDescriptionParser.TryParse(e.Payload.Span); + if (dumpPd) + Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}"); if (p is null) return; // R3: a trailer-truncated parse carries zero placeholder option @@ -1113,6 +1106,8 @@ public static class GameEventWiring if (attr.Current is uint cur) { // Vital entry (id 7/8/9) — has absolute current. + if (dumpPd) + Console.WriteLine($"vitals: PD-vital id={attr.AtType} ranks={attr.Ranks} start={attr.Start} cur={cur}"); localPlayer.OnVitalUpdate( vitalId: attr.AtType, ranks: attr.Ranks, @@ -1125,6 +1120,8 @@ public static class GameEventWiring // Primary attribute (id 1..6) — Endurance+Self feed // the vital max formula (Endurance/2 for Health, // Endurance for Stamina, Self for Mana). + if (dumpPd) + Console.WriteLine($"vitals: PD-attr id={attr.AtType} ranks={attr.Ranks} start={attr.Start}"); localPlayer.OnAttributeUpdate( atType: attr.AtType, ranks: attr.Ranks, @@ -1177,6 +1174,10 @@ public static class GameEventWiring int total = (int)(formulaBonus + s.Init + s.Ranks); if (s.SkillId == 24u) runSkill = total; else if (s.SkillId == 22u) jumpSkill = total; + + if (dumpPd) + Console.WriteLine( + $"vitals: PD-skill id={s.SkillId} init={s.Init} ranks={s.Ranks} formulaBonus={formulaBonus} total={total}"); } if (runSkill >= 0 || jumpSkill >= 0) onSkillsUpdated?.Invoke(runSkill, jumpSkill); diff --git a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs index 35ff578d..4ee9d358 100644 --- a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs +++ b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs @@ -24,7 +24,6 @@ public static class ClientCommandRequests public const uint SetAfkModeOpcode = 0x000Fu; public const uint SetAfkMessageOpcode = 0x0010u; public const uint EmoteOpcode = 0x01DFu; - public const uint SoulEmoteOpcode = 0x01E1u; public const uint AddFriendOpcode = 0x0018u; public const uint AbandonContractOpcode = 0x0316u; public const uint RemoveFriendOpcode = 0x0017u; @@ -140,10 +139,6 @@ public static class ClientCommandRequests public static byte[] BuildEmote(uint sequence, string message) => BuildString(sequence, EmoteOpcode, message); - // CM_Communication::Event_SoulEmote @ 0x006A4500. - public static byte[] BuildSoulEmote(uint sequence, string message) => - BuildString(sequence, SoulEmoteOpcode, message); - // CM_Social::Event_AddFriend/RemoveFriend/ClearFriends // @ 0x006A5C10 / 0x006A5650 / 0x006A55C0. public static byte[] BuildAddFriend(uint sequence, string name) => diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index e14ac19f..4226fb0b 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -869,73 +869,6 @@ public static class GameEvents public static FellowshipFellowStatsDone ParseFellowshipFellowStatsDone(ReadOnlySpan payload) => new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null); - // ── Character titles (Campaign CT slice CT2, 2026-08-24) ──────────────── - - /// - /// 0x0029 CharacterTitle — retail CharacterTitleTable::UnPack - /// @0x005c6e90 (named-retail pseudo-C offset 471514-471526). The - /// FIRST u32 is advanced past but never stored into any field — retail's - /// own CharacterTitleTable::Pack @0x005c6e40 (offset 471494-471510) - /// always writes the literal constant 1 there - /// (**(uint32_t**)arg2 = 1), and ACE's - /// GameEventCharacterTitle.cs matches with an unconditional - /// Writer.Write(1u) — a version/format tag retail itself discards - /// on read, not meaningful gameplay data (CT2 task item 1). Then the - /// current display title id (mDisplayTitle), then the - /// count-prefixed PList<uint> of every earned title id - /// (mTitleList). - /// - public readonly record struct CharacterTitleTable( - uint DisplayTitleId, - IReadOnlyList TitleIds); - - public static CharacterTitleTable? ParseCharacterTitleTable(ReadOnlySpan payload) - { - try - { - int pos = 0; - _ = FellowshipReadU32(payload, ref pos); // discarded pack-version tag — see doc comment above - uint displayTitleId = FellowshipReadU32(payload, ref pos); - uint count = FellowshipReadU32(payload, ref pos); - // PList::UnPack stores a 32-bit count bounded only by the - // remaining packet — same generous guard as - // SocialStateMessages.ParseFriendsUpdate. - if (count > 65_536) return null; - var titleIds = new uint[count]; - for (int i = 0; i < titleIds.Length; i++) - titleIds[i] = FellowshipReadU32(payload, ref pos); - return new CharacterTitleTable(displayTitleId, titleIds); - } - catch (FormatException) { return null; } - } - - /// - /// 0x002B UpdateTitle — retail's dispatch entry - /// CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0 reads - /// exactly titleId then setAsDisplay and forwards to - /// ClientUISystem::Handle_Social__AddOrSetCharacterTitle - /// @0x00564260, which ALWAYS broadcasts - /// SendNotice_AddCharacterTitle(titleId) (a title just earned is - /// unconditionally added to the earned set) and, only when - /// setAsDisplay != 0, ALSO broadcasts - /// SendNotice_SetDisplayCharacterTitle(titleId). ACE's - /// GameEventUpdateTitle.cs: u32 title, u32 - /// setAsDisplayTitle — matches exactly. - /// - public readonly record struct UpdateTitle(uint TitleId, bool SetAsDisplay); - - public static UpdateTitle? ParseUpdateTitle(ReadOnlySpan payload) - { - try - { - int pos = 0; - uint titleId = FellowshipReadU32(payload, ref pos); - bool setAsDisplay = FellowshipReadU32(payload, ref pos) != 0u; - return new UpdateTitle(titleId, setAsDisplay); - } - catch (FormatException) { return null; } - } - private static FellowMember ReadFellow(ReadOnlySpan payload, ref int pos, uint guid) { uint cpCache = FellowshipReadU32(payload, ref pos); diff --git a/src/AcDream.Core.Net/Messages/InventoryActions.cs b/src/AcDream.Core.Net/Messages/InventoryActions.cs index 8bf41bd6..c88a9cef 100644 --- a/src/AcDream.Core.Net/Messages/InventoryActions.cs +++ b/src/AcDream.Core.Net/Messages/InventoryActions.cs @@ -27,7 +27,6 @@ public static class InventoryActions public const uint DropItemOpcode = 0x001Bu; public const uint NoLongerViewingContentsOpcode = 0x0195u; public const uint SetInscriptionOpcode = 0x00BFu; - public const uint CreateTinkeringToolOpcode = 0x027Du; /// /// Merge stack A into stack B of the same item type. Server validates @@ -206,48 +205,4 @@ public static class InventoryActions text.CopyTo(body, 18); return body; } - - /// - /// Salvage one or more carried items with an owned salvage tool. Retail - /// CM_Inventory::Event_CreateTinkeringTool @ 0x006AB830 writes the - /// tool id followed by PackableList<unsigned long>: u32 count, - /// then the object ids in list order. The server replies with GameEvent - /// 0x02B4 SalvageOperationsResult and removes accepted source items. - /// - public static byte[] BuildCreateTinkeringTool( - uint seq, - uint toolGuid, - IReadOnlyList itemGuids) - { - ArgumentNullException.ThrowIfNull(itemGuids); - if (toolGuid == 0u) - throw new ArgumentOutOfRangeException(nameof(toolGuid)); - if (itemGuids.Count == 0) - throw new ArgumentException( - "At least one item is required for salvage.", - nameof(itemGuids)); - - byte[] body = new byte[20 + (itemGuids.Count * sizeof(uint))]; - BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian( - body.AsSpan(8), - CreateTinkeringToolOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), toolGuid); - BinaryPrimitives.WriteUInt32LittleEndian( - body.AsSpan(16), - checked((uint)itemGuids.Count)); - for (int index = 0; index < itemGuids.Count; index++) - { - uint itemGuid = itemGuids[index]; - if (itemGuid == 0u) - throw new ArgumentException( - "Salvage item ids must be non-zero.", - nameof(itemGuids)); - BinaryPrimitives.WriteUInt32LittleEndian( - body.AsSpan(20 + (index * sizeof(uint))), - itemGuid); - } - return body; - } } diff --git a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs index 8fdb6fdf..765b97fe 100644 --- a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs +++ b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs @@ -447,7 +447,7 @@ public static class PlayerDescriptionParser } } } - catch (FormatException) + catch (FormatException ex) { // Trailer corrupted — keep what we have and flag it. Once // Tasks 3-9 add list reads inside this try block, partial @@ -455,6 +455,8 @@ public static class PlayerDescriptionParser // them so they can ignore the trailer if they need all-or- // nothing semantics. trailerTruncated = true; + if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1") + System.Console.WriteLine($"PlayerDescriptionParser: trailer FormatException at pos={pos}/{payload.Length}: {ex.Message}"); } return new Parsed( @@ -464,9 +466,12 @@ public static class PlayerDescriptionParser shortcuts, hotbarSpells, desiredComps, spellbookFilters, gameplayOptions, inventory, equipped, trailerTruncated); } - catch (FormatException) + catch (FormatException ex) { // Truncation mid-walk — return null so caller knows parse failed. + // Diagnostic when ACDREAM_DUMP_VITALS=1 surfaces the failure point. + if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1") + System.Console.WriteLine($"PlayerDescriptionParser: FormatException at pos={pos}/{payload.Length}: {ex.Message}"); return null; } } diff --git a/src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs b/src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs deleted file mode 100644 index 7c61f673..00000000 --- a/src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.Buffers.Binary; - -namespace AcDream.Core.Net.Messages; - -/// -/// Inbound primary-attribute update GameMessage for the local player -/// (0x02E3) — the server's authoritative answer to a RaiseAttribute -/// action (and any other server-side attribute change). A standalone -/// GameMessage like , NOT a 0xF7B0 -/// GameEvent. -/// -/// -/// Campaign CA slice CA2 (#431): until this parser existed the client's -/// attribute model was stale from login's PlayerDescription until the next -/// login — every post-raise derived value (skills, vitals maxima, run -/// rate) computed from old attributes. -/// -/// -/// -/// Wire layout — three-source agreement (ACE -/// GameMessagePrivateUpdateAttribute.cs:8-16; Chorizite -/// AttributeInfo.generated.cs:26-54; holtburger -/// player/types.rs:22-53 UpdateAttribute<false>), full -/// citations in -/// docs/research/2026-08-24-advancement-wire-and-recompute.md §2.5: -/// -/// -/// PrivateUpdateAttribute (0x02E3): -/// u32 opcode = 0x02E3 -/// u8 sequence // ByteSequence, per-attribute counter -/// u32 attribute // PropertyAttribute (1=Strength..6=Self) -/// u32 ranks -/// u32 start // StartingValue / InitLevel -/// u32 xp // ExperienceSpent / CPSpent -/// -/// -public static class PrivateUpdateAttribute -{ - public const uint Opcode = 0x02E3u; - - /// Parsed attribute update. - public readonly record struct Parsed( - byte Sequence, - uint AttributeId, - uint Ranks, - uint Start, - uint Xp); - - /// - /// Parse a raw PrivateUpdateAttribute (0x02E3) body. Returns - /// null on opcode mismatch or truncation. - /// - public static Parsed? TryParse(ReadOnlySpan body) - { - // 4 (opcode) + 1 (seq) + 4 * 4 (uints) = 21 bytes minimum. - if (body.Length < 21) return null; - uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); - if (opcode != Opcode) return null; - - int pos = 4; - byte seq = body[pos]; pos += 1; - uint attr = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - uint ranks = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - uint start = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - uint xp = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); - return new Parsed(seq, attr, ranks, start, xp); - } -} diff --git a/src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs b/src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs deleted file mode 100644 index 0d7f9a9d..00000000 --- a/src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Buffers.Binary; - -namespace AcDream.Core.Net.Messages; - -/// -/// Inbound skill update GameMessage for the local player -/// (0x02DD) — the server's authoritative answer to RaiseSkill / -/// TrainSkill / specialize / untrain / reset. A standalone GameMessage -/// like , NOT a 0xF7B0 GameEvent. -/// -/// -/// Campaign CA slice CA2 (#431). NOTE the opcode-neighborhood trap this -/// slice also corrected: 0x02DD is PrivateUpdateSkill, not -/// PrivateUpdatePropertyString (which is 0x02D5) — ACE -/// GameMessageOpcode.cs:21,29. The related ranks-only -/// PrivateUpdateSkillLevel (0x02DF) has NO producer anywhere in -/// ACE (verified 2026-08-24) and is deliberately not parsed. -/// -/// -/// -/// Wire layout — three-source agreement (ACE -/// GameMessagePrivateUpdateSkill.cs:8-24; Chorizite -/// Skill.generated.cs:22-86; holtburger -/// player/types.rs:71-131 with the golden fixture at -/// :279-293 confirming adjustPP=1 on real captures), full -/// citations in -/// docs/research/2026-08-24-advancement-wire-and-recompute.md §2.8: -/// -/// -/// PrivateUpdateSkill (0x02DD): -/// u32 opcode = 0x02DD -/// u8 sequence // ByteSequence, per-skill counter -/// u32 skillId // Skill enum ordinal -/// u16 ranks // LevelFromPP — ushort on the wire! -/// u16 adjustPP // hardcoded 1 by ACE on every send -/// u32 advancementClass // SkillAdvancementClass (1=Untrained/2=Trained/3=Specialized) -/// u32 xp // ExperienceSpent / PP -/// u32 init // InitLevel -/// u32 resistance // ResistanceAtLastCheck -/// f64 lastUsedTime -/// -/// -public static class PrivateUpdateSkill -{ - public const uint Opcode = 0x02DDu; - - /// Parsed skill update. Ranks widened from the wire's u16. - public readonly record struct Parsed( - byte Sequence, - uint SkillId, - uint Ranks, - ushort AdjustPP, - uint AdvancementClass, - uint Xp, - uint Init, - uint Resistance, - double LastUsed); - - /// - /// Parse a raw PrivateUpdateSkill (0x02DD) body. Returns - /// null on opcode mismatch or truncation. - /// - public static Parsed? TryParse(ReadOnlySpan body) - { - // 4 (opcode) + 1 (seq) + 4 + 2 + 2 + 4*4 + 8 = 37 bytes minimum. - if (body.Length < 37) return null; - uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); - if (opcode != Opcode) return null; - - int pos = 4; - byte seq = body[pos]; pos += 1; - uint skillId = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - ushort ranks = BinaryPrimitives.ReadUInt16LittleEndian(body[pos..]); pos += 2; - ushort adjustPP = BinaryPrimitives.ReadUInt16LittleEndian(body[pos..]); pos += 2; - uint sac = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - uint xp = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - uint init = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - uint resistance = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - double lastUsed = BitConverter.Int64BitsToDouble( - BinaryPrimitives.ReadInt64LittleEndian(body[pos..])); - return new Parsed( - seq, skillId, ranks, adjustPP, sac, xp, init, resistance, lastUsed); - } -} diff --git a/src/AcDream.Core.Net/Messages/SocialActions.cs b/src/AcDream.Core.Net/Messages/SocialActions.cs index 2ca551e7..02ef46c8 100644 --- a/src/AcDream.Core.Net/Messages/SocialActions.cs +++ b/src/AcDream.Core.Net/Messages/SocialActions.cs @@ -52,16 +52,6 @@ public static class SocialActions public const uint FellowshipAssignNewLeaderOpcode = 0x0290u; // u32 newLeaderGuid public const uint FellowshipChangeOpennessOpcode = 0x0291u; // u32 isOpen (0/1) — the REAL openness toggle - // Character titles (Campaign CT slice CT2, 2026-08-24). ACE - // GameActionType.TitleSet = 0x002C; GameActionSetTitle.cs reads exactly - // one u32 title id (session.Player.HandleActionSetTitle(title)). Retail - // sender CM_Social::Event_SetDisplayCharacterTitle @0x006a5720 (verified - // in the named pseudo-C): builds a 0x10-byte OrderHdr'd body whose - // payload is [u32 0x2c][u32 titleId] and sends via - // Proto_UI::SendToWeenie — no local state touched (see CT2's report: no - // optimistic mutation, matching this slice's CA-lesson design). - public const uint TitleSetOpcode = 0x002Cu; // u32 titleId - // Character options // CH3 (2026-08-09): the full-blob SetCharacterOptions (0x01A1) builder // and the string-payload AddChannel/RemoveChannel (0x0145/0x0146) @@ -240,17 +230,6 @@ public static class SocialActions return body; } - /// - /// Set the character's display title — 0x002C TitleSet. Retail's - /// Event_SetDisplayCharacterTitle sends this and touches NO local - /// state; the display title updates only when the server echoes back - /// UpdateTitle (0x002B) with setAsDisplay=true (CT2: no - /// optimistic local mutation, matching retail exactly — the CA-campaign - /// lesson never re-add one). - /// - public static byte[] BuildTitleSet(uint seq, uint titleId) - => SingleGuid(seq, TitleSetOpcode, titleId); - /// /// Toggle one character option and push it to the server. /// GameActionSetSingleCharacterOption @ GameActionType 0x0005 — diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 6bc9ea59..c513e699 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -500,21 +500,6 @@ public sealed class WorldSession : IDisposable /// public event Action? VitalCurrentUpdated; - /// - /// Campaign CA CA2 (#431): fires when a - /// PrivateUpdateAttribute (0x02E3) arrives — the authoritative - /// primary-attribute record after a raise. Subscribers typically feed - /// . - /// - public event Action? AttributeUpdated; - - /// - /// Campaign CA CA2 (#431): fires when a - /// PrivateUpdateSkill (0x02DD) arrives — the authoritative skill - /// record after raise / train / specialize / untrain / reset. - /// - public event Action? SkillUpdated; - /// /// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the /// server sends a PlayScriptId (opcode 0xF754) packet — @@ -795,12 +780,15 @@ public sealed class WorldSession : IDisposable // Issue #5 diagnostics (env-var-gated): // ACDREAM_DUMP_OPCODES=1 → log first occurrence of each unhandled opcode + // ACDREAM_DUMP_VITALS=1 → log every PrivateUpdateVital(Current) parse // ACDREAM_DUMP_APPEARANCE=1 → log every 0xF625 ObjDescEvent + 0xF7DB UpdateObject // with body len, target guid, hex preview. Used to // debug remote-player appearance asymmetry (retail // observer in acdream renders wrong skin/hair). private static readonly bool DumpOpcodesEnabled = Environment.GetEnvironmentVariable("ACDREAM_DUMP_OPCODES") == "1"; + private static readonly bool DumpVitalsEnabled = + Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1"; private static readonly bool DumpAppearanceEnabled = Environment.GetEnvironmentVariable("ACDREAM_DUMP_APPEARANCE") == "1"; private readonly System.Collections.Generic.HashSet _seenUnhandledOpcodes = new(); @@ -2261,6 +2249,8 @@ public sealed class WorldSession : IDisposable // format per holtburger UpdateVital — see // PrivateUpdateVital.TryParseFull. var parsed = PrivateUpdateVital.TryParseFull(body); + if (DumpVitalsEnabled) + Console.WriteLine($"vitals: 0x02E7 PrivateUpdateVital body.len={body.Length} parsed={(parsed is null ? "null" : $"v{parsed.Value.VitalId} ranks={parsed.Value.Ranks} start={parsed.Value.Start} cur={parsed.Value.Current}")}"); if (parsed is not null) VitalUpdated?.Invoke(parsed.Value); } @@ -2269,27 +2259,11 @@ public sealed class WorldSession : IDisposable // Issue #5: current-only delta (regen ticks / drains). // Wire format per holtburger UpdateVitalCurrent. var parsed = PrivateUpdateVital.TryParseCurrent(body); + if (DumpVitalsEnabled) + Console.WriteLine($"vitals: 0x02E9 PrivateUpdateVitalCurrent body.len={body.Length} parsed={(parsed is null ? "null" : $"v{parsed.Value.VitalId} cur={parsed.Value.Current}")}"); if (parsed is not null) VitalCurrentUpdated?.Invoke(parsed.Value); } - else if (op == PrivateUpdateAttribute.Opcode) - { - // Campaign CA CA2 (#431): authoritative attribute record - // after RaiseAttribute. Wire per ACE - // GameMessagePrivateUpdateAttribute (3-source agreement). - var parsed = PrivateUpdateAttribute.TryParse(body); - if (parsed is not null) - AttributeUpdated?.Invoke(parsed.Value); - } - else if (op == PrivateUpdateSkill.Opcode) - { - // Campaign CA CA2 (#431): authoritative skill record after - // raise/train/specialize/untrain/reset. Wire per ACE - // GameMessagePrivateUpdateSkill (3-source agreement). - var parsed = PrivateUpdateSkill.TryParse(body); - if (parsed is not null) - SkillUpdated?.Invoke(parsed.Value); - } else if (op == PublicUpdatePropertyInt.Opcode) { var p = PublicUpdatePropertyInt.TryParse(body); @@ -2681,13 +2655,6 @@ public sealed class WorldSession : IDisposable SendGameAction(ClientCommandRequests.BuildEmote(seq, message)); } - public void SendSoulEmote(string message) - { - ArgumentNullException.ThrowIfNull(message); - uint seq = NextGameActionSequence(); - SendGameAction(ClientCommandRequests.BuildSoulEmote(seq, message)); - } - /// /// Send retail SetSingleCharacterOption (0x0005) — toggles one character /// option. For the six ListenTo*Chat ids this is the message that @@ -2726,18 +2693,6 @@ public sealed class WorldSession : IDisposable spellbookFilters)); } - /// - /// Send retail TitleSet (0x002C) — sets the character's display - /// title. Sends only; no local state changes here (Campaign CT slice - /// CT2: retail's own send path is non-optimistic — see - /// ). - /// - public void SendSetTitle(uint titleId) - { - uint seq = NextGameActionSequence(); - SendGameAction(SocialActions.BuildTitleSet(seq, titleId)); - } - public void SendAddFriend(string name) { uint seq = NextGameActionSequence(); @@ -3268,19 +3223,6 @@ public sealed class WorldSession : IDisposable SendGameAction(InventoryActions.BuildStackableSplitTo3D(seq, stackGuid, amount)); } - /// - /// Send retail CreateTinkeringTool (0x027D), the salvage operation used by - /// gmSalvageUI and VTank. - /// - public void SendSalvage(uint toolGuid, IReadOnlyList itemGuids) - { - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildCreateTinkeringTool( - seq, - toolGuid, - itemGuids)); - } - /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). /// /// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 diff --git a/src/AcDream.Core.Net/packages.win-x64.lock.json b/src/AcDream.Core.Net/packages.win-x64.lock.json index 64561bb2..c2855510 100644 --- a/src/AcDream.Core.Net/packages.win-x64.lock.json +++ b/src/AcDream.Core.Net/packages.win-x64.lock.json @@ -2,6 +2,55 @@ "version": 2, "dependencies": { "net10.0": { + "BCnEncoder.Net": { + "type": "Direct", + "requested": "[2.2.1, )", + "resolved": "2.2.1", + "contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==", + "dependencies": { + "CommunityToolkit.HighPerformance": "8.4.0" + } + }, + "Chorizite.Core": { + "type": "Direct", + "requested": "[0.0.18, )", + "resolved": "0.0.18", + "contentHash": "Pvf5idSsN0NfhZspJhrpo7QiNTmHx3mnKFkmAvHFpEFx+RnqfXh96Q+Pxam+hrqXbimmKGfk1ooIl5kpQcMo6w==", + "dependencies": { + "Autofac": "8.4.0", + "Chorizite.ACProtocol": "1.0.1", + "Chorizite.Common": "1.0.3", + "Chorizite.DatReaderWriter": "1.0.0", + "FontStashSharp": "1.3.10", + "Microsoft.Diagnostics.Runtime": "3.1.512801", + "Microsoft.Extensions.Logging.Abstractions": "9.0.9", + "NJsonSchema": "11.5.1", + "SixLabors.ImageSharp": "3.1.11", + "SixLabors.ImageSharp.Drawing": "2.1.7" + } + }, + "Chorizite.DatReaderWriter": { + "type": "Direct", + "requested": "[2.1.7, )", + "resolved": "2.1.7", + "contentHash": "6CpUhfHDV/O+lNx0xUZUcXK7wGkEkHYCnGHFtD8BBeAv4i1BuaNfTID+VQoSzx2EtmOVM0A2O/9wRFibApz6rQ==", + "dependencies": { + "DotNet.Standard.Common": "2.0.1", + "ZLibDotNet": "0.1.1" + } + }, + "Serilog": { + "type": "Direct", + "requested": "[4.0.2, )", + "resolved": "4.0.2", + "contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA==" + }, + "StbImageSharp": { + "type": "Direct", + "requested": "[2.30.16, )", + "resolved": "2.30.16", + "contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw==" + }, "Autofac": { "type": "Transitive", "resolved": "8.4.0", @@ -196,57 +245,9 @@ "resolved": "0.1.1", "contentHash": "QEti4O7dwRcOb9zbnLuudSrt2IT61OYjq0R7lcJb+EzUm5N6djOVGU/cp+FZIZzJUnaFIntwrpwiuRvhpS7ZHg==" }, - "acdream.core": { - "type": "Project", - "dependencies": { - "AcDream.Plugin.Abstractions": "[1.0.0, )", - "BCnEncoder.Net": "[2.2.1, )", - "Chorizite.Core": "[0.0.18, )", - "Chorizite.DatReaderWriter": "[2.1.7, )", - "Serilog": "[4.0.2, )", - "StbImageSharp": "[2.30.16, )" - } - }, "acdream.plugin.abstractions": { "type": "Project" }, - "BCnEncoder.Net": { - "type": "CentralTransitive", - "requested": "[2.2.1, )", - "resolved": "2.2.1", - "contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==", - "dependencies": { - "CommunityToolkit.HighPerformance": "8.4.0" - } - }, - "Chorizite.Core": { - "type": "CentralTransitive", - "requested": "[0.0.18, )", - "resolved": "0.0.18", - "contentHash": "Pvf5idSsN0NfhZspJhrpo7QiNTmHx3mnKFkmAvHFpEFx+RnqfXh96Q+Pxam+hrqXbimmKGfk1ooIl5kpQcMo6w==", - "dependencies": { - "Autofac": "8.4.0", - "Chorizite.ACProtocol": "1.0.1", - "Chorizite.Common": "1.0.3", - "Chorizite.DatReaderWriter": "1.0.0", - "FontStashSharp": "1.3.10", - "Microsoft.Diagnostics.Runtime": "3.1.512801", - "Microsoft.Extensions.Logging.Abstractions": "9.0.9", - "NJsonSchema": "11.5.1", - "SixLabors.ImageSharp": "3.1.11", - "SixLabors.ImageSharp.Drawing": "2.1.7" - } - }, - "Chorizite.DatReaderWriter": { - "type": "CentralTransitive", - "requested": "[2.1.7, )", - "resolved": "2.1.7", - "contentHash": "6CpUhfHDV/O+lNx0xUZUcXK7wGkEkHYCnGHFtD8BBeAv4i1BuaNfTID+VQoSzx2EtmOVM0A2O/9wRFibApz6rQ==", - "dependencies": { - "DotNet.Standard.Common": "2.0.1", - "ZLibDotNet": "0.1.1" - } - }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", "requested": "[9.0.9, )", @@ -256,24 +257,12 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9" } }, - "Serilog": { - "type": "CentralTransitive", - "requested": "[4.0.2, )", - "resolved": "4.0.2", - "contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA==" - }, "SixLabors.ImageSharp": { "type": "CentralTransitive", "requested": "[3.1.12, )", "resolved": "3.1.11", "contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw==" }, - "StbImageSharp": { - "type": "CentralTransitive", - "requested": "[2.30.16, )", - "resolved": "2.30.16", - "contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw==" - }, "StbTrueTypeSharp": { "type": "CentralTransitive", "requested": "[1.26.12, )", diff --git a/src/AcDream.Core/Chat/ChatCommandTargetState.cs b/src/AcDream.Core/Chat/ChatCommandTargetState.cs index fda55957..47a955cc 100644 --- a/src/AcDream.Core/Chat/ChatCommandTargetState.cs +++ b/src/AcDream.Core/Chat/ChatCommandTargetState.cs @@ -16,8 +16,6 @@ public sealed class ChatCommandTargetState : IDisposable private readonly object _gate = new(); private string? _lastIncomingTellSender; private string? _lastOutgoingTellTarget; - private string? _lastMonarchSender; - private string? _lastPatronSender; private bool _disposed; public ChatCommandTargetState(ChatLog chat) @@ -44,26 +42,6 @@ public sealed class ChatCommandTargetState : IDisposable } } - /// Most recent sender of an incoming retail @m broadcast. - public string? LastMonarchSender - { - get - { - lock (_gate) - return _lastMonarchSender; - } - } - - /// Most recent sender of an incoming retail @p broadcast. - public string? LastPatronSender - { - get - { - lock (_gate) - return _lastPatronSender; - } - } - public bool IsDisposed { get @@ -83,8 +61,6 @@ public sealed class ChatCommandTargetState : IDisposable { _lastIncomingTellSender = null; _lastOutgoingTellTarget = null; - _lastMonarchSender = null; - _lastPatronSender = null; } } @@ -101,34 +77,17 @@ public sealed class ChatCommandTargetState : IDisposable private void OnEntryAppended(ChatEntry entry) { - if (string.IsNullOrEmpty(entry.Sender)) + if (entry.Kind != ChatKind.Tell || string.IsNullOrEmpty(entry.Sender)) return; lock (_gate) { if (_disposed) return; - if (entry.Kind == ChatKind.Tell) - { - if (entry.SenderGuid != 0u) - _lastIncomingTellSender = entry.Sender; - else - _lastOutgoingTellTarget = entry.Sender; - return; - } - - // gmCCommunicationSystem keeps independent reply targets for the - // legacy Monarch (0x4000) and Patron (0x2000) broadcasts. The - // legacy 0x0147 wire payload has no sender GUID, so its committed - // ChatEntry correctly carries zero even for an incoming speaker. - // Local channel echoes have an empty Sender and were rejected at - // the top of this method; the non-empty name is the discriminator. - if (entry.Kind != ChatKind.Channel) - return; - if (entry.ChannelId == 0x00004000u) - _lastMonarchSender = entry.Sender; - else if (entry.ChannelId == 0x00002000u) - _lastPatronSender = entry.Sender; + if (entry.SenderGuid != 0u) + _lastIncomingTellSender = entry.Sender; + else + _lastOutgoingTellTarget = entry.Sender; } } } diff --git a/src/AcDream.Core/Chat/ClientTextRefusals.cs b/src/AcDream.Core/Chat/ClientTextRefusals.cs index d7a65c8d..a1851827 100644 --- a/src/AcDream.Core/Chat/ClientTextRefusals.cs +++ b/src/AcDream.Core/Chat/ClientTextRefusals.cs @@ -144,19 +144,4 @@ public static class ClientTextRefusals /// SpewBox-only channel every other refusal in this file uses. /// public const string CantLogOffMidAir = "Cannot log off while in mid-air."; - - /// - /// #436 — attack pressed with no valid combat target. Inline literal in - /// ClientCombatSystem::ExecuteAttack (0x0056bb70; the - /// refusal branch at 0x0056bc05), routed via - /// ClientSystem::AddTextToScroll(..., 0x1A, true, 0) — the same - /// RetailLogTextType.ClientLocal SpewBox channel as the rest of - /// this file, though unlike the 11 globals above it is not one of - /// ClientCommunicationSystem's static-ctor strings. Retail shows - /// ONLY this message here: attacking while not in melee/missile mode is - /// silent (that path is unreachable in retail's dispatch), so acdream - /// deliberately emits nothing for that case either. - /// - public const string MustSelectCombatTarget = - "You must select a valid combat target before attacking"; } diff --git a/src/AcDream.Core/Chat/InventoryFailureMessages.cs b/src/AcDream.Core/Chat/InventoryFailureMessages.cs index 7b7cab46..c05c3269 100644 --- a/src/AcDream.Core/Chat/InventoryFailureMessages.cs +++ b/src/AcDream.Core/Chat/InventoryFailureMessages.cs @@ -33,19 +33,18 @@ public static class InventoryFailureMessages string itemName, uint weenieError) { - // ServerSaysAttemptFailed's complete verb switch. The enum values are - // named by operation rather than retail's numeric IR_* values, but the - // wording and NAME_PLURAL/NAME_APPROPRIATE choice are verbatim. + // ServerSaysAttemptFailed's verb switch. acdream has no latched kind + // for retail's IR_MOVE ("moved") or IR_WIELD ("wielded") today — + // wields ride AutoWieldController without the single-request gate — + // so those rows are absent rather than guessed onto a wrong kind. string? verb = kind switch { InventoryRequestKind.Merge => "merged", InventoryRequestKind.SplitToContainer => "split", InventoryRequestKind.SplitToWorld => "split", - InventoryRequestKind.Move => "moved", InventoryRequestKind.Pickup => "picked up", InventoryRequestKind.PutInContainer => "put in the container", InventoryRequestKind.DropToWorld => "dropped", - InventoryRequestKind.Wield => "wielded", InventoryRequestKind.Give => "given", _ => null, }; diff --git a/src/AcDream.Core/Input/RetailActionMap.cs b/src/AcDream.Core/Input/RetailActionMap.cs index 22f8115d..9ec2083c 100644 --- a/src/AcDream.Core/Input/RetailActionMap.cs +++ b/src/AcDream.Core/Input/RetailActionMap.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using AcDream.Core.Content; using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; namespace AcDream.Core.Input; @@ -125,23 +124,7 @@ public sealed record RetailActionMapRow( /// The complete read result: every user-bindable ActionMap row, plus the raw /// row count read (for conformance pinning against the installed dats). -public sealed record RetailActionMapSnapshot( - IReadOnlyList Rows, - IReadOnlyDictionary>? ConflictingInputMaps = null) -{ - /// - /// Retail ICIDM::FindConflictingInputMaps policy. A context always - /// conflicts with itself; cross-context conflicts exist only when the - /// DAT ActionMap.ConflictingMaps table names the other context. - /// Contexts absent from that table therefore do not conflict across maps. - /// - public bool InputMapsConflict(uint leftInputMapId, uint rightInputMapId) => - leftInputMapId == rightInputMapId - || (ConflictingInputMaps?.TryGetValue( - leftInputMapId, - out IReadOnlySet? conflicts) == true - && conflicts.Contains(rightInputMapId)); -} +public sealed record RetailActionMapSnapshot(IReadOnlyList Rows); /// /// Retail's 19 named InputMapID -> ID_InputMap_* string-table keys @@ -237,16 +220,7 @@ public static class RetailActionMapReader } } - var conflictingInputMaps = new Dictionary>(); - foreach (var entry in actionMap.ConflictingMaps) - { - InputsConflictsValue value = entry.Value; - uint inputMapId = value.InputMap != 0u ? value.InputMap : entry.Key; - conflictingInputMaps[inputMapId] = - new HashSet(value.ConflictingInputMaps); - } - - return new RetailActionMapSnapshot(rows, conflictingInputMaps); + return new RetailActionMapSnapshot(rows); } private static void CollectDefaults( diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index 40ea3b74..42a99890 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -251,21 +251,6 @@ public sealed class ClientObject /// Retail PublicWeenieDesc._spellID; used by caster endowments. public uint? SpellId { get; set; } /// - /// Spell ids retained from this item's latest successful - /// IdentifyObjectResponse SpellBook block. These are item spells, - /// not the local character's learned spellbook; VTank uses them to - /// classify cast-on-strike weapons and item-cast debuffs. - /// - public IReadOnlyList AppraisedSpellIds { get; internal set; } = - Array.Empty(); - /// - /// Monotonic millisecond tick at which the latest successful identify - /// response was received. This is Decal's per-world-object - /// LastIdTime, retained on the canonical object so it disappears - /// with that exact object lifetime. - /// - public int LastAppraisalTimeMs { get; internal set; } - /// /// Retail PublicWeenieDesc._cooldown_id. Positive values name a /// shared item-cooldown group whose player enchantment id is /// CooldownId + 0x8000. diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 8b75e595..0faeca46 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -774,45 +774,6 @@ public sealed class ClientObjectTable public bool UpdateProperties(uint itemId, PropertyBundle incoming) { if (!_objects.TryGetValue(itemId, out var item)) return false; - MergeProperties(item, incoming); - ApplyCooldownProperties(item, incoming); - ObjectUpdated?.Invoke(item); - return true; - } - - /// - /// Atomically retains every successful item-appraisal result: the typed - /// property tables and the per-item SpellBook block. Publishing one update - /// prevents observers from seeing properties without their matching spell - /// manifest (or the reverse). - /// - public bool UpdateAppraisal( - uint itemId, - PropertyBundle incoming, - IReadOnlyList spellIds, - double receivedAtSeconds = 0d) - { - ArgumentNullException.ThrowIfNull(incoming); - ArgumentNullException.ThrowIfNull(spellIds); - if (!_objects.TryGetValue(itemId, out var item)) return false; - MergeProperties(item, incoming); - item.AppraisedSpellIds = spellIds.Count == 0 - ? Array.Empty() - : spellIds.ToArray(); - if (double.IsFinite(receivedAtSeconds) && receivedAtSeconds >= 0d) - { - long milliseconds = checked((long)Math.Round( - receivedAtSeconds * 1000d, - MidpointRounding.AwayFromZero)); - item.LastAppraisalTimeMs = unchecked((int)milliseconds); - } - ApplyCooldownProperties(item, incoming); - ObjectUpdated?.Invoke(item); - return true; - } - - private static void MergeProperties(ClientObject item, PropertyBundle incoming) - { foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value; @@ -820,6 +781,9 @@ public sealed class ClientObjectTable foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; + ApplyCooldownProperties(item, incoming); + ObjectUpdated?.Invoke(item); + return true; } /// diff --git a/src/AcDream.Core/Items/ExternalContainerState.cs b/src/AcDream.Core/Items/ExternalContainerState.cs index 6ec69aa8..d42eebc4 100644 --- a/src/AcDream.Core/Items/ExternalContainerState.cs +++ b/src/AcDream.Core/Items/ExternalContainerState.cs @@ -24,31 +24,14 @@ public readonly record struct ExternalContainerTransition( /// public sealed class ExternalContainerState { - private readonly HashSet _openedCorpses = []; - public uint RequestedContainerId { get; private set; } public uint CurrentContainerId { get; private set; } - public int OpenedCorpseCount => _openedCorpses.Count; public event Action? Changed; - /// - /// Sets retail's requested ground object. When that object is a corpse, - /// this is also the exact SetGroundObject edge at which retail calls - /// ACCWeenieObject::SetCorpseOpened @ 0x0058E670. - /// - public bool RequestOpen(uint containerId, bool isCorpse = false) + public bool RequestOpen(uint containerId) { - if (containerId == 0u) - return false; - - // Retail marks the corpse on the SetGroundObject edge even when the - // requested ground-object id is already current. Keep that lifetime - // fact independent from whether this call changes presentation state. - if (isCorpse) - _openedCorpses.Add(containerId); - - if (RequestedContainerId == containerId) + if (containerId == 0u || RequestedContainerId == containerId) return false; uint previous = CurrentContainerId; @@ -71,19 +54,6 @@ public sealed class ExternalContainerState return true; } - /// - /// Retail ACCWeenieObject::HasCorpseBeenOpened @ 0x0058DB70. - /// The set is session-scoped and an object's delete edge removes its id. - /// - public bool HasCorpseBeenOpened(uint objectId) - => objectId != 0u && _openedCorpses.Contains(objectId); - - /// - /// Retail ACCWeenieObject::SetCorpseDeleted @ 0x0058E6C0. - /// - public bool SetCorpseDeleted(uint objectId) - => objectId != 0u && _openedCorpses.Remove(objectId); - public bool ApplyViewContents(uint containerId) { if (containerId == 0u || containerId != RequestedContainerId) @@ -128,12 +98,9 @@ public sealed class ExternalContainerState public bool Reset() { uint previous = CurrentContainerId; - bool changed = previous != 0u - || RequestedContainerId != 0u - || _openedCorpses.Count != 0; + bool changed = previous != 0u || RequestedContainerId != 0u; CurrentContainerId = 0u; RequestedContainerId = 0u; - _openedCorpses.Clear(); var transition = new ExternalContainerTransition( ExternalContainerTransitionKind.Reset, diff --git a/src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs b/src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs deleted file mode 100644 index eccb97e9..00000000 --- a/src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs +++ /dev/null @@ -1,158 +0,0 @@ -namespace AcDream.Core.Items; - -/// -/// Side-effect-free container-placement result shared by drag hover and -/// release. It ports the observable rules from -/// ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0, -/// AttemptToPlaceInContainer_IsContainerLegal @ 0x005879B0, and -/// WillItemFitInContainer @ 0x00587D60 that can be answered from the -/// client's public object projection. -/// -public enum InventoryContainerPlacementRejection -{ - None, - InvalidItem, - CannotMovePlayer, - CannotMoveCreature, - SourceBeingTraded, - InvalidDestination, - DestinationBeingTraded, - RecursiveContainment, - ItemCapacityFull, - ContainerCapacityFull, -} - -public static class InventoryContainerPlacementPolicy -{ - public static InventoryContainerPlacementRejection Evaluate( - ClientObjectTable objects, - uint itemId, - uint destinationId, - uint playerId) - { - ArgumentNullException.ThrowIfNull(objects); - if (itemId == 0u || objects.Get(itemId) is not { } item) - return InventoryContainerPlacementRejection.InvalidItem; - if (itemId == playerId) - return InventoryContainerPlacementRejection.CannotMovePlayer; - if ((item.Type & ItemType.Creature) != 0) - return InventoryContainerPlacementRejection.CannotMoveCreature; - if (item.TradeState == 1) - return InventoryContainerPlacementRejection.SourceBeingTraded; - ClientObject? destination = objects.Get(destinationId); - if (destinationId == 0u - || (destination is null && destinationId != playerId) - || (destination is not null && !IsContainer(destination) && destinationId != playerId)) - { - return InventoryContainerPlacementRejection.InvalidDestination; - } - if (destination?.TradeState == 1) - return InventoryContainerPlacementRejection.DestinationBeingTraded; - if (itemId == destinationId || IsContainedBy(objects, destinationId, itemId)) - return InventoryContainerPlacementRejection.RecursiveContainment; - - bool alreadyDirectlyContained = item.ContainerId == destinationId; - if (IsContainer(item)) - { - int capacity = destination?.ContainersCapacity ?? 0; - if (!alreadyDirectlyContained - && capacity > 0 - && CountContainers(objects, destinationId) >= capacity) - { - return InventoryContainerPlacementRejection.ContainerCapacityFull; - } - } - else - { - int capacity = destination?.ItemsCapacity ?? 0; - if (!alreadyDirectlyContained - && capacity > 0 - && CountItems(objects, destinationId) >= capacity) - { - return InventoryContainerPlacementRejection.ItemCapacityFull; - } - } - - return InventoryContainerPlacementRejection.None; - } - - public static string? ComposeClientLocal( - InventoryContainerPlacementRejection rejection, - ClientObject? item, - ClientObject? destination, - uint playerId) - { - string itemName = item?.GetAppropriateName() ?? "item"; - string destinationName = destination?.GetAppropriateName() ?? "container"; - return rejection switch - { - InventoryContainerPlacementRejection.None => null, - InventoryContainerPlacementRejection.InvalidItem => "That item is not valid!", - InventoryContainerPlacementRejection.CannotMovePlayer => - "You cannot place yourself within another object!", - InventoryContainerPlacementRejection.CannotMoveCreature => - "You cannot pick up creatures!", - InventoryContainerPlacementRejection.SourceBeingTraded => - $"The {itemName} is being traded", - InventoryContainerPlacementRejection.InvalidDestination => - "The destination container is not valid!", - InventoryContainerPlacementRejection.DestinationBeingTraded => - $"The {destinationName} is being traded", - InventoryContainerPlacementRejection.RecursiveContainment => - "You cannot place an object within itself!", - InventoryContainerPlacementRejection.ItemCapacityFull => - destination?.ObjectId == playerId - ? $"{destinationName} is completely full!" - : $"The {destinationName} is completely full!", - InventoryContainerPlacementRejection.ContainerCapacityFull => - destination?.ObjectId == playerId - ? $"{destinationName} can carry no more containers!" - : $"The {destinationName} can fit no more containers!", - _ => null, - }; - } - - public static bool IsContainer(ClientObject item) - => item.ContainerTypeHint != 0u - || (item.Type & ItemType.Container) != 0 - || item.ItemsCapacity != 0 - || item.ContainersCapacity != 0; - - private static int CountItems(ClientObjectTable objects, uint containerId) - { - int count = 0; - foreach (uint childId in objects.GetContents(containerId)) - { - if (objects.Get(childId) is { } child && !IsContainer(child)) - count++; - } - return count; - } - - private static int CountContainers(ClientObjectTable objects, uint containerId) - { - int count = 0; - foreach (uint childId in objects.GetContents(containerId)) - { - if (objects.Get(childId) is { } child && IsContainer(child)) - count++; - } - return count; - } - - private static bool IsContainedBy( - ClientObjectTable objects, - uint candidateId, - uint possibleAncestorId) - { - var visited = new HashSet(); - uint current = candidateId; - while (current != 0u && visited.Add(current)) - { - if (current == possibleAncestorId) - return true; - current = objects.Get(current)?.ContainerId ?? 0u; - } - return false; - } -} diff --git a/src/AcDream.Core/Items/InventoryTransactionState.cs b/src/AcDream.Core/Items/InventoryTransactionState.cs index 8e4c75c0..bf6ab57d 100644 --- a/src/AcDream.Core/Items/InventoryTransactionState.cs +++ b/src/AcDream.Core/Items/InventoryTransactionState.cs @@ -6,10 +6,8 @@ public enum InventoryRequestKind PutInContainer, SplitToContainer, Merge, - Move, DropToWorld, SplitToWorld, - Wield, Give, } diff --git a/src/AcDream.Core/Items/ItemInteractionPolicy.cs b/src/AcDream.Core/Items/ItemInteractionPolicy.cs index 0e4dbc29..f1bc7210 100644 --- a/src/AcDream.Core/Items/ItemInteractionPolicy.cs +++ b/src/AcDream.Core/Items/ItemInteractionPolicy.cs @@ -18,24 +18,13 @@ public enum PublicWeenieFlags : uint Stuck = 0x00000004, Player = 0x00000008, Attackable = 0x00000010, - /// PWD bit 5 — ACCWeenieObject::IsPK @0x0058C8B0. - PlayerKiller = 0x00000020, - HiddenAdmin = 0x00000040, - UiHidden = 0x00000080, - Book = 0x00000100, Vendor = 0x00000200, PlayerKillerSwitch = 0x00000400, NonPlayerKillerSwitch = 0x00000800, Door = 0x00001000, Corpse = 0x00002000, - Lifestone = 0x00004000, - Food = 0x00008000, Healer = 0x00010000, Lockpick = 0x00020000, - Portal = 0x00040000, - Admin = 0x00100000, - FreePlayerKiller = 0x00200000, - ImmuneCellRestrictions = 0x00400000, RequiresPackSlot = 0x00800000, /// /// F4 (Slice 6b/6c review): BF_RETAINED, the "unsellable" bit @@ -44,10 +33,6 @@ public enum PublicWeenieFlags : uint /// . /// Retained = 0x01000000, - /// PWD bit 0x19 (25) — ACCWeenieObject::IsPKLite @0x0058C8A0. - PlayerKillerLite = 0x02000000, - IncludesSecondHeader = 0x04000000, - Bindstone = 0x08000000, VolatileRare = 0x10000000, WieldOnUse = 0x20000000, WieldLeft = 0x40000000, @@ -90,8 +75,7 @@ public readonly record struct ItemPolicyObject( int TradeState, int StackSize, int MaxSplitSize, - bool IsIn3DView, - string Name = "item") + bool IsIn3DView) { public bool IsPlayer => (Flags & PublicWeenieFlags.Player) != 0; } @@ -261,11 +245,11 @@ public static class ItemInteractionPolicy } if (source.TradeState == 1) - return Reject($"You cannot use the {NameOf(source)} because you are trading it"); + return Reject("You cannot use an item while it is being traded."); if (source.CurrentLocation == EquipMask.None && ItemUseability.LeastLimitedSourceUse(source.Useability) == ItemUseability.Wielded) - return Reject($"You must wield the {NameOf(source)} to use it"); + return Reject("You must wield that item before you can use it."); if (ItemUseability.IsTargeted(source.Useability)) { @@ -273,9 +257,9 @@ public static class ItemInteractionPolicy return Consumed(new ItemPolicyAction(ItemPolicyActionKind.EnterTargetMode, source.Id)); if (input.SelectedTarget is not { } target) - return Reject($"Select your target before using the {NameOf(source)}"); - if (TargetCompatibilityFailure(source, target, input.PlayerId) is { } failure) - return Reject(failure); + return Reject("Select a target for this item first."); + if (!IsTargetCompatible(source, target, input.PlayerId)) + return Reject("That is not a valid target for this item."); var actions = new List { @@ -317,13 +301,13 @@ public static class ItemInteractionPolicy if (source.Id == input.PlayerId) return new ItemUsePolicyDecision(false, Array.Empty()); if ((source.Flags & PublicWeenieFlags.Door) != 0) - return Reject($"You can't open or close this {NameOf(source)} that way"); + return Reject("You cannot open or close that object right now."); if ((source.Flags & PublicWeenieFlags.Attackable) != 0 && input.InNonCombatMode) - return Reject($"To attack {NameOf(source)}, click on the dove icon first"); + return Reject("You must switch to a combat mode before attacking that target."); if ((source.Flags & PublicWeenieFlags.Attackable) == 0 || input.InNonCombatMode) - return Reject($"The {NameOf(source)} cannot be used"); + return Reject("That object cannot be used."); return new ItemUsePolicyDecision(false, Array.Empty()); } @@ -331,18 +315,9 @@ public static class ItemInteractionPolicy in ItemPolicyObject source, in ItemPolicyObject target, uint playerId) - => TargetCompatibilityFailure(source, target, playerId) is null; - - private static string? TargetCompatibilityFailure( - in ItemPolicyObject source, - in ItemPolicyObject target, - uint playerId) { if (source.TradeState == 1) - return $"You cannot use the {NameOf(source)} because you are trading it"; - - if (target.TradeState == 1) - return $"You can't use the {NameOf(source)} on an item you are trading"; + return false; uint flags = ItemUseability.TargetFlags(source.Useability); if (!target.OwnedByPlayer) @@ -351,20 +326,18 @@ public static class ItemInteractionPolicy if ((least & ItemUseability.Contained) != 0) { if (!(target.Id == playerId && (flags & ItemUseability.Self) != 0)) - return $"You can't use the {NameOf(source)} on what you don't own"; + return false; } else if ((least & ItemUseability.Wielded) != 0) { - return $"You can't use the {NameOf(source)} on what you aren't wielding"; + return false; } } if (target.Id == playerId && (flags & ItemUseability.Self) == 0) - return $"Cannot use the {NameOf(source)} on yourself"; + return false; - return (source.TargetType & (uint)target.Type) != 0 - ? null - : $"Cannot use the {NameOf(source)} with the {NameOf(target)}"; + return (source.TargetType & (uint)target.Type) != 0; } public static ItemPlacementPolicyDecision DecidePlacement( @@ -376,10 +349,9 @@ public static class ItemInteractionPolicy if (input.TargetId == input.PlayerId) return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInBackpack, input.Item.Id)); if (!input.Item.OwnedByPlayer) - return Placement(false, RejectAction($"You must first pick up the {NameOf(input.Item)}")); + return Placement(false, RejectAction("You must first pick up that item.")); if (input.Item.TradeState != 0) - return Placement(false, RejectAction( - $"You are trading the {NameOf(input.Item)}, it cannot be dropped")); + return Placement(false, RejectAction("You cannot move an item while it is being traded.")); if (input.TargetId == 0) return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false); @@ -394,7 +366,7 @@ public static class ItemInteractionPolicy if (input.SplitSize >= input.Item.MaxSplitSize) return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.SellToVendor, input.Item.Id, target.Id, input.SplitSize)); - return Placement(false, RejectAction("You must split the stack before selling it.")); + return Placement(false, RejectAction("Split the stack before selling part of it.")); } if (input.DragOnPlayerOpensSecureTrade && target.IsPlayer) @@ -408,17 +380,16 @@ public static class ItemInteractionPolicy if (target.IsContainer) { if ((target.Flags & PublicWeenieFlags.Openable) == 0) - return Placement(false, RejectAction($"The {NameOf(target)} is locked")); + return Placement(false, RejectAction("That container is locked.")); if (target.Id != input.GroundObjectId) - return Placement(false, RejectAction($"You must open the {NameOf(target)} first")); + return Placement(false, RejectAction("You must open that container first.")); return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInContainer, input.Item.Id, target.Id, input.SplitSize)); } if (input.AllowGroundFallback) return PlaceOnGround(input); - return Placement(false, RejectAction( - $"Cannot give {NameOf(input.Item)} to {NameOf(target)}")); + return Placement(false, RejectAction("You cannot give that item to this target.")); } private static IReadOnlyList BuildUsingItemActions( @@ -461,18 +432,15 @@ public static class ItemInteractionPolicy in ItemPlacementPolicyInput input) { if (!input.PlayerOnGround) - return Placement(false, RejectAction("You cannot do that in mid air")); + return Placement(false, RejectAction("You cannot do that in mid air.")); if (input.SplitSize < input.Item.MaxSplitSize) return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.SplitToWorld, input.Item.Id, Amount: input.SplitSize)); if (!input.Item.IsIn3DView) return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.DropToWorld, input.Item.Id)); - return Placement(false, RejectAction("Move cancelled")); + return Placement(false, RejectAction("Move cancelled.")); } - private static string NameOf(in ItemPolicyObject item) - => string.IsNullOrWhiteSpace(item.Name) ? "item" : item.Name; - private static ItemUsePolicyDecision Consumed(params ItemPolicyAction[] actions) => new(true, actions); diff --git a/src/AcDream.Core/Items/VendorDiagnostics.cs b/src/AcDream.Core/Items/VendorDiagnostics.cs new file mode 100644 index 00000000..9e8d058e --- /dev/null +++ b/src/AcDream.Core/Items/VendorDiagnostics.cs @@ -0,0 +1,27 @@ +using System; + +namespace AcDream.Core.Items; + +/// +/// TEMPORARY diagnostic-probe owner for two live-only regressions that have +/// each survived two green-tested fixes: Chain A (a far-click walk-to-use +/// approaches a vendor, the vendor plays its cosmetic greeting, but the shop +/// window never opens) and Chain B (selecting a splittable vendor stack +/// shows the bare item name with no quantity slider). Every probe line is +/// prefixed [vendor-diag] and gated on +/// so the family is a true no-op when the flag is unset. +/// +/// +/// Read once from ACDREAM_DUMP_VENDOR=1 at process start, per Code +/// Structure Rule 5 (one static diagnostic-owner class per subsystem, no +/// per-call-site reads). +/// Lives in AcDream.Core — the one project every call site (App, +/// Core.Net, Runtime) already references — so a single flag instance is +/// shared across the whole probe family regardless of which layer observes +/// it first. +/// +public static class VendorDiagnostics +{ + public static bool DumpVendorEnabled { get; } = + Environment.GetEnvironmentVariable("ACDREAM_DUMP_VENDOR") == "1"; +} diff --git a/src/AcDream.Core/Items/VendorStagingList.cs b/src/AcDream.Core/Items/VendorStagingList.cs index 1d1af85f..d89f102f 100644 --- a/src/AcDream.Core/Items/VendorStagingList.cs +++ b/src/AcDream.Core/Items/VendorStagingList.cs @@ -127,27 +127,6 @@ public sealed class VendorStagingList return true; } - /// - /// Replaces retail's temporary pre-split sell-row identity with the - /// server-created split stack while preserving the row's position and - /// selected quantity. VendorSellUI::ItemAttributesChanged - /// performs the same in-place substitution after matching the new - /// object's class id and stack size. - /// - public bool Replace(uint itemGuid, uint replacementGuid) - { - if (itemGuid == 0u || replacementGuid == 0u || itemGuid == replacementGuid) - return false; - - int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid); - if (index < 0 || _entries.Exists(entry => entry.ItemGuid == replacementGuid)) - return false; - - _entries[index] = _entries[index] with { ItemGuid = replacementGuid }; - Changed?.Invoke(); - return true; - } - /// Port of the unconditional PackableList<ItemProfile>::Flush calls /// ("Clear List" buttons, and the optimistic post-send clear both Buy All and Sell All /// perform immediately after their wire send — see the batched-send call sites). diff --git a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs index b9e958df..d1bdf41f 100644 --- a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs +++ b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs @@ -34,6 +34,12 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink public bool ApplyMotion(uint motion, float speed) { uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed)); + // Bug A probe ([remote-landing-after], ACDREAM_PROBE_REMOTE_LANDING): + // the MotionTableManagerError code is discarded by this bool return, + // so hand it to the diagnostic latch before it is lost. Self-guarded + // — one flag test when the probe is off, no behaviour change either + // way. TEMPORARY, strips with the rest of the probe family. + PhysicsDiagnostics.RecordRemoteLandingDispatch(motion, result); return result == MotionTableManagerError.Success; } diff --git a/src/AcDream.Core/Physics/Motion/StickyManager.cs b/src/AcDream.Core/Physics/Motion/StickyManager.cs index d0794f8f..1ca0308b 100644 --- a/src/AcDream.Core/Physics/Motion/StickyManager.cs +++ b/src/AcDream.Core/Physics/Motion/StickyManager.cs @@ -82,6 +82,10 @@ public sealed class StickyManager if (TargetId == 0) return; + if (PhysicsDiagnostics.ProbeStickyEnabled) + Console.WriteLine(FormattableString.Invariant( + $"[sticky] guid=0x{_host.Id:X8} UNSTICK target=0x{TargetId:X8}")); + TargetId = 0; Initialized = false; _host.ClearTarget(); @@ -121,6 +125,10 @@ public sealed class StickyManager Initialized = false; StickyTimeoutTime = _host.CurTime + StickyTime; + if (PhysicsDiagnostics.ProbeStickyEnabled) + Console.WriteLine(FormattableString.Invariant( + $"[sticky] guid=0x{_host.Id:X8} STICK target=0x{objectId:X8} tgtR={targetRadius:F2} ownR={_host.Radius:F2} lease={StickyTime:F1}s")); + // set_target(context_id=0, objectId, radius=0.5, quantum=0.5). _host.SetTarget(0, objectId, 0.5f, 0.5); } @@ -141,6 +149,10 @@ public sealed class StickyManager // C0|C3 clear = cur_time > timeout; ACE `>` too), not >=. if (_host.CurTime > StickyTimeoutTime) { + if (PhysicsDiagnostics.ProbeStickyEnabled) + Console.WriteLine(FormattableString.Invariant( + $"[sticky] guid=0x{_host.Id:X8} LEASE-EXPIRE target=0x{TargetId:X8}")); + TargetId = 0; Initialized = false; _host.ClearTarget(); @@ -171,6 +183,10 @@ public sealed class StickyManager if (TargetId != 0) { + if (PhysicsDiagnostics.ProbeStickyEnabled) + Console.WriteLine(FormattableString.Invariant( + $"[sticky] guid=0x{_host.Id:X8} TARGET-{info.Status} teardown target=0x{TargetId:X8}")); + TargetId = 0; Initialized = false; _host.ClearTarget(); @@ -252,5 +268,9 @@ public sealed class StickyManager if (heading < -MoveToMath.Epsilon) heading += 360f; offset.SetHeading(heading); + + if (PhysicsDiagnostics.ProbeStickyEnabled) + Console.WriteLine(FormattableString.Invariant( + $"[sticky] guid=0x{_host.Id:X8} ADJ dist={dist:F3} delta={delta:F3} speed={speed:F1} hdgDelta={heading:F1} live={(target is not null ? 1 : 0)}")); } } diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index df221885..5aed4aec 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -2269,9 +2269,8 @@ public sealed class MotionInterpreter : IMotionDoneSink if (PhysicsObj is null) return false; - const TransientStateFlags groundedMask = - TransientStateFlags.Contact | TransientStateFlags.OnWalkable; - bool grounded = (PhysicsObj.TransientState & groundedMask) == groundedMask; + bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact) + && PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable); if (!grounded) return false; diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index f25afcf9..af4f3e87 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -650,6 +650,23 @@ public sealed class PhysicsDataCache $"[cell-cache] envCellId=0x{envCellId:X8} physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} resolvedCount={resolved.Count} bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} {bsStr} portalCount={portals.Count} visibleCells={visibleCellIds.Count} cellBspRoot={(cellStruct.CellBSP?.Root is null ? "null" : "ok")} worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2}) {portalTargets}")); } + if (PhysicsDiagnostics.ProbeWalkMissEnabled) + { + int walkableCount = 0; + foreach (var entry in WalkMissDiagnostic.EnumerateWalkable( + resolved, PhysicsGlobals.FloorZ)) + walkableCount++; + + Console.Write(System.FormattableString.Invariant( + $"[floor-polys] cellId=0x{envCellId:X8} walkableCount={walkableCount}")); + foreach (var entry in WalkMissDiagnostic.EnumerateWalkable( + resolved, PhysicsGlobals.FloorZ)) + { + Console.Write(System.FormattableString.Invariant( + $" [id=0x{entry.PolyId:X4} nz={entry.NormalZ:F3} bbox=({entry.BboxMin.X:F2},{entry.BboxMin.Y:F2})..({entry.BboxMax.X:F2},{entry.BboxMax.Y:F2}) planeZ@center={entry.PlaneZAtBboxCenter:F3}]")); + } + Console.WriteLine(); + } } /// diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index cc8a9231..7af14853 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -6,12 +6,10 @@ namespace AcDream.Core.Physics; /// /// L.2a slice 1 (2026-05-12) — runtime-toggleable physics probe flags. -/// Initialized from env vars at process start; flippable at runtime by -/// direct assignment. Log call sites read these statics so a change takes -/// effect on the next resolve without relaunching. (#434: these flags used -/// to have a DebugPanel checkbox mirror. That panel has been unreachable -/// since Campaign V slice V11 removed its ImGui host, so every flag here is -/// startup-or-assignment only.) +/// Initialized from env vars at process start; flippable at runtime via +/// the DebugPanel mirror (or by direct assignment). Log call sites read +/// these statics so a checkbox toggle takes effect on the next resolve +/// without relaunching. /// /// /// L.2d slice 1 (2026-05-13) adds + @@ -159,7 +157,7 @@ public static class PhysicsDiagnostics /// /// /// Initial state from ACDREAM_PROBE_BUILDING=1. Mirrorable - /// by direct assignment (its DebugVM mirror is gone — #434). + /// via DebugVM.ProbeBuilding when ACDREAM_DEVTOOLS=1. /// /// /// @@ -180,6 +178,60 @@ public static class PhysicsDiagnostics public static bool ProbeCellSetEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELLSET") == "1"; + /// + /// R5-V3 #171 residuals (2026-07-04) — sticky-melee timeline probe. + /// One [sticky] line per StickyManager lifecycle event (STICK / + /// UNSTICK / LEASE-EXPIRE / TARGET-status teardown) and per armed + /// AdjustOffset tick (guid, signed gap distance, applied delta, + /// heading delta), plus [sticky-snap-skip] lines at the NPC + /// UpdatePosition handler when a server hard-snap is suppressed because + /// the entity is stuck. Heavy while a pack is stuck (~60 Hz × stuck + /// count); capture-session only. All lines carry the guid + /// (feedback_probe_identity_attribution). + /// + public static bool ProbeStickyEnabled { get; set; } + = Environment.GetEnvironmentVariable("ACDREAM_PROBE_STICKY") == "1"; + + /// + /// Bug A investigation (2026-08-04, live route 4a test — see + /// docs/ISSUES.md #32 and + /// docs/research/2026-08-04-remote-landing-investigation.md): a + /// PLAYER remote sometimes stays in the falling animation after landing, + /// then snaps to the grounded pose after a delay. Three hypotheses were + /// identified, with non-overlapping fixes, so this probe captures the + /// state needed to discriminate them at BOTH remote landing-detection + /// sites: the UpdatePosition landing block in + /// LiveEntityNetworkUpdateController (site=controller) and + /// the per-tick VectorUpdate landing branch in + /// RuntimeRemotePhysicsUpdater (site=per-tick). + /// + /// + /// When true, emits one [remote-landing] line per landing edge via + /// , capturing: the airborne flag on entry, + /// whether the Gravity state bit is still set (the + /// MotionInterpreter.HitGround gate at + /// MotionInterpreter.cs:~2435 no-ops silently when it is NOT — + /// hypothesis 1), the Contact/OnWalkable transient bits, whether a + /// DefaultSink is bound (hypothesis 2 — nothing to dispatch + /// through), the per-tick site's resolveResult.IsOnGround (n/a at + /// the controller site, which has no resolver call), and the + /// sequencer's current style/motion id (hypothesis 3 — the sequencer + /// disagrees with what the re-apply should produce). A companion + /// [remote-landing-gate] line fires via + /// whenever a landing site is + /// reached but Gravity is already clear, so the HitGround call about to + /// happen is a silent no-op — the single most valuable signal for + /// hypothesis 1. + /// + /// + /// + /// Initial state from ACDREAM_PROBE_REMOTE_LANDING=1. TEMPORARY — + /// strip once the discriminating live-test capture has landed. + /// + /// + public static bool ProbeRemoteLandingEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_REMOTE_LANDING") == "1"; + /// /// C4 route 4b-3 (2026-08-04) live-execution proof (process rule 5): one /// [remote-teleport] line per routed teleport arm @@ -213,6 +265,143 @@ public static class PhysicsDiagnostics $"[remote-teleport] guid=0x{guid:X8} cause={cause} hookRan={hookRan} placement={placementStatus}")); } + /// + /// Emit one [remote-landing] line for a remote landing-detection + /// edge. Caller MUST guard with + /// if (!ProbeRemoteLandingEnabled) return; before calling. + /// is at the + /// controller site (no per-frame resolver call at that edge). + /// + public static void LogRemoteLanding( + string site, + uint guid, + bool airborneBefore, + bool gravitySet, + bool contact, + bool onWalkable, + bool hasDefaultSink, + bool? resolveIsOnGround, + uint sequencerStyle, + uint sequencerMotion) + { + var ci = System.Globalization.CultureInfo.InvariantCulture; + string onGroundText = resolveIsOnGround.HasValue + ? resolveIsOnGround.Value.ToString() + : "n/a"; + Console.WriteLine(string.Format(ci, + "[remote-landing] site={0} guid=0x{1:X8} t={2} airborneBefore={3} " + + "gravitySet={4} contact={5} onWalkable={6} hasDefaultSink={7} " + + "resolveIsOnGround={8} seqStyle=0x{9:X8} seqMotion=0x{10:X8}", + site, guid, Environment.TickCount64, airborneBefore, + gravitySet, contact, onWalkable, hasDefaultSink, onGroundText, + sequencerStyle, sequencerMotion)); + } + + /// + /// Emit one [remote-landing-gate] line when a landing edge is + /// reached but the Gravity state bit is already clear, so the imminent + /// MotionInterpreter.HitGround call will silently no-op (the + /// gate at MotionInterpreter.cs:~2435) — hypothesis 1 for Bug A. + /// Caller MUST guard with + /// if (!ProbeRemoteLandingEnabled) return; before calling. + /// + public static void LogRemoteLandingGateNoOp(string site, uint guid) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[remote-landing-gate] site={site} guid=0x{guid:X8} t={Environment.TickCount64} NOOP gravityAlreadyClear=true")); + } + + // ── [remote-landing-after] — the OUTCOME half of the Bug A probe ────── + // + // docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md §6.1: the + // [remote-landing] line above reads state immediately BEFORE + // MovementManager.HitGround, so it cannot separate (a) the edge never + // firing, from (b) HitGround firing and something re-asserting Falling, + // from (c) the motion-table sink refusing the cycle. The companion line + // below reads the same entity immediately AFTER the call, at the same + // two sites, and pairs 1:1 with it (same site + guid, next line for + // that guid). + // + // Dispatch capture: MotionTableDispatchSink.ApplyMotion discards the + // MotionTableManagerError code (it returns bool) and HitGround itself + // returns void, so nothing at the call site can observe what the sink + // did. These [ThreadStatic] latches carry it across the synchronous + // HitGround call without changing any signature: the call site calls + // BeginRemoteLandingDispatchCapture() right before HitGround, the sink + // records each ApplyMotion, and LogRemoteLandingAfter reports the count + // plus the LAST ApplyMotion — which for the landing re-apply + // (ApplyInterpretedMovement, MotionInterpreter.cs:2842-2903) is the + // decisive one: either Falling (:2867) or InterpretedState.ForwardCommand + // (:2878). Thread-static because the whole window is synchronous on the + // ticking thread, and headless hosts tick several sessions in parallel. + // + // Every member here is inert unless ProbeRemoteLandingEnabled is true. + // TEMPORARY — strip with the rest of the ACDREAM_PROBE_REMOTE_LANDING + // family once the discriminating live capture has landed. + + [ThreadStatic] private static int _remoteLandingApplyCalls; + [ThreadStatic] private static uint _remoteLandingLastApplyMotion; + [ThreadStatic] private static uint _remoteLandingLastApplyResult; + + /// + /// Arm the per-call sink-dispatch capture read back by + /// . Call immediately before + /// MovementManager.HitGround. No-op unless + /// . + /// + public static void BeginRemoteLandingDispatchCapture() + { + if (!ProbeRemoteLandingEnabled) return; + _remoteLandingApplyCalls = 0; + _remoteLandingLastApplyMotion = 0; + _remoteLandingLastApplyResult = 0; + } + + /// + /// Record one IInterpretedMotionSink.ApplyMotion dispatch and its + /// raw MotionTableManagerError code. Called by + /// ; self-guarded, so it is + /// a single flag test when the probe is off. + /// + public static void RecordRemoteLandingDispatch(uint motion, uint result) + { + if (!ProbeRemoteLandingEnabled) return; + _remoteLandingApplyCalls++; + _remoteLandingLastApplyMotion = motion; + _remoteLandingLastApplyResult = result; + } + + /// + /// Emit one [remote-landing-after] line for the landing edge whose + /// [remote-landing] line was just written. Caller MUST guard with + /// if (!ProbeRemoteLandingEnabled) return; before calling, and MUST + /// emit it before any post-HitGround ownership re-check can return — a + /// before-line with no after-line therefore means the call site threw. + /// is if a + /// gate short-circuited between the two lines (no such gate exists at + /// either site today; the field exists so the absence is stated rather + /// than inferred from a missing line). + /// + public static void LogRemoteLandingAfter( + string site, + uint guid, + bool hitGroundInvoked, + uint sequencerStyle, + uint sequencerMotion, + uint forwardCommand) + { + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[remote-landing-after] site={0} guid=0x{1:X8} t={2} " + + "hitGroundInvoked={3} seqStyle=0x{4:X8} seqMotion=0x{5:X8} " + + "fwdCmd=0x{6:X8} sinkApplyCalls={7} sinkLastMotion=0x{8:X8} " + + "sinkLastResult=0x{9:X8}", + site, guid, Environment.TickCount64, + hitGroundInvoked, sequencerStyle, sequencerMotion, + forwardCommand, _remoteLandingApplyCalls, + _remoteLandingLastApplyMotion, _remoteLandingLastApplyResult)); + } + // ── [remote-slide-*] — Bug B (remote ledge/roof slide) capture ──────── // // docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md §2 names TWO @@ -660,6 +849,32 @@ public static class PhysicsDiagnostics /// public static ResolvedPolygon? LastBspHitPoly { get; set; } + /// + /// B.6 slice 1 (2026-05-14) — baseline trace for the local-player + /// server-initiated auto-walk path (issue #63). When true, the + /// following events emit one-line [autowalk-*] logs: + /// + /// [autowalk-out] on every SendUse + /// / SendPickUp the local player issues — these are the + /// packets that may trigger ACE's server-side CreateMoveToChain + /// when the target is out of WithinUseRadius. + /// [autowalk-mt] on every inbound + /// UpdateMotion for the local player — captures the + /// MovementType + MoveToPath + speed/runRate ACE sends. + /// [autowalk-up] on every inbound + /// UpdatePosition for the local player — answers "what's + /// ACE's broadcast cadence during auto-walk?" + /// + /// Initial state from ACDREAM_PROBE_AUTOWALK=1. + /// + /// + /// Spec: docs/superpowers/specs/2026-05-14-phase-b6-design.md + /// §"Required investigation". + /// + /// + public static bool ProbeAutoWalkEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_AUTOWALK") == "1"; + /// /// 2026-05-16. Logs one line per `IsUseableTarget` call that takes /// the null-useability fallback path (creature pass / BF_DOOR pass / @@ -676,7 +891,7 @@ public static class PhysicsDiagnostics /// /// /// Toggle via env var ACDREAM_PROBE_USEABILITY_FALLBACK=1 - /// by direct assignment. + /// or DebugPanel checkbox. /// public static bool ProbeUseabilityFallbackEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_USEABILITY_FALLBACK") == "1"; @@ -700,7 +915,7 @@ public static class PhysicsDiagnostics /// the post-collision velocity disagrees with retail. /// /// Initial state from ACDREAM_DUMP_STEEP_ROOF=1. Runtime-toggleable - /// via the property setter (open + /// via the property setter; not yet wired to a DebugPanel checkbox (open /// follow-up if a debugging session calls for it). /// public static bool DumpSteepRoofEnabled { get; set; } = @@ -725,7 +940,7 @@ public static class PhysicsDiagnostics /// /// /// Initial state from ACDREAM_PROBE_INDOOR_BSP=1. - /// Runtime-toggleable by direct assignment. + /// Runtime-toggleable via DebugPanel. /// /// /// @@ -752,7 +967,7 @@ public static class PhysicsDiagnostics /// zero poly refs, candidate (b)/(d). /// /// This diagnostic fires at most once per EnvCell (cache is no-op after - /// first population). This is + /// first population). It does NOT have a DebugPanel mirror yet — this is /// a one-shot capture tool, not a persistent toggle. Promote to full /// infrastructure after the root cause is identified. /// @@ -781,6 +996,39 @@ public static class PhysicsDiagnostics public static bool ProbeContactPlaneEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_CONTACT_PLANE") == "1"; + /// + /// Indoor walking ISSUES #83 H-disambiguation spike (2026-05-21). + /// When true, two diagnostic emissions activate: + /// + /// One [walk-miss] line per + /// MISS + /// event, dumping foot world/local position, the nearest + /// walkable polygon in the cell (with XY-containment flag and + /// vertical gap), and whether the LandCell terrain at the same + /// XY would have grounded the player. + /// One [floor-polys] line per indoor + /// cell cached, enumerating each walkable-eligible polygon's + /// id, normal Z, local-XY bounding box, and plane Z at the + /// bbox center. + /// + /// Together these answer H1 (multi-cell iteration missing) vs H2 + /// (probe distance too short) vs H3 (poly absent / + /// walkable_hits_sphere rejection) for the ISSUES #83 + /// stuck-falling bug. Spike-only — remove once the root cause is + /// identified and the fix lands. + /// + /// + /// Initial state from ACDREAM_PROBE_WALK_MISS=1. + /// No DebugPanel mirror — one-shot diagnostic. + /// + /// + /// + /// Spec: docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md. + /// + /// + public static bool ProbeWalkMissEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_WALK_MISS") == "1"; + /// /// Phase A6.P1 cdb probe spike (2026-05-21). When true, every BSP /// collision response site emits a structured [push-back] line: @@ -799,7 +1047,7 @@ public static class PhysicsDiagnostics /// /// /// Initial state from ACDREAM_PROBE_PUSH_BACK=1. - /// Runtime-toggleable by direct assignment. + /// Runtime-toggleable via DebugVM mirror. /// /// /// @@ -898,6 +1146,294 @@ public static class PhysicsDiagnostics public static bool ProbeSweptEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_SWEPT") == "1"; + // ----------------------------------------------------------------------- + // #334 broadphase / candidate-disposition probe — TEMPORARY (2026-08-06) + // + // STRIP THIS WHOLE REGION together with the rest of the physics probe + // family once #334 is scored. + // + // #334 is "large static formations can be walked through on flat ground, + // and jumping over them drops you inside". Three candidate mechanisms + // survive the report, and this probe exists to DISCRIMINATE them, not to + // confirm any one of them: + // + // (a) the object IS a candidate in the cell but the broadphase reach + // filter rejects it before its BSP is consulted (AP-158 / #333). + // CONFIRMED and CLOSED 2026-08-06: this was the cause of #337, and + // the filter is now DELETED — retail has none. `rejectedReach` is + // retained as a structurally-zero column so a post-fix capture is + // directly comparable with the pre-fix one that recorded 7,225 + // rejections on one owner, every single one with + // wouldAcceptAtCenter=True; + // (b) the object is NOT in the cell's candidate set at all — a + // membership / registration failure (AP-156's territory, which did + // not fix this, or the static publication path never registered it); + // (c) the object IS a candidate and IS NOT rejected, but resolves to no + // usable shape — an empty shape list or an unresolved physics BSP. + // + // Context for (a): the filter measures |currPos - obj.Position| — the part + // ORIGIN — against obj.Radius (the physics-BSP ROOT sphere radius) plus a + // 2 m acdream-invented slack. The BSP root sphere's CENTRE is frequently + // NOT the part origin (376 of 973 installed physics-BSP parts sit further + // from it than half their own radius; worst 20.762 m). Where that offset + // exceeds the slack, geometry well inside the sphere is rejected. So every + // candidate line carries BOTH distances and the decisive + // wouldAcceptAtCenter boolean. + // + // Context for (c): AP-152 (4abd1b5e) made us emit BSP shapes exclusively + // where primitives were also emitted before. That did NOT cause #334 (the + // user reproduced on a pre-AP-152 build), but the same failure mode can + // exist independently, so no-shape is a first-class disposition here. + // ----------------------------------------------------------------------- + + /// + /// #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with + /// the physics-probe family). Emits two line types from + /// Transition.FindObjCollisionsInCell: + /// + /// + /// [reach-q] — one per-cell query summary: the number of + /// shadow entries the cell yielded and the per-disposition tallies. + /// It is emitted even when the cell yields zero entries, which is + /// what makes outcome (b) visible: "cell yielded 0" at a spot where a + /// formation is plainly in front of the player is a registration gap, + /// and is recorded as data rather than as silence. Without this line an + /// absence of rejection lines would be ambiguous between "nothing was + /// rejected" and "nothing was there", which is precisely the + /// unfalsifiable-criterion trap this campaign has already been bitten + /// by. + /// [reach-obj] — one per candidate, carrying its identity + /// (mover guid, target entity id, GfxObj id, cell) and its + /// disposition: exempt-self, exempt-missile, + /// exempt-rule + /// (rejected-reach is retired — #333 deleted the filter that + /// produced it), + /// exempt-ethereal-stepdown, no-shape, + /// bsp-only-skip, or tested:<result>. For BSP + /// candidates it also carries the origin-measured distance the filter + /// used, the centre-measured distance it should have used, the budget, + /// the shortfall, and wouldAcceptAtCenter. + /// + /// + /// + /// Volume control (this site is hot — it runs per cell per transitional + /// insert, and one resolve performs many inserts). [reach-obj] is + /// de-duplicated per (mover, target, cell) and re-emits immediately + /// whenever the disposition changes or the shortfall crosses a 0.5 m + /// bucket, and otherwise at most once per second. [reach-q] is + /// de-duplicated per (mover, cell) on the full tally tuple, so any change + /// in what the cell yielded emits at once, and otherwise at most twice a + /// second. Both therefore emit eagerly on change — which is exactly when + /// the player walks into the formation — and stay quiet when nothing is + /// happening. Nothing is aggregated away: every distinct state the query + /// passes through appears. + /// + /// + /// + /// Initial state from ACDREAM_PROBE_REACH=1. Zero cost when off + /// (one static bool read per query). + /// + /// + public static bool ProbeReachEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_REACH") == "1"; + + private static readonly object _reachGate = new(); + private static readonly Dictionary<(uint Mover, uint Entity, uint Cell), (long Ms, string Disp, int Bucket)> + _reachSeenObj = new(); + private static readonly Dictionary<(uint Mover, uint Cell), (long Ms, long Tally)> + _reachSeenQuery = new(); + + /// + /// One [reach-obj] line. Self-guards on + /// . + /// + /// The moving entity's guid — never omitted; a + /// per-entity probe without an identity produced a wrong root cause once + /// already (feedback_probe_identity_attribution). + /// What happened to this candidate. Use the + /// documented vocabulary on . + /// What the reach filter measured: the distance + /// from the swept sphere's current centre to the target's part ORIGIN. + /// Negative when not applicable. + /// What it should have measured: the distance to + /// the target's physics-BSP root sphere CENTRE. Negative when not + /// applicable. + /// What the deleted filter's admission threshold WOULD + /// have been, 2 m slack included. Since #333 no live predicate reads it; it + /// is kept so the acceptance capture shows which candidates the old filter + /// would have thrown away. + /// The same threshold WITHOUT the slack — the + /// honest conservative bound once the real centre is used. + public static void LogReachCandidate( + uint moverId, + uint entityId, + uint gfxObjId, + uint cellId, + ShadowCollisionType shape, + string disposition, + bool stepDown, + float distOrigin, + float distCenter, + float objRadius, + float sphereRadius, + float movementLen, + float budget, + float centerBudget, + Vector3 objPos, + Vector3 bspCentreOffset, + Vector3 currPos) + { + if (!ProbeReachEnabled) return; + + float shortfall = distOrigin - budget; + bool wouldAcceptAtCenter = distCenter >= 0f && distCenter <= centerBudget; + int bucket = distOrigin < 0f ? 0 : (int)MathF.Floor(shortfall / 0.5f); + long now = Environment.TickCount64; + + lock (_reachGate) + { + var key = (moverId, entityId, cellId); + if (_reachSeenObj.TryGetValue(key, out var prev) + && string.Equals(prev.Disp, disposition, StringComparison.Ordinal) + && prev.Bucket == bucket + && now - prev.Ms < 1000) + { + return; + } + _reachSeenObj[key] = (now, disposition, bucket); + } + + Console.WriteLine(string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "[reach-obj] mover=0x{0:X8} obj=0x{1:X8} gfx=0x{2:X8} cell=0x{3:X8} " + + "disp={4} shape={5} stepDown={6} distOrigin={7:F3} distCenter={8:F3} " + + "objR={9:F3} sphereR={10:F3} move={11:F3} budget={12:F3} " + + "centerBudget={13:F3} shortfall={14:F3} wouldAcceptAtCenter={15} " + + "objPos=({16:F2},{17:F2},{18:F2}) " + + "bspCentreOffset=({19:F2},{20:F2},{21:F2}) |bspCentreOffset|={22:F3} " + + "currPos=({23:F2},{24:F2},{25:F2}) t={26}", + moverId, entityId, gfxObjId, cellId, + disposition, shape, stepDown, distOrigin, distCenter, + objRadius, sphereRadius, movementLen, budget, + centerBudget, shortfall, wouldAcceptAtCenter, + objPos.X, objPos.Y, objPos.Z, + bspCentreOffset.X, bspCentreOffset.Y, bspCentreOffset.Z, bspCentreOffset.Length(), + currPos.X, currPos.Y, currPos.Z, now)); + } + + /// + /// One [reach-q] per-cell query summary. MUST be called even when + /// the cell yields zero entries — that is the whole point of the line. + /// Self-guards on . + /// + /// Shadow entries the cell yielded, before any + /// exemption. Zero here at a spot with visible geometry is outcome (b). + /// Candidates that survived the exemptions and went + /// on to a shape dispatch. Before #333 this was "and were measured by the + /// reach filter"; the filter is gone, so the two are now the same set. + /// Of those, how many the reach filter + /// rejected — outcome (a). Structurally 0 since #333 deleted the + /// filter, and retained precisely so that a post-fix capture reading 0 + /// is comparable against the pre-fix capture that read 7,225. + /// Candidates that passed the filter but resolved to + /// no usable shape — outcome (c). + /// Candidates that actually reached a shape test. + public static void LogReachQuery( + uint moverId, + uint cellId, + bool stepDown, + int inCell, + int exempt, + int reached, + int rejectedReach, + int noShape, + int tested, + int blocked, + Vector3 currPos) + { + if (!ProbeReachEnabled) return; + + // Tally fingerprint: any change in what this cell yielded re-emits at + // once. Deliberately includes every counter, so a state the query + // passes through cannot be swallowed by the throttle. + long tally = (((long)inCell * 31 + exempt) * 31 + reached) * 31; + tally = ((tally + rejectedReach) * 31 + noShape) * 31; + tally = ((tally + tested) * 31 + blocked) * 31 + (stepDown ? 1 : 0); + + long now = Environment.TickCount64; + lock (_reachGate) + { + var key = (moverId, cellId); + if (_reachSeenQuery.TryGetValue(key, out var prev) + && prev.Tally == tally + && now - prev.Ms < 500) + { + return; + } + _reachSeenQuery[key] = (now, tally); + } + + Console.WriteLine(string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "[reach-q] mover=0x{0:X8} cell=0x{1:X8} stepDown={2} inCell={3} " + + "exempt={4} reached={5} rejectedReach={6} noShape={7} tested={8} " + + "blocked={9} pos=({10:F2},{11:F2},{12:F2}) t={13}", + moverId, cellId, stepDown, inCell, + exempt, reached, rejectedReach, noShape, tested, + blocked, currPos.X, currPos.Y, currPos.Z, now)); + } + + // ----------------------------------------------------------------------- + // [support] / [geom] — #337 "what is holding this body up, and is the + // collision geometry where the visual geometry is?" (2026-08-06 — + // TEMPORARY, strip with the physics-probe family). + // + // WHY A NEW FAMILY RATHER THAN MORE [resolve]. + // ACDREAM_PROBE_RESOLVE already prints, per resolve: in/target/out + // position + cell, ok, groundedIn, a THREE-VALUE contact-plane token + // (valid / lastKnown / none), the collision normal + responsible entity if + // something was hit, and one walkable-polygon bool. That is enough to say + // THAT the body stopped. It cannot say WHAT held it up, because it prints + // no plane normal, no plane height, no terrain sample, and no attribution + // for who wrote the plane. So on a "wedged on a rock" capture, (a) terrain + // holding the body, (b) an object surface holding it somewhere other than + // where the rock is drawn, and (c) an unobstructed transition that simply + // fails to advance all produce the SAME [resolve] line. Two diagnoses this + // campaign have already been refuted by measurement; a probe that cannot + // separate the remaining three is not worth the launch. + // + // WHAT SEPARATES THEM. + // [support] — per resolve, per body (players AND corpses/NPCs, which is + // what makes the fall-through case observable at all). It samples the + // OUTDOOR TERRAIN independently at the body's own out-XY and prints + // the contact plane's own height at that same XY. Two independent + // heights at one point: + // cpZ@out == terrZ → terrain is the support, whatever set it. + // cpZ@out >> terrZ → an object surface is the support. + // cpValid=false → nothing is; the body is in free fall. + // `cpSrc` names the code site that wrote the plane, so the classifier + // and the provenance are cross-checkable rather than one inferring + // the other. + // [geom] — once per GfxObj that comes near the mover. Compares the + // object's PHYSICS BSP vertex cloud against its VISUAL mesh AABB in + // the same local frame. If the collision geometry is absent, empty, + // displaced, or the wrong size, this line says so directly. That is + // the working hypothesis's refutation test: `verdict=coincident` + // kills "the collision isn't where the visual is" outright, and no + // amount of movement-side evidence is then needed to rule it out. + // + // Neither line gates, orders, or mutates anything. Both are pure reads. + // ----------------------------------------------------------------------- + + /// + /// Initial state from ACDREAM_PROBE_SUPPORT=1. Enables the + /// [support] and [geom] lines described above. Zero cost when + /// off (one static bool read per resolve and per collision candidate). + /// TEMPORARY — strip with the rest of the physics-probe family. + /// + public static bool ProbeSupportEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_SUPPORT") == "1"; + /// /// Initial state from ACDREAM_PROBE_JUMP=1. Campaign CH user-gate /// round 2, item 1 (jump-in-air refusal reported STILL silent live after @@ -911,6 +1447,500 @@ public static class PhysicsDiagnostics public static bool ProbeJumpEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_JUMP") == "1"; + /// Vertical agreement window, in metres, inside which the contact + /// plane's height and the terrain's height at the same XY are called the + /// same surface. + public const float SupportSameSurfaceZ = 0.05f; + + /// Straight-up-component agreement window inside which the contact + /// plane's tilt and the terrain triangle's tilt are called the same + /// surface. 0.02 is roughly 1 degree near flat. + public const float SupportSameSurfaceNormalZ = 0.02f; + + private static readonly object _supportGate = new(); + private static readonly Dictionary _supportSeen = new(); + private static readonly HashSet _geomSeen = new(); + + // Contact-plane provenance latch. Ten distinct sites call + // CollisionInfo.SetContactPlane — terrain, object BSP (graph and flat), + // cell BSP, three water paths, and a straight-up fallback — and the plane + // they write is indistinguishable once stored, so [support]'s + // classification would have no independent cross-check. + // + // This deliberately does NOT live on CollisionInfo. That object's stored + // members are compared member-for-member by the flat/graph differential + // referee and by the transition-scratch reset poison test; adding a + // diagnostic field there makes both oracles report a difference that is + // not a difference, and the only way to keep them green is to teach them + // to skip a member — which is how a referee quietly stops refereeing. + // [ThreadStatic] because a headless host ticks several sessions in + // parallel and physics is synchronous within each. + [ThreadStatic] private static string? _contactPlaneSourceMember; + [ThreadStatic] private static int _contactPlaneSourceLine; + + /// + /// Clear the provenance latch. Call once per resolve, before the sweep, so + /// a resolve that establishes no plane reports none rather than the + /// previous resolve's answer. No-op unless + /// . + /// + public static void BeginContactPlaneAttribution() + { + if (!ProbeSupportEnabled) return; + _contactPlaneSourceMember = null; + _contactPlaneSourceLine = 0; + } + + /// + /// Record the site asserting a contact plane. Called by + /// CollisionInfo.SetContactPlane with compiler-supplied literals; + /// self-guarded, so it is a single flag test when the probe is off and + /// allocates nothing when it is on. + /// + public static void RecordContactPlaneSource(string member, int line) + { + if (!ProbeSupportEnabled) return; + _contactPlaneSourceMember = member; + _contactPlaneSourceLine = line; + } + + /// + /// member:line of the last site to assert a contact plane since + /// , or "none". + /// + public static string ContactPlaneSource => + _contactPlaneSourceMember is { Length: > 0 } member + ? string.Concat( + member, + ":", + _contactPlaneSourceLine.ToString( + System.Globalization.CultureInfo.InvariantCulture)) + : "none"; + + /// + /// Classify what is under the body. Pure function so the live probe and any + /// offline reader agree on the vocabulary. + /// + /// none — no contact plane: the body is unsupported. + /// terrain — the contact plane sits at the terrain's height + /// AND shares its tilt. + /// object — the contact plane sits clear of the terrain: some + /// collision surface other than the ground is the support. + /// coplanar-tilt-mismatch — same height, different tilt. + /// Reported as its own answer rather than folded into either, because + /// it is exactly what a collision mesh laid flat against the ground + /// would look like and guessing between the two would be the third + /// unverified diagnosis this campaign. + /// no-terrain — no outdoor terrain under this XY (indoors, + /// or the landblock is not resident): the comparison is unavailable + /// and is said so rather than defaulted. + /// + /// + public static string ClassifySupport( + bool contactPlaneValid, + bool terrainSampled, + float contactPlaneZAtXY, + float contactPlaneNormalZ, + float terrainZ, + float terrainNormalZ) + { + if (!contactPlaneValid) return "none"; + if (!terrainSampled) return "no-terrain"; + + bool sameHeight = MathF.Abs(contactPlaneZAtXY - terrainZ) <= SupportSameSurfaceZ; + bool sameTilt = MathF.Abs(contactPlaneNormalZ - terrainNormalZ) <= SupportSameSurfaceNormalZ; + + if (sameHeight && sameTilt) return "terrain"; + if (sameHeight) return "coplanar-tilt-mismatch"; + return "object"; + } + + /// + /// Evaluate a plane's height at a given XY. Returns + /// when the plane is near-vertical, where a height is not defined — a wall + /// is never a floor, and reporting a huge number for one would read as a + /// displaced surface. + /// + public static bool TryPlaneZAt(in Plane plane, float x, float y, out float z) + { + float nz = plane.Normal.Z; + if (MathF.Abs(nz) < 1e-4f) + { + z = float.NaN; + return false; + } + z = -(plane.D + plane.Normal.X * x + plane.Normal.Y * y) / nz; + return true; + } + + /// + /// One [support] line. Self-guards on + /// . + /// + /// + /// Volume control: per mover, the line re-emits IMMEDIATELY on any change + /// in the state signature — the support classification, the ok / contact / + /// walkable / stalled bits, a 0.1 m change in the body's height, or a + /// 0.1 m change in its height above terrain — and otherwise at most once + /// per 250 ms. A body falling through geometry therefore produces a line + /// every 10 cm of descent, and a body standing still produces four lines a + /// second. Nothing is aggregated away. + /// + /// + /// + /// is never omitted: + /// feedback_probe_identity_attribution — a per-entity probe without + /// an identity produced a wrong root cause once already, and this capture + /// deliberately covers several bodies at once. + /// + /// + public static void LogSupport( + uint moverId, + bool isPlayer, + Vector3 inPos, + uint inCell, + Vector3 targetPos, + Vector3 outPos, + uint outCell, + bool ok, + bool groundedIn, + bool contact, + bool onWalkable, + bool contactPlaneValid, + Plane contactPlane, + uint contactPlaneCellId, + bool contactPlaneIsWater, + string contactPlaneSource, + bool lastKnownValid, + Plane lastKnownPlane, + bool terrainSampled, + float terrainZ, + Vector3 terrainNormal, + uint terrainCellId, + bool terrainIsWater, + bool walkablePolygon, + bool lastWalkablePolygon, + float stepUpHeight, + float stepDownHeight, + Vector3 velocity) + { + if (!ProbeSupportEnabled) return; + + float cpZ = float.NaN; + bool cpZDefined = contactPlaneValid + && TryPlaneZAt(contactPlane, outPos.X, outPos.Y, out cpZ); + if (!cpZDefined) cpZ = float.NaN; + + float cpNz = contactPlaneValid ? contactPlane.Normal.Z : float.NaN; + float terrNz = terrainSampled ? terrainNormal.Z : float.NaN; + + string support = ClassifySupport( + contactPlaneValid && cpZDefined, + terrainSampled, + cpZ, + cpNz, + terrainZ, + terrNz); + + float commanded = Vector3.Distance(inPos, targetPos); + float moved = Vector3.Distance(inPos, outPos); + // "The body was told to move and did not." The 1 cm floor is the + // resolver's own no-op scale, not a tuned threshold. + bool stalled = commanded > 0.01f && moved <= 0.01f; + + float zAboveTerrain = terrainSampled ? outPos.Z - terrainZ : float.NaN; + float cpAboveTerrain = terrainSampled && cpZDefined ? cpZ - terrainZ : float.NaN; + + long now = Environment.TickCount64; + long signature = support.GetHashCode(); + signature = signature * 31 + (ok ? 1 : 0); + signature = signature * 31 + (groundedIn ? 1 : 0); + signature = signature * 31 + (contact ? 1 : 0); + signature = signature * 31 + (onWalkable ? 1 : 0); + signature = signature * 31 + (contactPlaneValid ? 1 : 0); + signature = signature * 31 + (stalled ? 1 : 0); + signature = signature * 31 + (long)MathF.Floor(outPos.Z * 10f); + signature = signature * 31 + (float.IsNaN(zAboveTerrain) + ? 0 + : (long)MathF.Floor(zAboveTerrain * 10f)); + + lock (_supportGate) + { + if (_supportSeen.TryGetValue(moverId, out var prev) + && prev.Signature == signature + && now - prev.Ms < 250) + { + return; + } + _supportSeen[moverId] = (now, signature); + } + + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[support] mover=0x{0:X8} isPlayer={1} t={2} support={3} " + + "in=({4:F3},{5:F3},{6:F3}) inCell=0x{7:X8} " + + "tgt=({8:F3},{9:F3},{10:F3}) out=({11:F3},{12:F3},{13:F3}) outCell=0x{14:X8} " + + "ok={15} cmd={16:F3} moved={17:F3} stalled={18} " + + "groundedIn={19} contact={20} onWalkable={21} " + + "cpValid={22} cpSrc={23} cpCell=0x{24:X8} cpWater={25} " + + "cpN=({26:F4},{27:F4},{28:F4}) cpNz={29:F4} floorZ={30:F4} cpWalkable={31} " + + "cpZatOut={32:F3} " + + "lkcpValid={33} lkcpNz={34:F4} " + + "terrOk={35} terrZ={36:F3} terrNz={37:F4} terrWalkable={38} " + + "terrCell=0x{39:X8} terrWater={40} " + + "zAboveTerr={41:F3} cpAboveTerr={42:F3} " + + "walkPoly={43} lastWalkPoly={44} stepUp={45:F3} stepDown={46:F3} " + + "vel=({47:F3},{48:F3},{49:F3})", + moverId, isPlayer, now, support, + inPos.X, inPos.Y, inPos.Z, inCell, + targetPos.X, targetPos.Y, targetPos.Z, + outPos.X, outPos.Y, outPos.Z, outCell, + ok, commanded, moved, stalled, + groundedIn, contact, onWalkable, + contactPlaneValid, contactPlaneSource, contactPlaneCellId, contactPlaneIsWater, + contactPlaneValid ? contactPlane.Normal.X : float.NaN, + contactPlaneValid ? contactPlane.Normal.Y : float.NaN, + cpNz, cpNz, PhysicsGlobals.FloorZ, + contactPlaneValid && cpNz >= PhysicsGlobals.FloorZ, + cpZ, + lastKnownValid, lastKnownValid ? lastKnownPlane.Normal.Z : float.NaN, + terrainSampled, terrainZ, terrNz, + terrainSampled && terrNz >= PhysicsGlobals.FloorZ, + terrainCellId, terrainIsWater, + zAboveTerrain, cpAboveTerrain, + walkablePolygon, lastWalkablePolygon, stepUpHeight, stepDownHeight, + velocity.X, velocity.Y, velocity.Z)); + } + + /// + /// Ask whether [geom] has already been emitted for this GfxObj. + /// The line is a property of the ASSET, not of any moment, so once per + /// process is the whole story and re-emitting it would bury the + /// [support] stream. + /// + public static bool ShouldLogGeometry(uint gfxObjId) + { + if (!ProbeSupportEnabled) return false; + lock (_supportGate) + { + return _geomSeen.Add(gfxObjId); + } + } + + /// + /// One [geom] line: is this object's collision geometry where its + /// visual geometry is? Caller MUST have claimed the id through + /// . + /// + /// + /// The verdict vocabulary, and what each one settles: + /// + /// no-physics-bsp / empty-physics-bsp — the object + /// has no collision polygons at all. Everything a body does around it + /// follows from that one fact and no movement-side theory is needed. + /// no-visual-bounds — the comparison could not be made. Said + /// out loud rather than silently treated as agreement. + /// displaced — collision and visual are the same size but + /// sit in different places. This is the working hypothesis, and this + /// token is the only thing that confirms it. + /// extent-mismatch — same place, different size. + /// coincident — collision and visual agree. This REFUTES the + /// working hypothesis for this object, and the cause is then on the + /// movement side (terrain support, or the transition itself). + /// + /// + /// + public static void LogGeometry( + uint gfxObjId, + uint entityId, + int bspNodeCount, + int bspPolygonCount, + int bspVertexCount, + Vector3 rootSphereOrigin, + float rootSphereRadius, + bool physicsBoundsValid, + Vector3 physicsMin, + Vector3 physicsMax, + bool visualBoundsValid, + Vector3 visualMin, + Vector3 visualMax, + float visualRadius, + Vector3 entityWorldPosition, + float entityScale, + float registeredRadius) + { + Vector3 physExtent = physicsBoundsValid ? physicsMax - physicsMin : Vector3.Zero; + Vector3 visExtent = visualBoundsValid ? visualMax - visualMin : Vector3.Zero; + Vector3 physCentre = physicsBoundsValid + ? (physicsMin + physicsMax) * 0.5f + : Vector3.Zero; + Vector3 visCentre = visualBoundsValid + ? (visualMin + visualMax) * 0.5f + : Vector3.Zero; + + float centreDelta = physicsBoundsValid && visualBoundsValid + ? Vector3.Distance(physCentre, visCentre) + : float.NaN; + + // Tolerances are deliberately loose: this line answers "same place, + // same size?" at the scale of a rock formation, not to the millimetre. + // A physics hull is a coarse stand-in for the render mesh, so a + // half-metre of centre drift or a 2x extent ratio is normal; what this + // is looking for is the pathological case. + float centreTolerance = visualBoundsValid + ? MathF.Max(0.5f, visualRadius * 0.25f) + : 0.5f; + + bool extentMismatch = false; + if (physicsBoundsValid && visualBoundsValid) + { + for (int axis = 0; axis < 3; axis++) + { + float p = axis == 0 ? physExtent.X : axis == 1 ? physExtent.Y : physExtent.Z; + float v = axis == 0 ? visExtent.X : axis == 1 ? visExtent.Y : visExtent.Z; + // Flat axes (a floor plate) legitimately have ~0 extent in one + // dimension on both sides; only compare where the visual has + // real size. + if (v < 0.1f) continue; + float ratio = p / v; + if (ratio is < 0.5f or > 2.0f) extentMismatch = true; + } + } + + string verdict = + bspNodeCount == 0 ? "no-physics-bsp" + : bspPolygonCount == 0 ? "empty-physics-bsp" + : !visualBoundsValid ? "no-visual-bounds" + : !physicsBoundsValid ? "no-physics-bounds" + : centreDelta > centreTolerance ? "displaced" + : extentMismatch ? "extent-mismatch" + : "coincident"; + + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[geom] gfx=0x{0:X8} verdict={1} entity=0x{2:X8} t={3} " + + "bspNodes={4} bspPolys={5} bspVerts={6} " + + "rootSphere=({7:F3},{8:F3},{9:F3}) rootR={10:F3} registeredR={11:F3} " + + "physMin=({12:F3},{13:F3},{14:F3}) physMax=({15:F3},{16:F3},{17:F3}) " + + "physExt=({18:F3},{19:F3},{20:F3}) " + + "visMin=({21:F3},{22:F3},{23:F3}) visMax=({24:F3},{25:F3},{26:F3}) " + + "visExt=({27:F3},{28:F3},{29:F3}) visR={30:F3} " + + "centreDelta={31:F3} centreTol={32:F3} extentMismatch={33} " + + "objPos=({34:F2},{35:F2},{36:F2}) scale={37:F3} " + + "physWorldZ=[{38:F2},{39:F2}] visWorldZ=[{40:F2},{41:F2}]", + gfxObjId, verdict, entityId, Environment.TickCount64, + bspNodeCount, bspPolygonCount, bspVertexCount, + rootSphereOrigin.X, rootSphereOrigin.Y, rootSphereOrigin.Z, + rootSphereRadius, registeredRadius, + physicsMin.X, physicsMin.Y, physicsMin.Z, + physicsMax.X, physicsMax.Y, physicsMax.Z, + physExtent.X, physExtent.Y, physExtent.Z, + visualMin.X, visualMin.Y, visualMin.Z, + visualMax.X, visualMax.Y, visualMax.Z, + visExtent.X, visExtent.Y, visExtent.Z, visualRadius, + centreDelta, centreTolerance, extentMismatch, + entityWorldPosition.X, entityWorldPosition.Y, entityWorldPosition.Z, + entityScale, + // Rotation is NOT applied to these two world Z ranges: an + // axis-aligned box is not rotation-invariant, so a rotated object + // would report a box that is merely indicative. Both sides get the + // SAME treatment, so their AGREEMENT (the thing being measured) + // stays exact regardless. + entityWorldPosition.Z + physicsMin.Z * entityScale, + entityWorldPosition.Z + physicsMax.Z * entityScale, + entityWorldPosition.Z + visualMin.Z * entityScale, + entityWorldPosition.Z + visualMax.Z * entityScale)); + } + + /// + /// Resolve the collision-vs-visual comparison for one GfxObj straight from + /// the SAME prepared assets the resolver itself queries, and emit its + /// [geom] line. Going through the production accessors is the point: + /// AP-156's lesson was that a probe reading geometry by a second route can + /// report a shape the registry never emitted. Caller MUST have claimed the + /// id through . + /// + /// + /// The physics box is measured over the vertices of the polygons the BSP + /// actually indexes, not over the whole polygon table — a table can carry + /// rows no node references, and including those would report collision + /// geometry that no query can ever reach. + /// + /// + public static void LogGeometryFromAssets( + uint gfxObjId, + uint entityId, + FlatGfxObjCollisionAsset? flat, + GfxObjVisualBounds? visual, + Vector3 entityWorldPosition, + float entityScale, + float registeredRadius) + { + int nodeCount = 0; + int polygonCount = 0; + int vertexCount = 0; + Vector3 rootOrigin = Vector3.Zero; + float rootRadius = 0f; + bool physBoundsValid = false; + var physMin = new Vector3(float.PositiveInfinity); + var physMax = new Vector3(float.NegativeInfinity); + + FlatPhysicsBsp? bsp = flat?.PhysicsBsp; + if (bsp is { RootIndex: >= 0 } && bsp.Nodes.Length > 0) + { + nodeCount = bsp.Nodes.Length; + rootOrigin = bsp.Nodes[bsp.RootIndex].BoundingSphere.Origin; + rootRadius = bsp.Nodes[bsp.RootIndex].BoundingSphere.Radius; + + FlatPolygonTable table = bsp.PolygonTable; + foreach (FlatPhysicsBspNode node in bsp.Nodes) + { + FlatIndexRange range = node.PolygonIndexRange; + for (int i = range.Start; i < range.EndExclusive; i++) + { + int polygonIndex = bsp.PolygonIndexStream[i]; + if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue; + + polygonCount++; + FlatIndexRange vertices = table.Polygons[polygonIndex].VertexRange; + for (int v = vertices.Start; v < vertices.EndExclusive; v++) + { + Vector3 p = table.Vertices[v]; + vertexCount++; + physMin = Vector3.Min(physMin, p); + physMax = Vector3.Max(physMax, p); + physBoundsValid = true; + } + } + } + } + + if (!physBoundsValid) + { + physMin = Vector3.Zero; + physMax = Vector3.Zero; + } + + LogGeometry( + gfxObjId: gfxObjId, + entityId: entityId, + bspNodeCount: nodeCount, + bspPolygonCount: polygonCount, + bspVertexCount: vertexCount, + rootSphereOrigin: rootOrigin, + rootSphereRadius: rootRadius, + physicsBoundsValid: physBoundsValid, + physicsMin: physMin, + physicsMax: physMax, + visualBoundsValid: visual is not null, + visualMin: visual?.Min ?? Vector3.Zero, + visualMax: visual?.Max ?? Vector3.Zero, + visualRadius: visual?.Radius ?? 0f, + entityWorldPosition: entityWorldPosition, + entityScale: entityScale, + registeredRadius: registeredRadius); + } + /// /// Teleport-foundation timing probe (2026-06-22 — REMOVABLE diagnostic). /// Emits one [tp-probe] line per teleport-pipeline event with a @@ -1004,7 +2034,7 @@ public static class PhysicsDiagnostics /// /// /// Initial state from ACDREAM_PROBE_STEP_WALK=1. One-shot - /// diagnostic. + /// diagnostic; no DebugPanel mirror until the root cause is identified. /// /// public static bool ProbeStepWalkEnabled { get; set; } = @@ -1108,18 +2138,36 @@ public static class PhysicsDiagnostics ProbeParkEnabled = false; ProbeBuildingEnabled = false; ProbeCellSetEnabled = false; + ProbeStickyEnabled = false; + ProbeAutoWalkEnabled = false; ProbeUseabilityFallbackEnabled= false; DumpSteepRoofEnabled = false; ProbeIndoorBspEnabled = false; ProbeCellCacheEnabled = false; ProbeContactPlaneEnabled = false; + ProbeWalkMissEnabled = false; ProbePushBackEnabled = false; ProbePolyDumpEnabled = false; ProbePlacementFailEnabled = false; ProbeSweptEnabled = false; ProbeStepWalkEnabled = false; + ProbeReachEnabled = false; + lock (_reachGate) + { + _reachSeenObj.Clear(); + _reachSeenQuery.Clear(); + } + ProbeSupportEnabled = false; + _contactPlaneSourceMember = null; + _contactPlaneSourceLine = 0; + lock (_supportGate) + { + _supportSeen.Clear(); + _geomSeen.Clear(); + } ProbeTeleportEnabled = false; ProbeRemoteTeleportEnabled = false; + ProbeRemoteLandingEnabled = false; ProbeRemoteSlideEnabled = false; ProbeRemoteSlideGuids = new System.Collections.Generic.HashSet(); _remoteSlideAttributionGuid = 0; @@ -1500,6 +2548,130 @@ public static class PhysicsDiagnostics return "?"; } + // ----------------------------------------------------------------------- + // #338 — step-height provenance + // ----------------------------------------------------------------------- + + /// + /// #338 (2026-08-06, TEMPORARY). Traces the LOCAL PLAYER's step-up / + /// step-down heights along the chain that is supposed to carry them from + /// the authored Setup to the resolver, so we can see WHERE the observed + /// 0.400 / 0.400 wins. + /// + /// + /// What is already known and does NOT need measuring: retail reads the + /// authored field (CTransition::step_up @0x0050b610 substitutes + /// object_info.step_up_height when state & 2, i.e. + /// OnWalkable, and otherwise uses 0.0399999991f), and acdream + /// already ports that gate faithfully in Transition.DoStepUp. The + /// value is the defect, not the gate. + /// + /// + /// + /// What is NOT known, and is exactly what this probe decides: the + /// controller's fields initialise to 0.4f, while + /// RuntimeSetPositionMoverPreparation genuinely computes the + /// Setup-derived value and a writer genuinely assigns it. So either the + /// prepare/publish path never runs for the local player, or it runs and + /// something later overwrites it. A live reading of 0.400 alone + /// cannot tell those apart, and a fix chosen without knowing which is a + /// coin flip. + /// + /// + /// Decision table. Read the [step-h] lines in order: + /// + /// No site=prepare line at all → the Setup-derived path + /// never runs for the local player. Fix is to wire it (and the missing + /// PlayerModeController.ApplyStepHeights the doc comment names + /// was probably it). + /// site=prepare shows 0.600/1.500 but no + /// site=publish → the command is built and never consumed on this + /// path. + /// prepare and publish both show 0.600/1.500 but + /// site=resolve shows 0.400 → a later writer clobbers it; find + /// that writer, do not re-set the field. + /// site=prepare itself shows 0.400 → the Setup lookup is + /// returning the wrong Setup, or scale is wrong. + /// site=prepare shows 0.000 → preparation.Setup.Collision + /// was null and the retail dummy path was taken. + /// + /// + /// + /// + /// Initial state from ACDREAM_PROBE_STEP_HEIGHTS=1. Zero cost when + /// off (one static bool read per site). + /// + /// + public static bool ProbeStepHeightsEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_HEIGHTS") == "1"; + + private static readonly object _stepHeightGate = new(); + private static readonly Dictionary _stepHeightSeen = new(); + + private static int _stepHeightAnnounced; + + /// + /// #338 (TEMPORARY). Prints ONCE per process, regardless of the flag, from + /// the resolve site. Two placements of this probe produced no output at + /// all, and silence cannot distinguish "the site is never reached" from + /// "the flag is false" — so the instrument reports its own state rather + /// than leaving that to inference. Reaching this call proves the site + /// executes; the printed values say whether the flag and the player filter + /// would have let anything through. + /// + public static void AnnounceStepHeightProbeOnce(bool isPlayerMover) + { + if (System.Threading.Interlocked.Exchange(ref _stepHeightAnnounced, 1) != 0) + return; + + Console.WriteLine( + $"[step-h] SELF-REPORT: resolve site reached. " + + $"ProbeStepHeightsEnabled={ProbeStepHeightsEnabled} " + + $"(ACDREAM_PROBE_STEP_HEIGHTS=" + + $"{Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_HEIGHTS") ?? ""}) " + + $"firstMoverIsPlayer={isPlayerMover}"); + // Which BINARY is this? The 2026-08-07 wrong-checkout incident: two + // checkouts, a relative launch path, and every "test" of a fix ran a + // binary that did not contain it. Assembly identity belongs IN the + // capture, not inferred from bin timestamps afterwards. + Console.WriteLine( + $"[step-h] SELF-REPORT: assembly=" + + $"{typeof(PhysicsDiagnostics).Assembly.Location}"); + } + + /// + /// One [step-h] line. Self-guards on + /// , and is edge-triggered per site: + /// a site that keeps reporting the same pair prints once, so the + /// per-tick resolve site cannot drown the two one-shot sites it has to be + /// compared against. + /// + /// Where on the chain this reading was taken — + /// prepare, publish, or resolve. + /// The step-up height at that point, in metres. + /// The step-down height at that point. + /// Free-form provenance: the Setup id, the scale, or + /// which branch produced the value. This is what makes a surprising + /// reading actionable instead of merely surprising. + public static void LogStepHeights( + string site, float stepUp, float stepDown, string detail) + { + if (!ProbeStepHeightsEnabled) return; + + lock (_stepHeightGate) + { + if (_stepHeightSeen.TryGetValue(site, out var prev) + && prev.Up == stepUp && prev.Down == stepDown) + { + return; + } + _stepHeightSeen[site] = (stepUp, stepDown); + } + + Console.WriteLine( + $"[step-h] site={site} stepUp={stepUp:F3} stepDown={stepDown:F3} {detail}"); + } + // ------------------------------------------------------------------ // S6 (Campaign S, 2026-08-07) — AP-83/AP-91 PerfectClip TOI-tail // containment guard. @@ -1637,7 +2809,7 @@ public static class PhysicsDiagnostics // channel), naming which phase halted the attempt and, on Collided, // the colliding polygon's normal and which of the three channels // wrote it. - // [transit-fail-stepup] — DoStepUp entry/exit, mirroring the retired + // [transit-fail-stepup] — DoStepUp entry/exit, mirroring the existing // ACDREAM_DUMP_STEPUP probe's own input-normal-and-verdict content // so a stuck-tick capture carries the step chain without running a // second flag. @@ -1753,7 +2925,7 @@ public static class PhysicsDiagnostics /// /// One line per Transition.DoStepUp entry or exit, mirroring the - /// content of the retired ACDREAM_DUMP_STEPUP probe (same input + /// content of the existing ACDREAM_DUMP_STEPUP probe (same input /// normal / walkable verdict / landing-plane fields) so a stuck-tick /// capture carries the step-up chain without a second flag. /// is "enter" or "exit"; diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 014df169..d73b5979 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1913,6 +1913,15 @@ public sealed class PhysicsEngine ? PhysicsResolveCapture.Snapshot(body) : null; + // #337 (2026-08-06 — TEMPORARY): arm the [support] probe's + // contact-plane provenance latch for this resolve, ahead of everything + // including the carried-plane seed below. The seed is itself one of + // the ten sites that assert a plane, so it stamps its own name and a + // capture can read `cpSrc=ResolveWithTransition:` as "carried + // from the body, nothing re-derived it this resolve" without needing a + // sentinel value for that case. No-op when the probe is off. + PhysicsDiagnostics.BeginContactPlaneAttribution(); + // #345 probe (2026-08-08): reset the per-tick transition-phase trace // buffer. Scoped to exactly one resolve/tick — see // PhysicsDiagnostics.BeginTransitFailTrace. No-op when the probe is @@ -1925,6 +1934,38 @@ public sealed class PhysicsEngine transition.ObjectInfo.StepUpHeight = stepUpHeight; transition.ObjectInfo.StepDownHeight = stepDownHeight; + // #338 (TEMPORARY): the resolver's own reading, taken where the + // values actually land rather than at one of two candidate call + // sites. The first attempt probed PlayerMovementController and + // printed NOTHING across 11,523 live log lines — the wrong one of + // its two resolve calls. A silent probe proves nothing, so this + // one sits where every caller must pass through. Filtered to the + // player so remotes cannot drown it. + // #338 self-report, once per process, UNCONDITIONAL. The probe has + // now been silent through two placements, and "no output" cannot + // distinguish "this site is never reached" from "the flag is + // false". This line answers both directly instead of a third round + // of inference. One Interlocked per process; strip with the probe. + PhysicsDiagnostics.AnnounceStepHeightProbeOnce( + (moverFlags & ObjectInfoState.IsPlayer) != 0); + + // The flag test MUST precede the interpolated string: this site runs + // per resolve, and building the detail eagerly cost 128 B/resolve + // with the probe OFF — caught by Slice I1's zero-allocation gate, + // which is exactly what that gate is for. + if (PhysicsDiagnostics.ProbeStepHeightsEnabled + && (moverFlags & ObjectInfoState.IsPlayer) != 0) + { + // The mover id is REQUIRED here (feedback_probe_identity_attribution): + // remote players also carry IsPlayer, and the one early + // 0.400 reading this probe caught was nearly misattributed to + // the local player for exactly that reason — it was a remote + // in its Setup-residency window (AD-68). + PhysicsDiagnostics.LogStepHeights( + "resolve", stepUpHeight, stepDownHeight, + $"mover=0x{movingEntityId:X8} onGround={isOnGround} hasBody={body is not null}"); + } + transition.ObjectInfo.StepDown = true; // Fix #42 (2026-05-05): the moving entity's ShadowEntry must be // skipped in FindObjCollisions or the sweep collides with self. @@ -2245,10 +2286,37 @@ public sealed class PhysicsEngine bool collisionNormalValid = ci.CollisionNormalValid; Vector3 collisionNormal = ci.CollisionNormal; + // #42 diagnostic (2026-05-05): trace airborne sweeps to identify the + // source of the ~1m XY drift on retail-observed stationary jumps. + // Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per + // resolve call. deltaXY = post - target tells us how much the sweep + // diverged from the requested target; for a clean stationary +Z + // jump we expect (0,0). cp=valid with a tilted normal would confirm + // H1 (initial-overlap depenetration → next-step AdjustOffset projects + // the +Z offset along a non-+Z normal). User repros at flat plaza / + // east hillside / north hillside; if drift direction tracks terrain + // orientation, H1 is the cause; if it tracks actor facing, H2 / H3. + if (!isOnGround + && Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1") + { + var post = sp.CheckPos; + float dx = post.X - targetPos.X; + float dy = post.Y - targetPos.Y; + string cpInfo = ci.ContactPlaneValid + ? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})" + : "none"; + Console.WriteLine( + $"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " + + $"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " + + $"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " + + $"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " + + $"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}"); + } + // L.2a slice 1 (2026-05-12): general-purpose resolver probe. // One line per call when PhysicsDiagnostics.ProbeResolveEnabled // is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the - // property assigned at runtime). Captures every + // DebugPanel checkbox flipped at runtime). Captures every // dimension L.2 cares about: input/output position, input/output // cell, ok-vs-partial, grounded-in vs contact-out, contact-plane // status, wall normal if hit, walkable polygon valid. Zero cost @@ -2287,6 +2355,67 @@ public sealed class PhysicsEngine $"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}")); } + // #337 [support] probe (2026-08-06 — TEMPORARY, strip with the + // physics-probe family). Runs for EVERY body, not just the player: + // a corpse sinking through geometry is a plain physics body with + // no player-specific logic, so it is the cheapest possible control + // on whether the movement code or the geometry is at fault, and it + // is invisible to any player-filtered probe. + // + // The terrain sample below is INDEPENDENT of whatever the sweep + // decided — it asks the landblock directly what the ground height + // is under the body's own out-XY. Pairing that with the contact + // plane's height at the same XY is what separates "terrain is + // holding this body up" from "some object surface is". Read-only: + // SampleTerrainWalkable takes no locks, mutates nothing, and is + // not on the resolve's committed path. + if (PhysicsDiagnostics.ProbeSupportEnabled) + { + Vector3 outPos = sp.CheckPos; + TerrainWalkableSample? terrain = + SampleTerrainWalkable(outPos.X, outPos.Y); + + bool terrainSampled = terrain.HasValue + && PhysicsDiagnostics.TryPlaneZAt( + terrain.Value.Plane, outPos.X, outPos.Y, out _); + float terrainZ = float.NaN; + if (terrainSampled) + { + PhysicsDiagnostics.TryPlaneZAt( + terrain!.Value.Plane, outPos.X, outPos.Y, out terrainZ); + } + + PhysicsDiagnostics.LogSupport( + moverId: movingEntityId, + isPlayer: (moverFlags & ObjectInfoState.IsPlayer) != 0, + inPos: currentPos, + inCell: cellId, + targetPos: targetPos, + outPos: outPos, + outCell: sp.CheckCellId, + ok: ok, + groundedIn: isOnGround, + contact: transition.ObjectInfo.Contact, + onWalkable: transition.ObjectInfo.OnWalkable, + contactPlaneValid: ci.ContactPlaneValid, + contactPlane: ci.ContactPlane, + contactPlaneCellId: ci.ContactPlaneCellId, + contactPlaneIsWater: ci.ContactPlaneIsWater, + contactPlaneSource: PhysicsDiagnostics.ContactPlaneSource, + lastKnownValid: ci.LastKnownContactPlaneValid, + lastKnownPlane: ci.LastKnownContactPlane, + terrainSampled: terrainSampled, + terrainZ: terrainZ, + terrainNormal: terrain?.Plane.Normal ?? Vector3.Zero, + terrainCellId: terrain?.CellId ?? 0u, + terrainIsWater: terrain?.IsWater ?? false, + walkablePolygon: sp.HasWalkablePolygon, + lastWalkablePolygon: sp.HasLastWalkablePolygon, + stepUpHeight: stepUpHeight, + stepDownHeight: stepDownHeight, + velocity: body?.Velocity ?? Vector3.Zero); + } + // Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell. // Emits before the ResolveResult is built so it shows what BOTH paths would return. // No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change. @@ -2383,11 +2512,7 @@ public sealed class PhysicsEngine body is not null ? PhysicsResolveCapture.Snapshot(body) : null); } - return resolveResult with - { - LastCollidedObjectId = ci.LastCollidedObjectGuid ?? 0u, - CollidedWithEnvironment = ci.CollidedWithEnvironment, - }; + return resolveResult; } finally { diff --git a/src/AcDream.Core/Physics/RawMotionState.cs b/src/AcDream.Core/Physics/RawMotionState.cs index fa12e9aa..315ee1ff 100644 --- a/src/AcDream.Core/Physics/RawMotionState.cs +++ b/src/AcDream.Core/Physics/RawMotionState.cs @@ -74,32 +74,6 @@ public readonly record struct RawMotionAction( /// public sealed class RawMotionState { - public RawMotionState() - { - } - - /// - /// Deep snapshot used at retail's synchronous SendMovementEvent boundary. - /// The action FIFO is copied so animation completion cannot mutate a - /// packet that has already been requested. - /// - public RawMotionState(RawMotionState other) - { - ArgumentNullException.ThrowIfNull(other); - CurrentHoldKey = other.CurrentHoldKey; - CurrentStyle = other.CurrentStyle; - ForwardCommand = other.ForwardCommand; - ForwardHoldKey = other.ForwardHoldKey; - ForwardSpeed = other.ForwardSpeed; - SidestepCommand = other.SidestepCommand; - SidestepHoldKey = other.SidestepHoldKey; - SidestepSpeed = other.SidestepSpeed; - TurnCommand = other.TurnCommand; - TurnHoldKey = other.TurnHoldKey; - TurnSpeed = other.TurnSpeed; - _actions.AddRange(other._actions); - } - /// Retail current_holdkey (ctor default HoldKey_None). public HoldKey CurrentHoldKey { get; set; } = HoldKey.None; /// Retail current_style (ctor default 0x8000003D, NonCombat). diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs index 49d2b2f5..28733b8d 100644 --- a/src/AcDream.Core/Physics/ResolveResult.cs +++ b/src/AcDream.Core/Physics/ResolveResult.cs @@ -58,16 +58,4 @@ public readonly record struct ResolveResult( /// Full cell that owns . uint ContactPlaneCellId = 0, /// Whether the accepted contact plane is water. - bool ContactPlaneIsWater = false) -{ - /// - /// Last live object touched by this transition, or zero for environment- - /// only/no collision. This is detached collision evidence, not an impact - /// side effect; projectile-awareness callers use it to distinguish the - /// designated target from an intervening creature or prop. - /// - public uint LastCollidedObjectId { get; init; } - - /// Whether resident environment geometry blocked the sweep. - public bool CollidedWithEnvironment { get; init; } -} + bool ContactPlaneIsWater = false); diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index c6a80164..051bf8c7 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -446,8 +446,29 @@ public sealed class CollisionInfo public void SetContactPlane( Plane plane, uint cellId, - bool isWater = false) + bool isWater = false, + // #337 [support] attribution — recorded on PhysicsDiagnostics, not on + // this object; see the comment in the body for why. Compiler-supplied + // literals: no call site passes these explicitly and none needs to. + [System.Runtime.CompilerServices.CallerMemberName] string sourceMember = "", + [System.Runtime.CompilerServices.CallerLineNumber] int sourceLine = 0) { + // #337 attribution (2026-08-06 — TEMPORARY, strip with the probe + // family). Recorded ABOVE the no-op guard on purpose: the meaning is + // "the last site that ASSERTED this plane", not "the site that first + // differed from the previous value" — a sweep that re-derives the + // identical plane it was seeded with has still told you where the + // plane comes from, and that is the fact the capture needs. + // + // It lives on PhysicsDiagnostics, NOT on this object. CollisionInfo's + // stored members are compared member-for-member by the flat/graph + // differential referee and by the scratch-reset poison test; a + // diagnostic field there is state those oracles must then be taught to + // ignore, which is how a referee stops refereeing. The latch is + // [ThreadStatic] and is armed once per resolve — see + // PhysicsDiagnostics.BeginContactPlaneAttribution. + PhysicsDiagnostics.RecordContactPlaneSource(sourceMember, sourceLine); + // A6.P3 slice 2 (2026-05-22): no-op-if-unchanged guard. Closes // issue #96 (per-tick CP-write blowup) without removing the // PhysicsEngine.cs L622 seed that step_up depends on. When the @@ -1349,6 +1370,9 @@ public sealed class Transition CollisionInfo.CopyFrom(source.CollisionInfo); } + private static bool DumpEdgeSlideEnabled => + Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1"; + // ----------------------------------------------------------------------- // A6.P7 (2026-05-25) — retail-binary shape dispatch rule // ----------------------------------------------------------------------- @@ -2224,6 +2248,7 @@ public sealed class Transition if (ci.ContactPlaneValid) return TransitionState.OK; + DumpStepDownBranchGate(contactInvalid: true); if (oi.Contact && !sp.StepDown && sp.CheckCellId != 0 && oi.StepDown) { // L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable, @@ -2276,6 +2301,7 @@ public sealed class Transition // intentionally narrow: it tells the next L.2c slice whether // we are missing precipice context, a steep contact plane, or // merely the EdgeSlide flag. + DumpEdgeSlideStepDownFailed(stepDownHeight, zVal); bool stop = EdgeSlideAfterStepDownFailed( engine, @@ -2464,6 +2490,7 @@ public sealed class Transition TransitionCellCollisionPhase.Objects, cellId, FindObjCollisionsInCell(engine, cellId)); + DumpPhase2(innerAttempt, environment, objects); PhysicsDiagnostics.TraceTransitInsertAttempt( ObjectInfo.SelfEntityId, innerAttempt, "objects", environment, building, objects, objects, @@ -2495,6 +2522,7 @@ public sealed class Transition // No steep-plane exception precedes this gate (0050b3d8-0050b3e7). if (!oi.OnWalkable || !oi.EdgeSlide) { + DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -2506,6 +2534,7 @@ public sealed class Transition if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal) { var cliffPlane = ci.ContactPlane; + DumpEdgeSlideBranch("branch2/steep-cliffslide", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -2528,6 +2557,7 @@ public sealed class Transition // rapidly down the stairs. Do not restore stale history here. if (sp.HasWalkablePolygon) { + DumpEdgeSlideBranch("branch3/precipice-slide", zVal); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; @@ -2537,6 +2567,7 @@ public sealed class Transition if (ci.ContactPlaneValid) { + DumpEdgeSlideBranch("branch4/contact-no-walkable", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -2614,6 +2645,8 @@ public sealed class Transition Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f); if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq) { + DumpCliffSlide("degenerate-cross/last-known", contactPlane, + new Plane(referenceNormal, 0f), contactNormal, 0f, false); return TransitionState.OK; } @@ -2621,6 +2654,8 @@ public sealed class Transition Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin; float angle = Vector3.Dot(collideNormal, offset); + DumpCliffSlide("ok/last-known", contactPlane, + new Plane(referenceNormal, 0f), collideNormal, angle, true); if (angle <= 0f) { @@ -2636,6 +2671,89 @@ public sealed class Transition return TransitionState.Adjusted; } + private void DumpEdgeSlideStepDownFailed(float stepDownHeight, float zVal) + { + if (!DumpEdgeSlideEnabled) return; + + var sp = SpherePath; + var ci = CollisionInfo; + var oi = ObjectInfo; + + Console.WriteLine( + System.FormattableString.Invariant( + $"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} lastWalkablePoly={sp.HasLastWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}")); + } + + /// + /// L.4-diag: log step-down branch gate decision. Whether we entered or + /// skipped the contact-recovery branch matters for whether CliffSlide + /// has any chance of firing. + /// + private void DumpStepDownBranchGate(bool contactInvalid) + { + if (!DumpEdgeSlideEnabled) return; + + var sp = SpherePath; + var ci = CollisionInfo; + var oi = ObjectInfo; + + bool wouldEnter = contactInvalid && oi.Contact && !sp.StepDown + && sp.CheckCellId != 0 && oi.StepDown; + + if (!wouldEnter) return; // only log when entering, to keep noise low + + Console.WriteLine( + System.FormattableString.Invariant( + $"edge-slide: stepdown-branch-enter cur={Fmt(sp.CurPos)} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} contact={oi.Contact}")); + } + + /// + /// L.4-diag: log Phase 2 outcome per inner attempt. Tells us whether + /// we're churning in Slid retries or escaping to step-down branch. + /// + private void DumpPhase2(int attempt, TransitionState envState, TransitionState objState) + { + if (!DumpEdgeSlideEnabled) return; + if (objState == TransitionState.OK) return; // skip clean attempts + + Console.WriteLine( + System.FormattableString.Invariant( + $"edge-slide: phase2 attempt={attempt} env={envState} obj={objState}")); + } + + /// + /// L.4-diag: log which branch of EdgeSlideAfterStepDownFailed fired. + /// Tells us whether CliffSlide gets called or whether we hit a + /// stop-at-edge branch. + /// + private void DumpEdgeSlideBranch(string branch, float zVal) + { + if (!DumpEdgeSlideEnabled) return; + var sp = SpherePath; + var ci = CollisionInfo; + var oi = ObjectInfo; + Console.WriteLine( + System.FormattableString.Invariant( + $"edge-slide: branch={branch} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} lastValid={ci.LastKnownContactPlaneValid} lastN.Z={(ci.LastKnownContactPlaneValid ? ci.LastKnownContactPlane.Normal.Z : 0f):F3} walkPolyValid={sp.HasWalkablePolygon} walkPolyN.Z={(sp.HasWalkablePolygon ? sp.WalkablePlane.Normal.Z : 0f):F3} lastWalkPolyN.Z={(sp.HasLastWalkablePolygon ? sp.LastWalkablePlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} edgeFlag={oi.EdgeSlide} zVal={zVal:F3}")); + } + + /// + /// L.4-diag: log CliffSlide invocation. Tells us whether the + /// cross-product is degenerate (no slide) or producing a real + /// deflection. + /// + private void DumpCliffSlide(string outcome, Plane current, Plane lastKnown, + Vector3 collideNormal, float angle, bool willApply) + { + if (!DumpEdgeSlideEnabled) return; + Console.WriteLine( + System.FormattableString.Invariant( + $"edge-slide: cliffslide outcome={outcome} curN={Fmt(current.Normal)} lastN={Fmt(lastKnown.Normal)} collideN={Fmt(collideNormal)} angle={angle:F4} apply={willApply}")); + } + + private static string Fmt(Vector3 value) => + System.FormattableString.Invariant($"({value.X:F3},{value.Y:F3},{value.Z:F3})"); + // ----------------------------------------------------------------------- // Environment collision — outdoor terrain // ----------------------------------------------------------------------- @@ -3762,12 +3880,35 @@ public sealed class Transition var oi = ObjectInfo; var ci = CollisionInfo; + // #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with + // the physics-probe family). Filtered to the player mover so NPC / + // remote dead-reckoning resolves do not pollute the capture, matching + // PhysicsResolveCapture's filter. The zero-entry query below is + // reported EXPLICITLY: "this cell yielded nothing" is outcome (b) — + // a registration gap — and must appear as data, never as silence. + bool reachProbe = PhysicsDiagnostics.ProbeReachEnabled && oi.IsPlayer; + if (objsInCell.Count == 0) { + if (reachProbe) + PhysicsDiagnostics.LogReachQuery( + oi.SelfEntityId, cellId, sp.StepDown, + inCell: 0, exempt: 0, reached: 0, rejectedReach: 0, + noShape: 0, tested: 0, blocked: 0, + currPos: sp.GlobalCurrCenter[0].Origin); return TransitionState.OK; } + // #42 diagnostic (2026-05-05): identify which static object causes + // the airborne first-frame ~1m push. + bool airborneDiag = !oi.Contact + && Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1"; + Vector3 sphereCheckBefore = sp.CheckPos; + Vector3 checkPos = sp.GlobalSphere[0].Origin; + Vector3 currPos = sp.GlobalCurrCenter[0].Origin; + float sphereRadius = sp.GlobalSphere[0].Radius; + Vector3 movement = checkPos - currPos; // Landblock offsets feed the [resolve-bldg] probe only. engine.TryGetLandblockContext(checkPos.X, checkPos.Y, @@ -3778,8 +3919,38 @@ public sealed class Transition // registry mutations through the same reference mid-iteration. using var nearbyObjs = ShadowEntrySnapshot.Capture(objsInCell); + // #334 probe tallies — see LogReachQuery for what each one decides. + // `rejectedReach` is deliberately still REPORTED and structurally 0 + // since #333 deleted the filter: a run of the acceptance capture whose + // [reach-q] lines read rejectedReach=0 where the pre-fix capture read + // 7,225 rejections is the evidence the filter is gone, and dropping the + // column would make the two captures incomparable. + const int rRejected = 0; + int rExempt = 0, rReached = 0, rNoShape = 0, + rTested = 0, rBlocked = 0; + foreach (ShadowEntry obj in nearbyObjs.Entries) { + // #337 [geom] probe (2026-08-06 — TEMPORARY, strip with the + // physics-probe family). Emitted here, at the TOP of the candidate + // loop, so it covers every object the mover comes near regardless + // of what the exemptions and the reach filter later do with it — + // an object whose collision geometry is absent or displaced must + // be reported even when nothing ever tests it. Once per GfxObj per + // process: the line describes an ASSET, not a moment. + if (obj.CollisionType == ShadowCollisionType.BSP + && PhysicsDiagnostics.ShouldLogGeometry(obj.GfxObjId)) + { + PhysicsDiagnostics.LogGeometryFromAssets( + gfxObjId: obj.GfxObjId, + entityId: obj.EntityId, + flat: engine.DataCache.GetFlatGfxObj(obj.GfxObjId), + visual: engine.DataCache.GetVisualBounds(obj.GfxObjId), + entityWorldPosition: obj.Position, + entityScale: obj.Scale, + registeredRadius: obj.Radius); + } + // Self-skip — fix #42 (2026-05-05). Mirrors retail // CObjCell::find_obj_collisions at acclient_2013_pseudo_c.txt // 308931: `physobj != arg2->object_info.object` rejects the @@ -3794,6 +3965,12 @@ public sealed class Transition // gfxObj=0x02000001 at exactly the entity's own position). if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId) { + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-self", sphereRadius, movement.Length(), currPos); + } continue; } @@ -3802,6 +3979,12 @@ public sealed class Transition // dispatch; a true result reaches neither branch. if (oi.MissileIgnore(obj.EntityId, obj.State, obj.Flags)) { + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-missile", sphereRadius, movement.Length(), currPos); + } continue; } @@ -3810,6 +3993,7 @@ public sealed class Transition // Cell membership IS the broad phase, and the BSP walk's own root // node bounding-sphere test — correctly centred, unlike the deleted // filter — is the early-out that made this one unnecessary. + if (reachProbe) rReached++; // Commit C 2026-04-29 — retail exemption block at the top of // CPhysicsObj::FindObjCollisions @@ -3821,6 +4005,12 @@ public sealed class Transition // so this is a cheap fall-through for them. if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State)) { + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-rule", sphereRadius, movement.Length(), currPos); + } continue; } @@ -3850,6 +4040,12 @@ public sealed class Transition if (etherealForTest && sp.StepDown) { // retail pc:276799 — ethereal target not tested in step-down + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-ethereal-stepdown", sphereRadius, movement.Length(), currPos); + } continue; } sp.ObstructionEthereal = etherealForTest; @@ -3900,6 +4096,15 @@ public sealed class Transition // clear (pc:276989) fires after shape tests; we clear early here to // leave the flag clean for the next iteration. sp.ObstructionEthereal = false; + // #334 outcome (c): the entry IS a candidate, the reach + // filter DID admit it, and it still contributes nothing + // because no usable physics BSP resolved for its GfxObj. + if (reachProbe) + { + rNoShape++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "no-shape", sphereRadius, movement.Length(), currPos); + } continue; } @@ -3967,6 +4172,12 @@ public sealed class Transition Console.WriteLine(System.FormattableString.Invariant( $"[sph-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only")); } + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "bsp-only-skip", sphereRadius, movement.Length(), currPos); + } continue; } @@ -4010,6 +4221,12 @@ public sealed class Transition Console.WriteLine(System.FormattableString.Invariant( $"[cyl-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only")); } + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "bsp-only-skip", sphereRadius, movement.Length(), currPos); + } continue; } @@ -4173,15 +4390,138 @@ public sealed class Transition // per-object test body before the outer loop continues. sp.ObstructionEthereal = false; + // #334: this candidate actually reached a shape test. `result` is + // post-Layer-2, i.e. what the query will really act on. + if (reachProbe) + { + rTested++; + if (result != TransitionState.OK) rBlocked++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + result switch + { + TransitionState.OK => "tested-ok", + TransitionState.Collided => "tested-collided", + TransitionState.Adjusted => "tested-adjusted", + TransitionState.Slid => "tested-slid", + _ => "tested-invalid", + }, + sphereRadius, movement.Length(), currPos); + } + if (result != TransitionState.OK) { + if (airborneDiag) + { + var sphereCheckAfter = sp.CheckPos; + var d = sphereCheckAfter - sphereCheckBefore; + Console.WriteLine( + $"[SWEEP-OBJ] type={obj.CollisionType} gfxObj=0x{obj.GfxObjId:X8} " + + $"objPos=({obj.Position.X:F3},{obj.Position.Y:F3},{obj.Position.Z:F3}) " + + $"objR={obj.Radius:F3} cylH={obj.CylHeight:F3} " + + $"state={result} pushDelta=({d.X:F3},{d.Y:F3},{d.Z:F3})"); + } + // #334 (TEMPORARY): the early exit is a real query outcome, so + // it must be summarised too — otherwise the summary would + // under-report exactly the queries where something DID block, + // and "blocked" is the control that proves the probe can see a + // working collision as well as a missing one. + if (reachProbe) + PhysicsDiagnostics.LogReachQuery( + oi.SelfEntityId, cellId, sp.StepDown, + nearbyObjs.Entries.Length, rExempt, rReached, + rRejected, rNoShape, rTested, rBlocked, currPos); return result; } } + if (reachProbe) + PhysicsDiagnostics.LogReachQuery( + oi.SelfEntityId, cellId, sp.StepDown, + nearbyObjs.Entries.Length, rExempt, rReached, + rRejected, rNoShape, rTested, rBlocked, currPos); + return TransitionState.OK; } + /// + /// #334 candidate-disposition probe helper (2026-08-06 — TEMPORARY, strip + /// with the physics-probe family). Static, and takes everything by + /// parameter, so it introduces no closure display class into + /// — Slice I1's 0 B/resolve budget + /// must hold with the probe compiled in and switched off. + /// + /// + /// The target's physics-BSP ROOT sphere is resolved through the SAME + /// production accessor registration used + /// (GetFlatGfxObj(id).PhysicsBsp's root node, per + /// LiveEntityCollisionBuilder and ShadowShapeBuilder), so the + /// probe cannot report geometry that differs from what the registry + /// actually emitted. AP-156's lesson was exactly that: one resolver. + /// + /// + private static void ProbeReachCandidate( + PhysicsEngine engine, + ObjectInfo oi, + SpherePath sp, + uint cellId, + in ShadowEntry obj, + string disposition, + float sphereRadius, + float movementLen, + Vector3 currPos) + { + bool xyOnly = obj.CollisionType == ShadowCollisionType.Cylinder; + + Vector3 dOrigin = currPos - obj.Position; + float distOrigin = xyOnly + ? MathF.Sqrt(dOrigin.X * dOrigin.X + dOrigin.Y * dOrigin.Y) + : dOrigin.Length(); + + // World-space offset from the part ORIGIN (what the DELETED filter + // measured against) to the BSP root sphere CENTRE (what it should have + // measured against). Both distances are still emitted after #333: the + // pair is what proved the diagnosis, and it stays comparable across the + // pre-fix and post-fix captures. Zero for non-BSP shapes, whose + // Position already IS their centre. + Vector3 bspCentreOffset = Vector3.Zero; + if (obj.CollisionType == ShadowCollisionType.BSP) + { + var flatBsp = engine.DataCache?.GetFlatGfxObj(obj.GfxObjId)?.PhysicsBsp; + if (flatBsp is { RootIndex: >= 0 }) + { + bspCentreOffset = Vector3.Transform( + flatBsp.Nodes[flatBsp.RootIndex].BoundingSphere.Origin * obj.Scale, + obj.Rotation); + } + } + + Vector3 dCentre = currPos - (obj.Position + bspCentreOffset); + float distCentre = xyOnly + ? MathF.Sqrt(dCentre.X * dCentre.X + dCentre.Y * dCentre.Y) + : dCentre.Length(); + + float budget = sphereRadius + obj.Radius + movementLen + 2f; + + PhysicsDiagnostics.LogReachCandidate( + moverId: oi.SelfEntityId, + entityId: obj.EntityId, + gfxObjId: obj.GfxObjId, + cellId: cellId, + shape: obj.CollisionType, + disposition: disposition, + stepDown: sp.StepDown, + distOrigin: distOrigin, + distCenter: distCentre, + objRadius: obj.Radius, + sphereRadius: sphereRadius, + movementLen: movementLen, + budget: budget, + centerBudget: budget - 2f, + objPos: obj.Position, + bspCentreOffset: bspCentreOffset, + currPos: currPos); + } + /// /// BR-7 / A6.P4 (2026-06-11). The retail BUILDING collision channel — /// CSortCell::find_collisions (Ghidra 0x005340a0): an outdoor @@ -5642,9 +5982,29 @@ public sealed class Transition var ci = CollisionInfo; var oi = ObjectInfo; - // #345 probe (2026-08-08): buffers this step-up attempt into the - // stuck-tick trace (self-guards internally, zero cost when - // ACDREAM_DUMP_TRANSIT_FAIL is unset). + // L.2.3f (2026-04-29): diagnostic for steep-roof bug. Logs the + // input polygon normal that triggered step-up. The verdict tells + // whether THIS polygon would pass FloorZ (≈ 0.66) — but actual + // step-up acceptance depends on the polygon found by step_sphere_down + // INSIDE the recursive TransitionalInsert, which may be different. + // The post-step "result=" line below logs that outcome. + bool diag = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEPUP") == "1"; + if (diag) + { + float floor = PhysicsGlobals.FloorZ; + string verdict = collisionNormal.Z >= floor ? "WALKABLE" : "STEEP"; + Console.WriteLine( + $"stepup: enter normal=({collisionNormal.X:F3},{collisionNormal.Y:F3},{collisionNormal.Z:F3}) " + + $"|Z|={collisionNormal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " + + $"OnWalkable={(oi.State & ObjectInfoState.OnWalkable) != 0}, " + + $"StepUpHeight={oi.StepUpHeight:F3}, " + + $"CurPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2})"); + } + + // #345 probe (2026-08-08): mirrors the ACDREAM_DUMP_STEPUP content + // above into the buffered stuck-tick trace (self-guards internally, + // zero cost when ACDREAM_DUMP_TRANSIT_FAIL is unset) so a stuck-tick + // capture carries the step-up chain without a second flag. PhysicsDiagnostics.TraceTransitStepUp( oi.SelfEntityId, "enter", collisionNormal, onWalkable: (oi.State & ObjectInfoState.OnWalkable) != 0, @@ -5690,6 +6050,28 @@ public sealed class Transition sp.StepUp = false; sp.ClearWalkable(); + // L.2.3f: log the result + landing plane if step-up succeeded. + // This is the actual surface the player ended up on, which may + // differ from the input collision normal (e.g. step-up scanned + // past a steep slope and landed on a flatter polygon higher up). + if (diag) + { + if (stepDown && ci.ContactPlaneValid) + { + float floor = PhysicsGlobals.FloorZ; + string verdict = ci.ContactPlane.Normal.Z >= floor ? "WALKABLE" : "STEEP"; + Console.WriteLine( + $"stepup: SUCCESS — landed on plane normal=" + + $"({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3}) " + + $"|Z|={ci.ContactPlane.Normal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " + + $"new CheckPos=({sp.CheckPos.X:F2},{sp.CheckPos.Y:F2},{sp.CheckPos.Z:F2})"); + } + else + { + Console.WriteLine($"stepup: FAILED — sliding back along normal"); + } + } + // #345 probe (2026-08-08): the matching exit line for the "enter" // trace above. PhysicsDiagnostics.TraceTransitStepUp( diff --git a/src/AcDream.Core/Physics/WalkMissDiagnostic.cs b/src/AcDream.Core/Physics/WalkMissDiagnostic.cs new file mode 100644 index 00000000..35a7dcfa --- /dev/null +++ b/src/AcDream.Core/Physics/WalkMissDiagnostic.cs @@ -0,0 +1,173 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// ISSUES #83 H-disambiguation spike (2026-05-21). Pure-function +/// aggregator over a dict — picks +/// the nearest walkable-eligible polygon to a given foot position +/// (cell-local space) and reports XY-containment + vertical gap so +/// the [walk-miss] emission site can disambiguate H1/H2/H3 +/// without re-walking the dictionary itself. +/// +/// +/// Also enumerates walkable polygons for the one-shot +/// [floor-polys] dump at cell-cache time. +/// +/// +/// +/// Spec: docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md. +/// +/// +public static class WalkMissDiagnostic +{ + public readonly struct AggregateResult + { + public bool Found { get; init; } + public ushort PolyId { get; init; } + public bool ContainsFootXY { get; init; } + public float Dz { get; init; } + public float NormalZ { get; init; } + } + + public readonly struct WalkableEntry + { + public ushort PolyId { get; init; } + public float NormalZ { get; init; } + public Vector3 BboxMin { get; init; } + public Vector3 BboxMax { get; init; } + public float PlaneZAtBboxCenter { get; init; } + } + + /// + /// Walks , considering only polygons + /// whose plane normal Z is at least + /// (walkable slope). Selection rule: + /// + /// Polygons whose local-XY bounding box contains + /// 's XY are preferred. Among them, + /// the one with smallest |dz| wins. + /// If no poly contains the foot XY, the poly + /// with smallest |dz| across all walkable polys wins, + /// and is false. + /// + /// + public static AggregateResult AggregateNearestWalkable( + IReadOnlyDictionary resolved, + Vector3 footLocal, + float floorZ) + { + bool bestFound = false; + bool bestContainsFootXY = false; + ushort bestPolyId = 0; + float bestAbsDz = float.MaxValue; + float bestSignedDz = 0f; + float bestNormalZ = 0f; + + foreach (var kvp in resolved) + { + var poly = kvp.Value; + if (poly.Plane.Normal.Z < floorZ) continue; + if (poly.Vertices.Length < 3) continue; + + // Local-XY bounding box. + float minX = float.MaxValue, minY = float.MaxValue; + float maxX = float.MinValue, maxY = float.MinValue; + for (int i = 0; i < poly.Vertices.Length; i++) + { + var v = poly.Vertices[i]; + if (v.X < minX) minX = v.X; + if (v.Y < minY) minY = v.Y; + if (v.X > maxX) maxX = v.X; + if (v.Y > maxY) maxY = v.Y; + } + bool containsFootXY = + footLocal.X >= minX && footLocal.X <= maxX && + footLocal.Y >= minY && footLocal.Y <= maxY; + + // Signed vertical gap from foot to the polygon's plane at + // the foot's XY: plane.D + n.x*X + n.y*Y + n.z*Z = 0 + // => planeZ = -(D + n.x*X + n.y*Y) / n.z + // => dz = footZ - planeZ + float planeZ = -(poly.Plane.D + + poly.Plane.Normal.X * footLocal.X + + poly.Plane.Normal.Y * footLocal.Y) + / poly.Plane.Normal.Z; + float signedDz = footLocal.Z - planeZ; + float absDz = MathF.Abs(signedDz); + + // Preference: prefer XY-containing polys. Among the + // preferred set, smallest |dz| wins. + bool preferOver = !bestFound + || (containsFootXY && !bestContainsFootXY) + || (containsFootXY == bestContainsFootXY && absDz < bestAbsDz); + + if (preferOver) + { + bestFound = true; + bestContainsFootXY = containsFootXY; + bestPolyId = kvp.Key; + bestAbsDz = absDz; + bestSignedDz = signedDz; + bestNormalZ = poly.Plane.Normal.Z; + } + } + + return new AggregateResult + { + Found = bestFound, + PolyId = bestPolyId, + ContainsFootXY = bestContainsFootXY, + Dz = bestSignedDz, + NormalZ = bestNormalZ, + }; + } + + /// + /// Enumerates walkable-eligible polygons (normal Z >= floorZ) + /// with their local-XY bounding boxes and plane Z at the bbox + /// center. Used by the one-shot [floor-polys] cell-load + /// dump. + /// + public static IEnumerable EnumerateWalkable( + IReadOnlyDictionary resolved, + float floorZ) + { + foreach (var kvp in resolved) + { + var poly = kvp.Value; + if (poly.Plane.Normal.Z < floorZ) continue; + if (poly.Vertices.Length < 3) continue; + + float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue; + float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue; + for (int i = 0; i < poly.Vertices.Length; i++) + { + var v = poly.Vertices[i]; + if (v.X < minX) minX = v.X; + if (v.Y < minY) minY = v.Y; + if (v.Z < minZ) minZ = v.Z; + if (v.X > maxX) maxX = v.X; + if (v.Y > maxY) maxY = v.Y; + if (v.Z > maxZ) maxZ = v.Z; + } + + float cx = (minX + maxX) * 0.5f; + float cy = (minY + maxY) * 0.5f; + float planeZAtCenter = -(poly.Plane.D + + poly.Plane.Normal.X * cx + + poly.Plane.Normal.Y * cy) + / poly.Plane.Normal.Z; + + yield return new WalkableEntry + { + PolyId = kvp.Key, + NormalZ = poly.Plane.Normal.Z, + BboxMin = new Vector3(minX, minY, minZ), + BboxMax = new Vector3(maxX, maxY, maxZ), + PlaneZAtBboxCenter = planeZAtCenter, + }; + } + } +} diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs index c0fd1d09..b122baf0 100644 --- a/src/AcDream.Core/Player/LocalPlayerState.cs +++ b/src/AcDream.Core/Player/LocalPlayerState.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; using AcDream.Core.Items; using AcDream.Core.Physics; using AcDream.Core.Properties; @@ -446,51 +444,15 @@ public sealed class LocalPlayerState } /// - /// Apply a primary-attribute update — from PlayerDescription's - /// attribute block at login, or (Campaign CA CA2, #431) from the live - /// PrivateUpdateAttribute (0x02E3) record after a raise. Vital - /// ids (7..=9) here are silently dropped — feed them through - /// instead. + /// Apply a primary-attribute update from PlayerDescription's + /// attribute block (ids 1..=6). Vital ids (7..=9) here are silently + /// dropped — feed them through instead. /// - /// - /// Retail computes every derived value LIVE at inquiry - /// (CACQualities::InqSkill @ 0x00592660, - /// InqAttribute2nd), so applying the raw write is the whole - /// recompute — what remains is telling the observers. Retail's - /// notification carries no value; widgets re-pull - /// (QualityRegistrar pattern). Vitals maxima derive from - /// Endurance (health, stamina) and Self (mana), so those raises fan - /// out to the vital observers too — note ACE pushes a full Health - /// record after an Endurance raise but NOT a Stamina one, with the - /// explicit comment that the client is expected to refresh both; this - /// local fan-out is that expectation (research doc §4.1). - /// Skill snapshots cache their attribute-formula contribution - /// (), so an attribute write - /// re-derives every cached bonus through - /// before observers re-pull — - /// the compute-on-write equivalent of retail's compute-on-read; the - /// values agree at every read because the ONLY input that changes - /// between reads is exactly what this method writes. - /// public void OnAttributeUpdate(uint atType, uint ranks, uint start, uint xp) { if (AttributeIdToKind(atType) is not AttributeKind kind) return; _attrs[kind] = new AttributeSnapshot(ranks, start, xp); - RecomputeSkillFormulaBonuses(); AttributeChanged?.Invoke(kind); - switch (kind) - { - case AttributeKind.Endurance: - Changed?.Invoke(VitalKind.Health); - Changed?.Invoke(VitalKind.Stamina); - break; - case AttributeKind.Self: - Changed?.Invoke(VitalKind.Mana); - break; - } - // Skill formula contributions derive from attribute currents — - // character-sheet consumers re-pull. - CharacterChanged?.Invoke(); } /// Replace the local player's top-level property snapshot from PlayerDescription. @@ -529,98 +491,102 @@ public sealed class LocalPlayerState } /// - /// Campaign CA CA2 (#431): apply the live - /// PrivateUpdateSkill (0x02DD) record — the authoritative skill - /// state after raise / train / specialize / untrain / reset. The wire - /// record carries no attribute-formula contribution (retail computes it - /// live at inquiry), so the existing snapshot's FormulaBonus is - /// preserved — a skill update never changes attributes. A skill unseen - /// at login (fresh train) starts at 0 until the CA3 live computation - /// replaces the cached field entirely. + /// Optimistically apply a successful local attribute-raise action. + /// The next server snapshot remains authoritative; this keeps UI state current + /// during the round trip after sending the retail raise action. /// - public void OnSkillWireUpdate( - uint skillId, - uint ranks, - uint status, - uint xp, - uint init, - uint resistance, - double lastUsed) + public bool ApplyAttributeRaise(uint atType, uint amount, ulong xpSpent) { - // The wire record carries no attribute contribution (retail computes - // it live at inquiry): re-derive through the resolver when present — - // this also covers a freshly TRAINED skill unseen at login — else - // preserve the login-derived value. - uint formulaBonus = SkillFormulaBonusResolver is { } resolver - ? resolver(skillId, AttributeCurrentsById()) - : _skills.TryGetValue(skillId, out var prev) - ? prev.FormulaBonus - : 0u; - _skills[skillId] = new SkillSnapshot( - skillId, ranks, status, xp, init, resistance, lastUsed, formulaBonus); - CharacterChanged?.Invoke(); - } + if (AttributeIdToKind(atType) is not AttributeKind kind) return false; + if (!_attrs.TryGetValue(kind, out var prev)) return false; - /// - /// Campaign CA CA3 (#431): the SkillTable attribute-formula resolver — - /// the same delegate shape GameEventWiring uses at - /// PlayerDescription parse (App supplies - /// LiveSkillCreditResolver.Resolve over the loaded SkillTable; - /// headless/no-dat hosts leave it null and keep login-cached bonuses). - /// - public Func /*attrCurrents*/, uint>? - SkillFormulaBonusResolver { get; set; } - - /// - /// Campaign CA CA3 (#431): re-derive every skill snapshot's cached - /// attribute-formula contribution from the current attributes. Retail - /// re-derives at every inquiry (InqSkillBaseLevel @ 0x00592140 → - /// SkillFormula::Calculate @ 0x00591960); recomputing at the - /// only write that changes the inputs yields identical values at every - /// read. No-op without a resolver. - /// - public void RecomputeSkillFormulaBonuses() - { - if (SkillFormulaBonusResolver is not { } resolver || _skills.Count == 0) - return; - IReadOnlyDictionary currents = AttributeCurrentsById(); - foreach (uint skillId in _skills.Keys.ToArray()) + _attrs[kind] = prev with { - SkillSnapshot snap = _skills[skillId]; - uint bonus = resolver(skillId, currents); - if (bonus != snap.FormulaBonus) - _skills[skillId] = snap with { FormulaBonus = bonus }; + Ranks = SaturatingAdd(prev.Ranks, amount), + Xp = SaturatingAdd(prev.Xp, xpSpent), + }; + AttributeChanged?.Invoke(kind); + return true; + } + + /// Optimistically apply a successful local max-vital raise action. + public bool ApplyVitalRaise(uint vitalId, uint amount, ulong xpSpent) + { + if (VitalIdToKind(vitalId) is not VitalKind kind) return false; + VitalSnapshot? existing = Get(kind); + if (existing is not VitalSnapshot prev) return false; + + var snap = prev with + { + Ranks = SaturatingAdd(prev.Ranks, amount), + Xp = SaturatingAdd(prev.Xp, xpSpent), + }; + switch (kind) + { + case VitalKind.Health: _health = snap; break; + case VitalKind.Stamina: _stamina = snap; break; + case VitalKind.Mana: _mana = snap; break; } + Changed?.Invoke(kind); + return true; + } + + /// Optimistically promote an untrained skill after a successful TrainSkill action. + public bool ApplySkillTraining(uint skillId) + { + if (!_skills.TryGetValue(skillId, out var prev)) return false; + if (prev.Status >= 2u) return false; + + _skills[skillId] = prev with { Status = 2u }; + CharacterChanged?.Invoke(); + return true; + } + + /// Optimistically apply a successful local skill-raise action. + public bool ApplySkillRaise(uint skillId, uint amount, ulong xpSpent) + { + if (!_skills.TryGetValue(skillId, out var prev)) return false; + if (prev.Status < 2u) return false; + + _skills[skillId] = prev with + { + Ranks = SaturatingAdd(prev.Ranks, amount), + Xp = SaturatingAdd(prev.Xp, xpSpent), + }; + CharacterChanged?.Invoke(); + return true; } /// - /// Current attribute values keyed by wire id (1=Strength..6=Self) — the - /// dictionary shape and - /// GameEventWiring's PlayerDescription path share. + /// Optimistically debit an int property in the player's property bundle + /// (clamped at 0), firing so bound UI + /// refreshes. False if the property is absent — callers walk their + /// fallback id chain. The next server snapshot remains authoritative. + /// All local-player property writes go through eventful APIs like this + /// one; writing dictionaries directly skips the + /// change event and is a single-owner-state violation. /// - public IReadOnlyDictionary AttributeCurrentsById() + public bool DebitIntProperty(uint propertyId, int amount) { - var currents = new Dictionary(_attrs.Count); - foreach ((AttributeKind kind, AttributeSnapshot snap) in _attrs) - currents[(uint)kind + 1u] = snap.Current; - return currents; + if (!_properties.Ints.TryGetValue(propertyId, out int current)) + return false; + _properties.Ints[propertyId] = current > amount ? current - amount : 0; + CharacterChanged?.Invoke(); + return true; } /// - /// Campaign CA CA3 (#431): the movement-skill totals - /// (formulaBonus + init + ranks, ACE Skill ordinals Run=24 / - /// Jump=22) in exactly the shape GameEventWiring computes at - /// PlayerDescription parse — so live raises push the SAME numbers down - /// the SAME movement seam. −1 = skill unknown (keep the previous value). + /// Optimistically debit an int64 property (clamped at 0), firing + /// . False if the property is absent or + /// already ≤ 0. The next server snapshot remains authoritative. /// - public (int RunSkill, int JumpSkill) MovementSkillTotals() + public bool DebitInt64Property(uint propertyId, long amount) { - int run = -1, jump = -1; - if (_skills.TryGetValue(24u, out var runSnap)) - run = (int)(runSnap.FormulaBonus + runSnap.Init + runSnap.Ranks); - if (_skills.TryGetValue(22u, out var jumpSnap)) - jump = (int)(jumpSnap.FormulaBonus + jumpSnap.Init + jumpSnap.Ranks); - return (run, jump); + if (!_properties.Int64s.TryGetValue(propertyId, out long current) || current <= 0) + return false; + _properties.Int64s[propertyId] = current > amount ? current - amount : 0L; + CharacterChanged?.Invoke(); + return true; } /// diff --git a/src/AcDream.Core/Plugins/PluginCommandRegistry.cs b/src/AcDream.Core/Plugins/PluginCommandRegistry.cs deleted file mode 100644 index a162bb8b..00000000 --- a/src/AcDream.Core/Plugins/PluginCommandRegistry.cs +++ /dev/null @@ -1,142 +0,0 @@ -using AcDream.Plugin.Abstractions; - -namespace AcDream.Core.Plugins; - -/// -/// Shared host implementation of the additive plugin-command contract. -/// Registrations are exact leases; callbacks are invoked outside the registry -/// lock so a handler may submit chat or unregister itself without deadlocking. -/// -public sealed class PluginCommandRegistry : IPluginCommandRegistry -{ - private readonly object _gate = new(); - private readonly Dictionary _registrations = - new(StringComparer.OrdinalIgnoreCase); - private readonly Action? _onFailure; - - public PluginCommandRegistry(Action? onFailure = null) - { - _onFailure = onFailure; - } - - public IDisposable Register(string verb, Action handler) - { - string normalized = NormalizeVerb(verb); - ArgumentNullException.ThrowIfNull(handler); - var registration = new Registration(this, normalized, handler); - lock (_gate) - { - if (_registrations.ContainsKey(normalized)) - { - throw new InvalidOperationException( - $"Plugin command '{normalized}' is already registered."); - } - _registrations.Add(normalized, registration); - } - return registration; - } - - /// Try to consume one complete command-shaped line. - public bool TryHandle(string rawText) - { - if (string.IsNullOrWhiteSpace(rawText)) - return false; - string trimmed = rawText.Trim(); - if (trimmed.Length < 2 || trimmed[0] is not ('/' or '@')) - return false; - - int separator = trimmed.IndexOfAny([' ', '\t'], 1); - string verb = separator < 0 - ? trimmed[1..] - : trimmed[1..separator]; - if (verb.Length == 0) - return false; - - Registration? registration; - lock (_gate) - _registrations.TryGetValue(verb, out registration); - if (registration is null) - return false; - - string arguments = separator < 0 - ? string.Empty - : trimmed[(separator + 1)..].Trim(); - try - { - registration.Invoke(new PluginCommand( - registration.Verb, - arguments, - trimmed)); - } - catch (Exception error) - { - try - { - _onFailure?.Invoke(registration.Verb, error); - } - catch - { - // Diagnostics observe plugin code; they cannot poison chat. - } - } - return true; - } - - private static string NormalizeVerb(string verb) - { - ArgumentException.ThrowIfNullOrWhiteSpace(verb); - string normalized = verb.Trim().TrimStart('/', '@'); - if (normalized.Length is < 1 or > 32 - || normalized.Any(static value => !char.IsLetterOrDigit(value))) - { - throw new ArgumentException( - "Plugin command verbs must contain 1-32 letters or digits.", - nameof(verb)); - } - return normalized; - } - - private void Remove(Registration expected) - { - lock (_gate) - { - if (_registrations.TryGetValue(expected.Verb, out Registration? current) - && ReferenceEquals(current, expected)) - { - _registrations.Remove(expected.Verb); - } - } - } - - private sealed class Registration( - PluginCommandRegistry owner, - string verb, - Action handler) : IDisposable - { - private readonly object _gate = new(); - private PluginCommandRegistry? _owner = owner; - private Action? _handler = handler; - - internal string Verb { get; } = verb; - - internal void Invoke(PluginCommand command) - { - Action? callback; - lock (_gate) - callback = _handler; - callback?.Invoke(command); - } - - public void Dispose() - { - PluginCommandRegistry? currentOwner; - lock (_gate) - { - currentOwner = _owner; - _owner = null; - _handler = null; - } - currentOwner?.Remove(this); - } - } -} diff --git a/src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs b/src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs deleted file mode 100644 index ecccadf8..00000000 --- a/src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs +++ /dev/null @@ -1,151 +0,0 @@ -using AcDream.Plugin.Abstractions; - -namespace AcDream.Core.Plugins; - -/// Process-local transactional registry for external loot plugins. -public sealed class PluginLootClassifierRegistry : IPluginLootClassifierRegistry -{ - private readonly object _gate = new(); - private readonly Dictionary _entries = - new(StringComparer.OrdinalIgnoreCase); - - public IReadOnlyList Available - { - get - { - lock (_gate) - { - return _entries.Values - .Select(static entry => entry.Info) - .OrderBy(static info => info.DisplayName, - StringComparer.OrdinalIgnoreCase) - .ThenBy(static info => info.Id, - StringComparer.OrdinalIgnoreCase) - .ToArray(); - } - } - } - - public IDisposable Register( - string classifierId, - string displayName, - IPluginLootClassifier classifier) - { - ArgumentException.ThrowIfNullOrWhiteSpace(classifierId); - ArgumentException.ThrowIfNullOrWhiteSpace(displayName); - ArgumentNullException.ThrowIfNull(classifier); - string id = classifierId.Trim(); - var entry = new Entry( - new PluginLootClassifierInfo(id, displayName.Trim()), - classifier); - lock (_gate) - { - if (!_entries.TryAdd(id, entry)) - { - throw new InvalidOperationException( - $"Loot classifier '{id}' is already registered."); - } - } - return new Registration(this, id, entry); - } - - public bool TryClassify( - string classifierId, - in PluginLootClassificationContext context, - out PluginLootClassification classification) - { - Entry? entry; - lock (_gate) - _entries.TryGetValue(classifierId ?? string.Empty, out entry); - if (entry is null) - { - classification = default; - return false; - } - try - { - classification = entry.Classifier.Classify(context); - return true; - } - catch - { - classification = default; - return false; - } - } - - public bool TryNotifyLooted( - string classifierId, - in PluginLootedItem item) - { - if (!TryGetClassifier(classifierId, out IPluginLootClassifier classifier)) - return false; - try - { - classifier.OnLooted(item); - return true; - } - catch - { - return false; - } - } - - public bool TryNotifyItemRemoved(string classifierId, uint objectId) - { - if (!TryGetClassifier(classifierId, out IPluginLootClassifier classifier)) - return false; - try - { - classifier.OnItemRemoved(objectId); - return true; - } - catch - { - return false; - } - } - - private bool TryGetClassifier( - string classifierId, - out IPluginLootClassifier classifier) - { - classifier = null!; - if (string.IsNullOrWhiteSpace(classifierId)) - return false; - lock (_gate) - { - if (!_entries.TryGetValue(classifierId.Trim(), out Entry? entry)) - return false; - classifier = entry.Classifier; - return true; - } - } - - private void Remove(string id, Entry expected) - { - lock (_gate) - { - if (_entries.TryGetValue(id, out Entry? current) - && ReferenceEquals(current, expected)) - { - _entries.Remove(id); - } - } - } - - private sealed record Entry( - PluginLootClassifierInfo Info, - IPluginLootClassifier Classifier); - - private sealed class Registration( - PluginLootClassifierRegistry owner, - string id, - Entry entry) : IDisposable - { - private PluginLootClassifierRegistry? _owner = owner; - - public void Dispose() => Interlocked.Exchange(ref _owner, null)? - .Remove(id, entry); - } -} diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs index 4aa5b6b2..82d1a2d3 100644 --- a/src/AcDream.Core/Plugins/PluginSession.cs +++ b/src/AcDream.Core/Plugins/PluginSession.cs @@ -258,10 +258,7 @@ public sealed class PluginSession : IDisposable { foreach (PluginDiscoveryResult candidate in available) { - var scope = new ScopedPluginHost( - _host, - candidate.Manifest!.Id, - candidate.Manifest.DisplayName); + var scope = new ScopedPluginHost(_host); ScopedRenderPackRegistry? renderPackScope = candidate.Manifest!.Declares(PluginKind.RenderPack) && _renderPacks is not null diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs index 852bf063..9cd8179b 100644 --- a/src/AcDream.Core/Plugins/ScopedPluginHost.cs +++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs @@ -13,30 +13,14 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable private readonly ScopedEvents _events; private readonly ScopedSelectionService _selection; private readonly ScopedUiRegistry _ui; - private readonly ScopedPluginStorage _storage; - private readonly ScopedPluginCommandRegistry _commands; - private readonly ScopedLootClassifierRegistry _lootClassifiers; private bool _disposed; - internal ScopedPluginHost( - IPluginHost inner, - string pluginId, - string pluginDisplayName) + internal ScopedPluginHost(IPluginHost inner) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); - ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); - ArgumentException.ThrowIfNullOrWhiteSpace(pluginDisplayName); _events = new ScopedEvents(inner.Events); _selection = new ScopedSelectionService(inner.Selection); - _ui = new ScopedUiRegistry( - inner.Ui, - new PluginUiOwner(pluginId, pluginDisplayName)); - _storage = new ScopedPluginStorage(inner.Storage, pluginId); - _commands = new ScopedPluginCommandRegistry(inner.Commands); - _lootClassifiers = new ScopedLootClassifierRegistry( - inner.LootClassifiers, - pluginId, - pluginDisplayName); + _ui = new ScopedUiRegistry(inner.Ui); } public bool HasUi => _inner.HasUi; @@ -45,9 +29,6 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable public IEvents Events => _events; public ISelectionService Selection => _selection; public IUiRegistry Ui => _ui; - public IPluginStorage Storage => _storage; - public IPluginCommandRegistry Commands => _commands; - public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers; /// /// Delegated rather than scoped, unlike , @@ -64,48 +45,6 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable /// public IAutomationSurface Automation => _inner.Automation; - private sealed class ScopedPluginStorage( - IPluginStorage inner, - string pluginId) : IPluginStorage - { - public bool IsAvailable => inner.IsAvailable; - public string? ReadText(string key) => - inner.ReadText(ScopedKey(key)); - public IReadOnlyList List(string prefix) - { - string scopedPrefix = ScopedKey(prefix); - string ownerPrefix = pluginId + Path.DirectorySeparatorChar; - return inner.List(scopedPrefix) - .Select(key => key.Replace('/', Path.DirectorySeparatorChar)) - .Where(key => key.StartsWith( - ownerPrefix, - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal)) - .Select(key => key[ownerPrefix.Length..] - .Replace(Path.DirectorySeparatorChar, '/')) - .ToArray(); - } - public void WriteText(string key, string content) => - inner.WriteText(ScopedKey(key), content); - public bool Delete(string key) => inner.Delete(ScopedKey(key)); - - private static string ValidateKey(string key) - { - ArgumentException.ThrowIfNullOrWhiteSpace(key); - if (Path.IsPathRooted(key) - || key.Contains("..", StringComparison.Ordinal) - || key.Contains('\\')) - { - throw new ArgumentException("Invalid plugin storage key.", nameof(key)); - } - return key.Replace('/', Path.DirectorySeparatorChar); - } - - private string ScopedKey(string key) => - Path.Combine(pluginId, ValidateKey(key)); - } - public void Dispose() { if (_disposed) @@ -114,127 +53,6 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable _events.Dispose(); _selection.Dispose(); _ui.Dispose(); - _commands.Dispose(); - _lootClassifiers.Dispose(); - } - - private sealed class ScopedLootClassifierRegistry( - IPluginLootClassifierRegistry inner, - string pluginId, - string pluginDisplayName) - : IPluginLootClassifierRegistry, - IDisposable - { - private readonly object _gate = new(); - private readonly List _registrations = []; - private bool _disposed; - - public IReadOnlyList Available => - inner.Available; - - public IDisposable Register( - string classifierId, - string displayName, - IPluginLootClassifier classifier) - { - ObjectDisposedException.ThrowIf(_disposed, this); - ArgumentException.ThrowIfNullOrWhiteSpace(classifierId); - string local = classifierId.Trim(); - if (local.Contains('/') || local.Contains('\\')) - { - throw new ArgumentException( - "A classifier id cannot contain a path separator.", - nameof(classifierId)); - } - string effectiveName = string.IsNullOrWhiteSpace(displayName) - ? pluginDisplayName - : displayName.Trim(); - IDisposable registration = inner.Register( - $"{pluginId}/{local}", - effectiveName, - classifier); - lock (_gate) - { - if (!_disposed) - { - _registrations.Add(registration); - return registration; - } - } - registration.Dispose(); - throw new ObjectDisposedException(nameof(ScopedLootClassifierRegistry)); - } - - public bool TryClassify( - string classifierId, - in PluginLootClassificationContext context, - out PluginLootClassification classification) => - inner.TryClassify(classifierId, context, out classification); - - public bool TryNotifyLooted( - string classifierId, - in PluginLootedItem item) => - inner.TryNotifyLooted(classifierId, item); - - public bool TryNotifyItemRemoved( - string classifierId, - uint objectId) => - inner.TryNotifyItemRemoved(classifierId, objectId); - - public void Dispose() - { - IDisposable[] registrations; - lock (_gate) - { - if (_disposed) - return; - _disposed = true; - registrations = _registrations.ToArray(); - _registrations.Clear(); - } - for (int index = registrations.Length - 1; index >= 0; index--) - registrations[index].Dispose(); - } - } - - private sealed class ScopedPluginCommandRegistry(IPluginCommandRegistry inner) - : IPluginCommandRegistry, - IDisposable - { - private readonly object _gate = new(); - private readonly List _registrations = []; - private bool _disposed; - - public IDisposable Register(string verb, Action handler) - { - ObjectDisposedException.ThrowIf(_disposed, this); - IDisposable registration = inner.Register(verb, handler); - lock (_gate) - { - if (!_disposed) - { - _registrations.Add(registration); - return registration; - } - } - registration.Dispose(); - throw new ObjectDisposedException(nameof(ScopedPluginCommandRegistry)); - } - - public void Dispose() - { - IDisposable[] registrations; - lock (_gate) - { - if (_disposed) - return; - _disposed = true; - registrations = _registrations.ToArray(); - _registrations.Clear(); - } - for (int index = registrations.Length - 1; index >= 0; index--) - registrations[index].Dispose(); - } } private sealed class ScopedSelectionService(ISelectionService inner) @@ -479,92 +297,22 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable private sealed class ScopedUiRegistry : IUiRegistry, IDisposable { private readonly IScopedUiRegistry _inner; - private readonly PluginUiOwner _owner; private readonly object _gate = new(); private readonly List _registrations = []; private bool _disposed; - internal ScopedUiRegistry(IUiRegistry inner, PluginUiOwner owner) + internal ScopedUiRegistry(IUiRegistry inner) { _inner = inner as IScopedUiRegistry ?? throw new InvalidOperationException( "Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back."); - _owner = owner; } public void AddMarkupPanel(string markupPath, object binding) { - AddRegistration(_inner.RegisterPanel( - _owner, - new PluginPanelDescriptor( - Path.GetFileNameWithoutExtension(markupPath), - _owner.DisplayName), + IDisposable registration = _inner.RegisterMarkupPanel( markupPath, - binding)); - } - - public void AddPanel( - PluginPanelDescriptor descriptor, - string markupPath, - object binding) - { - ArgumentNullException.ThrowIfNull(descriptor); - AddRegistration(_inner.RegisterPanel( - _owner, - descriptor, - markupPath, - binding)); - } - - public IDisposable RegisterPanel( - PluginPanelDescriptor descriptor, - string markupPath, - object binding) - { - ArgumentNullException.ThrowIfNull(descriptor); - return TrackRegistration(_inner.RegisterPanel( - _owner, - descriptor, - markupPath, - binding)); - } - - public IDisposable RegisterPanelContent( - PluginPanelDescriptor descriptor, - string markupContent, - object binding) - { - ArgumentNullException.ThrowIfNull(descriptor); - return TrackRegistration(_inner.RegisterPanelContent( - _owner, - descriptor, - markupContent, - binding)); - } - - public bool ViewExists(string viewName) => - _inner.ViewExists(_owner, viewName); - - public bool IsViewVisible(string viewName) => - _inner.IsViewVisible(_owner, viewName); - - public bool ControlExists(string viewName, string controlName) => - _inner.ControlExists(_owner, viewName, controlName); - - public bool SetControlLabel( - string viewName, - string controlName, - string label) => - _inner.SetControlLabel(_owner, viewName, controlName, label); - - public bool SetControlVisible( - string viewName, - string controlName, - bool visible) => - _inner.SetControlVisible(_owner, viewName, controlName, visible); - - private void AddRegistration(IDisposable registration) - { + binding); lock (_gate) { if (!_disposed) @@ -578,31 +326,6 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable throw new ObjectDisposedException(nameof(ScopedUiRegistry)); } - private IDisposable TrackRegistration(IDisposable registration) - { - lock (_gate) - { - if (!_disposed) - { - _registrations.Add(registration); - return new IndividualRegistration(this, registration); - } - } - - registration.Dispose(); - throw new ObjectDisposedException(nameof(ScopedUiRegistry)); - } - - private void RemoveRegistration(IDisposable registration) - { - lock (_gate) - { - if (!_registrations.Remove(registration)) - return; - } - registration.Dispose(); - } - public void Dispose() { IDisposable[] registrations; @@ -621,15 +344,5 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable catch { } } } - - private sealed class IndividualRegistration( - ScopedUiRegistry owner, - IDisposable registration) : IDisposable - { - private ScopedUiRegistry? _owner = owner; - - public void Dispose() => Interlocked.Exchange(ref _owner, null)? - .RemoveRegistration(registration); - } } } diff --git a/src/AcDream.Core/Properties/PropertyString.cs b/src/AcDream.Core/Properties/PropertyString.cs index d64d5cfc..0e2f0b03 100644 --- a/src/AcDream.Core/Properties/PropertyString.cs +++ b/src/AcDream.Core/Properties/PropertyString.cs @@ -10,7 +10,7 @@ namespace AcDream.Core.Properties; /// /// AC's PropertyString property table — the numeric keys the server sends in -/// PrivateUpdatePropertyString (0x02D5) / PublicUpdatePropertyString (0x02D6) +/// PrivateUpdatePropertyString (0x02DD) / PublicUpdatePropertyString (0x02DE) /// and in the property bundles carried by CreateObject / PlayerDescription / /// IdentifyResponse. The CLR payload for this table is string. /// diff --git a/src/AcDream.Core/Rendering/CameraDiagnostics.cs b/src/AcDream.Core/Rendering/CameraDiagnostics.cs index 02a8d448..0ceb5eef 100644 --- a/src/AcDream.Core/Rendering/CameraDiagnostics.cs +++ b/src/AcDream.Core/Rendering/CameraDiagnostics.cs @@ -6,8 +6,7 @@ namespace AcDream.Core.Rendering; /// Runtime-tunable knobs for the retail-faithful chase camera. Mirrors /// the pattern: /// static fields seeded from env vars at process start, runtime-settable -/// via property setters. (#434: the DebugPanel that used to write them has -/// been unreachable since Campaign V slice V11.) +/// via property setters that the DebugPanel writes to. /// /// /// Spec: docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md. @@ -22,7 +21,7 @@ public static class CameraDiagnostics /// AcDream.App.Rendering.ChaseCamera rigid-follow camera is. /// Initial state from ACDREAM_RETAIL_CHASE — default-on if /// unset, off only when explicitly set to "0". The legacy - /// camera stays available by assigning this property pending the + /// camera stays available via the DebugPanel toggle pending the /// follow-up deletion commit. /// public static bool UseRetailChaseCamera { get; set; } = diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 634627f4..4340318b 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -8,11 +8,9 @@ namespace AcDream.Core.Rendering; /// /// 2026-05-19 — runtime-toggleable diagnostic flags for the indoor cell /// rendering pipeline. Initialized from env vars at process start; -/// flippable at runtime by direct assignment. Log call sites read these -/// statics so a change takes effect on the next frame without relaunching. -/// (#434: these used to have a DebugPanel checkbox mirror. That panel has -/// been unreachable since Campaign V slice V11 removed its ImGui host, so -/// every flag here is startup-or-assignment only.) +/// flippable at runtime via the DebugPanel mirror. Log call sites read +/// these statics so a checkbox toggle takes effect on the next frame +/// without relaunching. /// /// /// Mirrors the L.2a @@ -92,13 +90,40 @@ public static class RenderingDiagnostics /// Initial state from ACDREAM_PROBE_VIS=1. /// /// Phase U.2d (2026-05-30) repurposed this flag from the abandoned A8 - /// two-pipe stencil pass to the Phase U unified pipeline. The env var name - /// is unchanged (its DebugPanel mirror is gone — #434). + /// two-pipe stencil pass to the Phase U unified pipeline. The env var name + + /// the DebugPanel mirror (DebugVM.ProbeVisibility) are unchanged. /// /// public static bool ProbeVisibilityEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIS") == "1"; + /// + /// #119-residual viewer/flood capture (2026-06-11): one [viewer] + /// line per CHANGE of (root cell, flood size, OutsideView poly count, + /// player cell), with the projection EYE at mm precision on every line — + /// the capture half of the tower-ascent capture→replay loop + /// (TowerAscentReplayTests replays the captured pairs deterministically). + /// Light: silent while the visibility state is stable; a tower climb + /// emits a few dozen lines. Initial state from + /// ACDREAM_PROBE_VIEWER=1. + /// + public static bool ProbeViewerEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIEWER") == "1"; + + /// + /// #131 (2026-06-12) outside-stage dynamics probe. When true, the renderer + /// emits one [outstage] line per CHANGE of the outside-stage + /// routing + per-slice cone verdict set under an interior root (which + /// outdoor dynamics were routed to the landscape slice, which survived the + /// slice viewcone), and GameWindow emits one [outstage-pt] line per + /// change of the slice Scene-particle id set + matched-emitter count. + /// Built for the portal-swirl-missing-through-doorway capture. Light: + /// silent while the set is stable. Initial state from + /// ACDREAM_PROBE_OUTSTAGE=1. + /// + public static bool ProbeOutStageEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_OUTSTAGE") == "1"; + /// /// Phase U.4c (2026-05-31) flap-convergence probe. When true, the portal /// visibility pass emits, EVERY frame the camera root is an indoor cell, a @@ -117,6 +142,21 @@ public static class RenderingDiagnostics public static bool ProbeFlapEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_FLAP") == "1"; + /// + /// Issue #78 (2026-05-31) cell-shell render probe. When true, + /// EnvCellRenderer.Render emits one [shell] line per opaque-pass + /// call: per visible (filtered) cell — is it present in the prepared snapshot, + /// how many gfxObjs + instances, and per-gfxObj batch count / index count / + /// translucent / zero-bindless-handle (missing texture) — plus the pass totals. + /// This directly answers WHY interior walls/ceilings don't appear: no geometry + /// prepared for the cell (cell absent / 0 instances), drawn-but-invisible + /// (zeroHandle / translucent against the clear color), or prepared+drawn (so the + /// fault is elsewhere — depth/occlusion). Throwaway apparatus — strip once the + /// indoor-enclosure render is fixed. Initial state from ACDREAM_PROBE_SHELL=1. + /// + public static bool ProbeShellEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_SHELL") == "1"; + /// /// Flap root-cause apparatus (2026-06-07). When true, the indoor render path emits ONE /// [pv-input] line per frame with the EXACT PortalVisibilityBuilder.Build inputs at HIGH @@ -160,6 +200,21 @@ public static class RenderingDiagnostics public static bool ProbeClipRouteEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_CLIPROUTE") == "1"; + /// + /// #105 white-indoor-textures apparatus (2026-06-10). When true, WbMeshAdapter.Tick + /// emits one [tex-flush] line whenever the staged-texture-update picture changes: + /// pending layer updates across all shared atlases BEFORE and AFTER the per-frame + /// ObjectMeshManager.GenerateMipmaps() flush, plus arrays-with-pending / total-array + /// counts. The broken contract this pins: TextureAtlasManager.AddTexture only STAGES + /// pixel data (PBO + pending list); without the per-frame flush (WB GameScene.cs:975) the + /// data never reaches the GL texture and the batch samples undefined content behind a valid + /// bindless handle — the classic white walls. A healthy run shows after=0 on every + /// line; a stuck before==after>0 at standstill is the #105 mechanism live. + /// Initial state from ACDREAM_PROBE_TEXFLUSH=1. + /// + public static bool ProbeTexFlushEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_TEXFLUSH") == "1"; + /// /// Bounded-propagation port apparatus (2026-06-08). When true, PortalVisibilityBuilder.Build emits /// one [portal-churn] summary line per call: per-cell pop count (re-pops = churn), total re-enqueues, @@ -171,6 +226,52 @@ public static class RenderingDiagnostics public static bool ProbePortalChurnEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PORTAL_CHURN") == "1"; + /// + /// BR-2 phantom-site probe (2026-06-11; plan + /// docs/plans/2026-06-11-building-render-port-plan.md §BR-2 first + /// task). The BR-1 pre-check proved the #113 phantom residual cannot be + /// GfxObj portal fills (never extracted); the surviving suspects are + /// cell-side. When true, RetailPViewRenderer emits, print-on-change + /// per cell: [phantom-shell] — per shell-pass cell, the clip-enable + /// state and each drawn slice's slot + plane count, flagging the pass-all + /// cases (NoClipSlice fallback for slot-less cells; assembler slot-0 + /// scissor fallback) — and [phantom-objs] — per object-list cell, + /// the entity-bucket size drawn unclipped/un-viewcone'd. Reproducing the + /// phantom with this on pins which mechanism draws it (shells → BR-2/BR-3; + /// statics → BR-5). Throwaway apparatus — strip when the phantom closes. + /// Initial state from ACDREAM_PROBE_PHANTOM=1. + /// + public static bool ProbePhantomEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_PHANTOM") == "1"; + + /// + /// #133 A7 (2026-06-13) dungeon-lighting objective probe. When true, + /// the per-frame scene-lighting build emits ONE [light] line + /// roughly every second (wall-clock rate-limited like WB-DIAG) via + /// : + /// + /// [light] insideCell=<bool> ambient=(r,g,b) sun=<intensity> + /// registeredLights=<N> activeLights=<uCellAmbient.w> playerCell=0x<id> + /// + /// This is the self-verification signal for the dungeon-dim question: + /// + /// insideCell=true ambient=(0.20,0.20,0.20) sun=0 + /// confirms the indoor branch fired (retail flat ambient, sun killed). + /// registeredLights is the count of dat-baked + /// point/spot lights (Setup.Lights) registered with the + /// LightManager — if this is 0 in a dungeon, the cell's static + /// objects carry no baked torches (so the only illumination IS the + /// 0.2 ambient → dim). + /// activeLights is uCellAmbient.w — the + /// shader's active-slot count, which INCLUDES the (zeroed) sun slot + /// indoors. So activeLights=1 registeredLights=0 = "only the dead + /// sun slot, no torches in range". + /// + /// Output-only, inert when off. Initial state from ACDREAM_PROBE_LIGHT=1. + /// + public static bool ProbeLightEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1"; + /// /// A7.L1 (2026-07-06) light-pool SET-COMPOSITION probe — the apparatus the /// [light] counts could not provide (the #176/#177 discriminator: the bug @@ -251,8 +352,8 @@ public static class RenderingDiagnostics /// (the intensity-100 portal purples + the viewer fill off; statics stay); /// 3 = raw vLit visualization in the fragment shader (texture ignored). /// Discriminates lighting-driven stripes (gone at 1/2, visible in the field - /// at 3) from texture/per-pixel machinery (survive 1). Settable at - /// runtime by direct assignment. + /// at 3) from texture/per-pixel machinery (survive 1). Settable for a + /// future DebugPanel mirror. /// public static int LightDebugMode { get; set; } = int.TryParse( @@ -393,8 +494,94 @@ public static class RenderingDiagnostics /// internal static void ResetVisibilityProbeForTests() => _lastVisRootCellId = 0; + // Wall-clock rate-limit gate for EmitLight. Ticks (100 ns) is plenty — + // we only need ~1 Hz and avoid a Stopwatch allocation/field. Sentinel 0 + // = "never emitted" so the first call always fires. + private static long _lastLightEmitTicks; private const long LightEmitIntervalTicks = 10_000_000; // 1 s in 100-ns ticks + /// + /// #133 A7 — emit ONE rate-limited [light] line describing the + /// current scene-lighting state, followed (when + /// is supplied) by up to three [light-detail] lines for the nearest + /// ACTIVE point/spot lights. Cheap no-op when + /// is false; otherwise fires at most + /// once per second. Pull the values from the spot where + /// GameWindow.UpdateSunFromSky set Lighting.CurrentAmbient + /// / Lighting.Sun and where SceneLightingUbo.Build computed + /// the active-slot count. + /// + /// The [light-detail] lines are the answer to the "candle-spotlight" + /// question — they expose each torch's REAL dat-derived runtime values + /// (range= Falloff metres, intensity=, cone= radians, + /// color=, distToViewer=) so it is visible in launch.log + /// whether dungeon torches are tiny-range points or wide cones and at what + /// intensity — without a screenshot: + /// + /// [light-detail] kind=Point range=<Falloff m> intensity=<I> cone=<rad> color=(r,g,b) distToViewer=<m> + /// + /// + /// + /// The playerInsideCell value driving the indoor branch. + /// Cell ambient red (xyz of uCellAmbient). + /// Cell ambient green. + /// Cell ambient blue. + /// The sun LightSource.Intensity (0 indoors). + /// Total point/spot lights registered with the LightManager. + /// uCellAmbient.w — shader active-slot count (includes the zeroed sun slot indoors). + /// The player's current cell id (0 if unresolved → outside). + /// The ticked LightManager (its Active list, sorted nearest-first by the + /// just-completed Tick). When non-null, drives the [light-detail] lines. Optional so existing call + /// sites / tests that only want the aggregate line keep compiling. + public static void EmitLight(bool insideCell, + float ambientR, float ambientG, float ambientB, + float sunIntensity, + int registeredLights, + int activeLights, + uint playerCellId, + AcDream.Core.Lighting.LightManager? lights = null) + { + if (!ProbeLightEnabled) return; + + long now = DateTime.UtcNow.Ticks; + if (_lastLightEmitTicks != 0 && (now - _lastLightEmitTicks) < LightEmitIntervalTicks) + return; + _lastLightEmitTicks = now; + + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[light] insideCell={0} ambient=({1:0.###},{2:0.###},{3:0.###}) sun={4:0.###} registeredLights={5} activeLights={6} playerCell=0x{7:X8}", + insideCell, ambientR, ambientG, ambientB, sunIntensity, + registeredLights, activeLights, playerCellId)); + + // #133 A7 (2026-06-13) — per-light detail for the "spotlight bubble" + // question. Dump the actual runtime dat-derived values of the nearest + // ~3 ACTIVE point/spot lights so the real Falloff/Intensity/ConeAngle + // are visible in launch.log (are torch ranges 1m or 10m? points or + // spots? what intensity?). The sun (Directional, slot 0) is skipped — + // it carries no Range/cone meaning. DistSq is already cached by + // LightManager.Tick this frame, so the active list is sorted nearest- + // first; we just take the first few non-directional entries. + if (lights is null) return; + var active = lights.Active; + int shown = 0; + const int MaxDetail = 3; + for (int i = 0; i < active.Length && shown < MaxDetail; i++) + { + var ls = active[i]; + if (ls is null) continue; + if (ls.Kind == AcDream.Core.Lighting.LightKind.Directional) continue; + + float dist = ls.DistSq >= 0f ? MathF.Sqrt(ls.DistSq) : 0f; + Console.WriteLine(string.Format(ci, + "[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###} owner=0x{8:X8} cell=0x{9:X8} dyn={10}", + ls.Kind, ls.Range, ls.Intensity, ls.ConeAngle, + ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist, + ls.OwnerId, ls.CellId, ls.IsDynamic ? 1 : 0)); + shown++; + } + } + // Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval). private static long _lastIndoorLightEmitTicks; @@ -505,6 +692,27 @@ public static class RenderingDiagnostics /// public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u; + /// + /// #119 tower-staircase decisive probe (2026-06-11). Comma-separated + /// Setup / GfxObj source ids (hex, optional 0x prefix) from + /// ACDREAM_DUMP_ENTITY. Any WorldEntity whose + /// SourceGfxObjOrSetupId is in this set emits: + /// (a) a [dump-entity] HYDRATE dump at MeshRef construction time + /// (GameWindow.BuildInteriorEntitiesForStreaming) — per-part + /// placement-frame translations + dropped-part accounting — discriminating + /// hydration-time corruption (H-A: SetupMesh.Flatten identity fallback / + /// silent gfx-null part drops under degraded dat reads); + /// (b) a [dump-entity] DRAW dump in WbDrawDispatcher at first + /// draw — live MeshRefs translations + Tier-1 classification cache state — + /// re-emitted compactly whenever that state changes (H-B: stale/partial + /// cached batch set); and + /// (c) rate-limited [dump-entity] WALK-REJECT lines when the + /// dispatcher's walk filters the entity out (absence-of-draw attribution). + /// Empty set = probe off; every call site early-outs on Count == 0. + /// + public static IReadOnlySet DumpEntitySourceIds { get; } = + ParseDumpEntityIds(Environment.GetEnvironmentVariable("ACDREAM_DUMP_ENTITY")); + /// /// Parse the ACDREAM_DUMP_ENTITY value: comma-separated hex ids, /// optional 0x prefix, whitespace tolerated, malformed segments ignored @@ -556,14 +764,12 @@ public static class RenderingDiagnostics /// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution, /// per-frame allocation counters, reported as one [frame-prof] /// line every ~5 s. Permanent apparatus (every MP-track gate reads it) — - /// do NOT strip with session probes. This paragraph previously claimed - /// the whole-frame GPU query self-disables under ACDREAM_WB_DIAG=1; - /// Campaign V slice V11 deleted that self-disable along with the GL query - /// ring it protected, and the two flags are now independent — see - /// FrameProfiler's own class doc. Every backend reports GPU time - /// through FrameProfiler.RecordGpuSample. + /// do NOT strip with session probes. The whole-frame GPU query is + /// self-disabled while ACDREAM_WB_DIAG=1 (GL forbids nested + /// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under + /// that flag — the 2026-06-23 "separate flags" measurement lesson). /// Initial state from ACDREAM_FRAME_PROF=1; runtime-toggleable - /// by direct assignment (its DebugPanel mirror is gone — #434). + /// via the DebugPanel mirror (DebugVM.FrameProf). /// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. /// public static bool FrameProfEnabled { get; set; } = @@ -584,4 +790,52 @@ public static class RenderingDiagnostics /// public static string? FrameHistoryPath { get; } = Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY"); + + // ── #337 collision-mesh wireframe (2026-08-06 — TEMPORARY) ────────────── + // + // The F2 collision overlay already existed, but for a BSP object it drew a + // proxy cylinder sized from the REGISTERED BROADPHASE RADIUS. That shows + // where the collision system thinks the object roughly is; it cannot show + // where the collision SURFACES are, which is the only thing that answers + // "is the collision geometry where the visual geometry is". The knobs + // below turn F2 into the real answer: the actual physics-BSP polygon + // edges, in world space, next to the same object's visual mesh box. + // + // Off by default, so F2 keeps its old cheap behaviour for anyone who wants + // it and this costs nothing until asked for. + + /// + /// When true, the F2 collision overlay draws each nearby object's REAL + /// physics-BSP polygon edges (cyan) and, beside them, the same object's + /// visual mesh bounding box (magenta), plus the terrain triangle under the + /// player (yellow). Any separation between the cyan surfaces and the + /// object you can see is the "collision is not where the visual is" + /// defect, read directly off the screen instead of inferred from a log. + /// Initial state from ACDREAM_WIRE_MESH=1. + /// TEMPORARY — strip with the #337 probe family. + /// + public static bool CollisionMeshWireframeEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_WIRE_MESH") == "1"; + + /// + /// Radius in metres around the player within which + /// resolves polygon geometry. + /// A whole landblock of rock is far more geometry than a line list wants; + /// 30 m covers everything you can wedge against. Override with + /// ACDREAM_WIRE_RADIUS=<metres>. + /// + public static float CollisionMeshWireframeRadius { get; set; } = + ParsePositiveFloat( + Environment.GetEnvironmentVariable("ACDREAM_WIRE_RADIUS"), + fallback: 30f); + + private static float ParsePositiveFloat(string? raw, float fallback) + => float.TryParse( + raw, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out float value) + && value > 0f + ? value + : fallback; } diff --git a/src/AcDream.Core/World/TeleportAnimSequencer.cs b/src/AcDream.Core/World/TeleportAnimSequencer.cs index 30c2ee7d..1af2ce57 100644 --- a/src/AcDream.Core/World/TeleportAnimSequencer.cs +++ b/src/AcDream.Core/World/TeleportAnimSequencer.cs @@ -53,13 +53,7 @@ public sealed class TeleportAnimSequencer public const int TunnelEndFrame = 120; public const float ExitWindowLow = FadeTime + 0.1f; public const float ExitWindowHigh = FadeTime + 0.3f; - // The retail viewport swap happens before the finite portal mesh reaches - // the final near-180-degree samples. Full-width 1280x720 captures establish - // level 1001 as the last radial field that covers the viewport; 1006 starts - // exposing the mesh perimeter and 1013 can leave its complete faceted rim - // on screen. Keep the cutoff in the quantized retail table domain so - // refresh rate cannot move the swap. - internal const short LastVisibleOutgoingAnimationLevel = 1001; + private const short LastVisibleOutgoingAnimationLevel = 1022; // UIGlobals::Init @ 0x004EE470. Retail integrates 100 integer sine // samples into a 0..1024 easing table. @@ -108,18 +102,10 @@ public sealed class TeleportAnimSequencer /// Advance the machine by seconds. /// = the complete destination-load barrier is /// satisfied (Near-tier render meshes/textures plus collision residency). - /// is diagnostic apparatus: when true the - /// machine observes readiness but withholds the Place edge and stays - /// in the stable tunnel state. The default is false and retail behavior is - /// unchanged. /// Returns the current snapshot + edge-triggered events fired THIS tick. /// public (TeleportAnimSnapshot snapshot, IReadOnlyList events) - Tick( - float dt, - bool worldReady, - int tunnelAnimationFrame = 72, - bool holdInTunnel = false) + Tick(float dt, bool worldReady, int tunnelAnimationFrame = 72) { var evts = new List(); @@ -147,7 +133,7 @@ public sealed class TeleportAnimSequencer case TeleportAnimState.Tunnel: // Hold here until worldReady (EndTeleportAnimation analogue). - if (worldReady && !holdInTunnel) + if (worldReady) { evts.Add(TeleportAnimEvent.Place); Advance(TeleportAnimState.TunnelContinue, enterTunnel: false); @@ -197,9 +183,10 @@ public sealed class TeleportAnimSequencer /// /// Retire an outgoing viewport before the first quantized projection that - /// exposes the finite tunnel boundary. Full-width captures show 1001 as - /// the last covered sample and 1006 as the first divergent faceted edge. - /// An uncapped modern loop can otherwise publish those terminal samples + /// exposes the finite tunnel boundary. Retail's whole-frame black clear and + /// frame-paced D3D presentation show level 1022 as the last tunnel sample; + /// paired captures show no 1023/1024 portal frame before the world swap. + /// An uncapped modern loop can otherwise publish those sub-20 ms samples /// and let the desktop compositor hold one for a complete display refresh. /// private bool OutgoingViewportReachedTerminalProjection() diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 76e2eacf..6bf5f0e1 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -27,12 +27,6 @@ internal sealed class HeadlessContentDescriptor [JsonRequired] public string PreparedAssetPath { get; init; } = string.Empty; - - public string? PreparedAssetOverlayPath { get; init; } - - public uint? PreparedAssetBaseRecipeVersion { get; init; } - - public uint? PreparedAssetEffectiveRecipeVersion { get; init; } } // MF-1 (Campaign OP OP7 review fix, 2026-08-11): record, not class — the diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index d6fb73d8..389b2a6f 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -173,18 +173,6 @@ internal static class HeadlessConfigurationLoader throw new HeadlessConfigurationException( "process.content requires non-empty datDirectory and preparedAssetPath."); } - - bool hasOverlay = !string.IsNullOrWhiteSpace( - content.PreparedAssetOverlayPath); - bool hasBaseRecipe = content.PreparedAssetBaseRecipeVersion is > 0; - bool hasEffectiveRecipe = - content.PreparedAssetEffectiveRecipeVersion is > 0; - if (hasOverlay != hasBaseRecipe || hasOverlay != hasEffectiveRecipe) - { - throw new HeadlessConfigurationException( - "process.content overlay path, base recipe, and effective recipe " - + "must be supplied together."); - } } private static void ValidateSession( diff --git a/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs b/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs index 0fa42185..bafa7a94 100644 --- a/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs @@ -15,7 +15,7 @@ internal sealed class HeadlessMovementInputSource( public MovementInput Capture() { if (_movement.HasCommandInput) - return _movement.CommandInput with { IsPersistentCommand = true }; + return _movement.CommandInput; return new MovementInput( Forward: _movement.AutoRunActive, Run: true); diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs index 45286e52..5c42ea75 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs @@ -43,56 +43,15 @@ internal sealed class ProductionHeadlessProcessContentFactory string datDirectory = Path.GetFullPath(descriptor.DatDirectory); string preparedAssetPath = Path.GetFullPath(descriptor.PreparedAssetPath); - string? overlayPath = string.IsNullOrWhiteSpace( - descriptor.PreparedAssetOverlayPath) - ? null - : Path.GetFullPath(descriptor.PreparedAssetOverlayPath); IDatReaderWriter? dats = null; IPreparedAssetSource? prepared = null; try { dats = RuntimeDatCollectionFactory.OpenReadOnly(datDirectory); - if (overlayPath is null) - { - prepared = new PakPreparedAssetSource( - preparedAssetPath, - dats, - diagnostic); - } - else - { - if (descriptor.PreparedAssetBaseRecipeVersion is not > 0 - || descriptor.PreparedAssetEffectiveRecipeVersion - != AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion) - { - throw new InvalidDataException( - "Layered prepared content does not match the client's recipe."); - } - - var baseSource = new PakPreparedAssetSource( - preparedAssetPath, - PreparedAssetCatalogIdentity.From( - dats, - descriptor.PreparedAssetBaseRecipeVersion.Value), - diagnostic); - try - { - var overlaySource = new PakPreparedAssetSource( - overlayPath, - PreparedAssetCatalogIdentity.From( - dats, - descriptor.PreparedAssetEffectiveRecipeVersion.Value), - diagnostic); - prepared = new LayeredPreparedAssetSource( - baseSource, - overlaySource); - } - catch - { - baseSource.Dispose(); - throw; - } - } + prepared = new PakPreparedAssetSource( + preparedAssetPath, + dats, + diagnostic); MagicCatalog magic = MagicCatalog.Load(dats); Region region = dats.Get(0x13000000u) ?? throw new InvalidOperationException( diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 46ab383f..a0140d36 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -341,13 +341,7 @@ internal sealed class HeadlessSessionHost : IDisposable // descriptor.StatusFile is unset — every call site below stays // unconditional. var statusWriter = new SessionStatusWriter(descriptor.StatusFile); - var pluginCommands = new AcDream.Core.Plugins.PluginCommandRegistry( - (verb, error) => diagnostics.Failure( - descriptor.Id, - $"plugin-command-{verb}", - error)); - var chatCommandSurface = new LiveChatCommandSurface( - pluginCommands.TryHandle); + var chatCommandSurface = new LiveChatCommandSurface(); var loginCommands = new LoginCommandSequence( descriptor.LoginCommands, TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs), @@ -365,8 +359,7 @@ internal sealed class HeadlessSessionHost : IDisposable statusWriter, descriptor.Id, pluginRoots ?? [], - descriptor.Plugins, - pluginCommands); + descriptor.Plugins); var liveSession = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( @@ -1208,7 +1201,6 @@ internal sealed class HeadlessSessionHost : IDisposable { Runtime.InventoryOwner.ExternalContainers .ApplyUseDone(error); - Runtime.ActionOwner.SpellCast.CompleteUse(error); Runtime.ActionOwner.Transactions.CompleteUse(error); }, Runtime.InventoryOwner.ItemMana, diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs index 23909378..45b99503 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -38,18 +38,15 @@ internal sealed class HeadlessPluginHost internal HeadlessPluginHost( GameRuntime runtime, - IPluginLogger logger, - IPluginCommandRegistry? commands = null) + IPluginLogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); Log = logger ?? throw new ArgumentNullException(nameof(logger)); - Commands = commands ?? NoOpPluginCommandRegistry.Instance; _eventSubscription = runtime.Subscribe(this); } public bool HasUi => false; public IPluginLogger Log { get; } - public IPluginCommandRegistry Commands { get; } public IGameState State => this; public IEvents Events => this; public ISelectionService Selection => _runtime.ActionOwner.Selection; diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs index 9110cb07..6791670f 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs @@ -44,8 +44,7 @@ internal sealed class HeadlessPluginSession : IDisposable SessionStatusWriter statusWriter, string sessionId, IEnumerable roots, - IReadOnlyList? allowList, - IPluginCommandRegistry? commands = null) + IReadOnlyList? allowList) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(diagnostics); @@ -58,8 +57,7 @@ internal sealed class HeadlessPluginSession : IDisposable new HeadlessPluginLogger( diagnostics, sessionId, - () => runtime.Generation.Value), - commands); + () => runtime.Generation.Value)); var plugins = new PluginSession( host, status => Report(statusWriter, sessionId, status), diff --git a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs index 075b34e2..24fbc48c 100644 --- a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs +++ b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs @@ -11,43 +11,18 @@ public sealed record BakeProcessRequest( string DatDirectory, string OutputPath, int Threads, - string? PublicationNonce = null, - IReadOnlyList? DatIds = null, - IReadOnlyList? Landblocks = null) + string? PublicationNonce = null) { - public IReadOnlyList Arguments - { - get - { - var arguments = new List - { - "--dat-dir", - DatDirectory, - "--out", - OutputPath, - "--threads", - Threads.ToString(CultureInfo.InvariantCulture), - "--progress-json", - }; - if (DatIds is { Count: > 0 }) - { - arguments.Add("--ids"); - arguments.Add(string.Join( - ',', - DatIds.Select(static id => $"0x{id:X8}"))); - } - - if (Landblocks is { Count: > 0 }) - { - arguments.Add("--landblocks"); - arguments.Add(string.Join( - ',', - Landblocks.Select(static id => $"0x{id:X2}"))); - } - - return arguments; - } - } + public IReadOnlyList Arguments => + [ + "--dat-dir", + DatDirectory, + "--out", + OutputPath, + "--threads", + Threads.ToString(CultureInfo.InvariantCulture), + "--progress-json", + ]; } public sealed record BakeProcessResult(int ExitCode, string StandardError); diff --git a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs deleted file mode 100644 index f31c2085..00000000 --- a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs +++ /dev/null @@ -1,131 +0,0 @@ -namespace AcDream.Launcher.Core.Installation; - -/// The only four prepared-content actions exposed to the launcher UI. -public enum ContentWorkKind -{ - None, - Overlay, - FullRebuild, - Verify, -} - -/// -/// One resolved recipe migration. Overlay ids are acdream-bake's existing -/// hexadecimal DAT-id filters; landblocks use its existing 8-bit hexadecimal -/// landblock filter. A plan is deliberately data-only so update orchestration -/// and the UI do not need to understand extraction algorithms. -/// -public sealed record ContentMigrationPlan( - uint FromRecipeVersion, - uint TargetRecipeVersion, - ContentWorkKind Kind, - string Reason, - IReadOnlyList? DatIds = null, - IReadOnlyList? Landblocks = null) -{ - public IReadOnlyList EffectiveDatIds => DatIds ?? []; - - public IReadOnlyList EffectiveLandblocks => Landblocks ?? []; -} - -/// -/// Compiled content-recipe ledger. Launcher and client ship together, so the -/// updated launcher always knows how to prepare the matching client's recipe -/// without changing the strict release-feed schema. -/// -public static class ContentMigrationCatalog -{ - private static readonly IReadOnlyDictionary Steps = - new Dictionary - { - [2] = FullRebuild(1, 2, "prepared EnvCell identity changed"), - [3] = FullRebuild(2, 3, "render-pass translucency moved into prepared meshes"), - [4] = FullRebuild(3, 4, "flat collision and EnvCell topology were added"), - [5] = FullRebuild( - 4, - 5, - "solid-colour positive mesh faces must be regenerated"), - [6] = FullRebuild( - 5, - 6, - "the optimized pak v2 texture catalog and compression format require one full rebuild"), - }; - - public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion) - { - if (fromRecipeVersion == 0 || targetRecipeVersion == 0) - { - throw new ArgumentOutOfRangeException( - nameof(fromRecipeVersion), - "Content recipe versions must be positive."); - } - - if (fromRecipeVersion == targetRecipeVersion) - { - return new ContentMigrationPlan( - fromRecipeVersion, - targetRecipeVersion, - ContentWorkKind.None, - "Prepared content already matches this client."); - } - - if (fromRecipeVersion > targetRecipeVersion) - { - throw new InvalidOperationException( - $"Prepared content recipe {fromRecipeVersion} is newer than this " - + $"launcher's recipe {targetRecipeVersion}."); - } - - var ids = new HashSet(); - var landblocks = new HashSet(); - var reasons = new List(); - ContentWorkKind combinedKind = ContentWorkKind.None; - for (uint target = checked(fromRecipeVersion + 1); - target <= targetRecipeVersion; - target++) - { - if (!Steps.TryGetValue(target, out ContentMigrationPlan? step) - || step.FromRecipeVersion != target - 1) - { - throw new InvalidOperationException( - $"No prepared-content migration is published for recipe " - + $"{target - 1} to {target}."); - } - - reasons.Add(step.Reason); - if (step.Kind == ContentWorkKind.FullRebuild) - { - combinedKind = ContentWorkKind.FullRebuild; - } - else if (combinedKind != ContentWorkKind.FullRebuild - && step.Kind == ContentWorkKind.Overlay) - { - combinedKind = ContentWorkKind.Overlay; - } - - foreach (uint id in step.EffectiveDatIds) - { - ids.Add(id); - } - - foreach (byte landblock in step.EffectiveLandblocks) - { - landblocks.Add(landblock); - } - } - - return new ContentMigrationPlan( - fromRecipeVersion, - targetRecipeVersion, - combinedKind, - string.Join("; ", reasons), - ids.Order().ToArray(), - landblocks.Order().ToArray()); - } - - private static ContentMigrationPlan FullRebuild( - uint from, - uint target, - string reason) => - new(from, target, ContentWorkKind.FullRebuild, reason); -} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs deleted file mode 100644 index 7776fd52..00000000 --- a/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs +++ /dev/null @@ -1,415 +0,0 @@ -using System.Buffers.Binary; -using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; -using AcDream.Launcher.Core.Integrity; -using AcDream.Launcher.Core.Launching; -using AcDream.Platform; - -namespace AcDream.Launcher.Core.Installation; - -public sealed record LauncherContentOverlay( - string Path, - string Sha256, - long Size, - uint RecipeVersion); - -public sealed record LauncherContentState( - int SchemaVersion, - string BaseSha256, - uint EffectiveRecipeVersion, - LauncherContentOverlay? Overlay) -{ - public const int CurrentSchemaVersion = 1; -} - -/// -/// Optional overlay authority kept beside, rather than inside, install.json. -/// Old launchers safely ignore this file instead of rejecting a new field in -/// their strict install-record schema. -/// -public sealed class LauncherContentStateStore -{ - private const uint PakMagic = 0x4B504341u; - private const uint PakFormatVersion = 2; - private const int PakHeaderSize = 64; - - private static readonly JsonSerializerOptions SerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, - }; - - private readonly string _pakDirectory; - private readonly Func> _computeSha256; - - public LauncherContentStateStore( - ApplicationPathSet paths, - Func>? computeSha256 = null) - { - ArgumentNullException.ThrowIfNull(paths); - _pakDirectory = Path.Combine( - Path.GetFullPath(paths.DataDirectory), - "pak"); - _computeSha256 = computeSha256 - ?? ((path, cancellationToken) => - FileIntegrity.ComputeSha256HexAsync(path, cancellationToken)); - } - - public string StatePath => Path.Combine(_pakDirectory, "content.current.json"); - - public string ClientCompatibilityPendingPath => Path.Combine( - _pakDirectory, - "content.client-pending"); - - public string OverlayCandidatePath => Path.Combine( - _pakDirectory, - ".acdream-update.candidate.pak"); - - public bool IsClientCompatibilityPending => - File.Exists(ClientCompatibilityPendingPath); - - public void MarkClientCompatibilityPending() - { - Directory.CreateDirectory(_pakDirectory); - string temporaryPath = ClientCompatibilityPendingPath + ".tmp"; - File.WriteAllText( - temporaryPath, - LauncherInstallRecordStore.CurrentBakeToolVersion.ToString( - CultureInfo.InvariantCulture)); - File.Move( - temporaryPath, - ClientCompatibilityPendingPath, - overwrite: true); - } - - public void ClearClientCompatibilityPending() - { - LauncherInstallRecordStore.TryDelete(ClientCompatibilityPendingPath); - LauncherInstallRecordStore.TryDelete( - ClientCompatibilityPendingPath + ".tmp"); - } - - public string GetOverlayPath(LauncherContentOverlay overlay) - { - ArgumentNullException.ThrowIfNull(overlay); - string? error = ValidateOverlayFileName(overlay.Path); - if (error is not null) - { - throw new InvalidDataException(error); - } - - return Path.Combine(_pakDirectory, overlay.Path); - } - - public async Task<(LauncherContentState? State, string? Error)> LoadAsync( - LauncherInstallRecord baseRecord, - bool forceFullVerification = false, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(baseRecord); - if (!File.Exists(StatePath)) - { - return (null, null); - } - - LauncherContentState? state; - try - { - await using FileStream stream = new( - StatePath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - 4096, - FileOptions.Asynchronous | FileOptions.SequentialScan); - state = await JsonSerializer.DeserializeAsync( - stream, - SerializerOptions, - cancellationToken) - .ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ex is IOException - or UnauthorizedAccessException - or JsonException - or NotSupportedException) - { - return (null, $"The prepared-content update record could not be read: {ex.Message}"); - } - - string? contractError = ValidateContract(baseRecord, state); - if (contractError is not null) - { - return (null, contractError); - } - - LauncherContentOverlay overlay = state!.Overlay!; - string overlayPath = GetOverlayPath(overlay); - if (!File.Exists(baseRecord.PreparedAssetPath)) - { - return (null, "The base prepared package is missing."); - } - - if (new FileInfo(baseRecord.PreparedAssetPath).Length - != baseRecord.PreparedAssetSize) - { - return (null, "The base prepared package size changed."); - } - - if (!File.Exists(overlayPath)) - { - return (null, "The prepared-content overlay is missing."); - } - - if (new FileInfo(overlayPath).Length != overlay.Size) - { - return (null, "The prepared-content overlay size changed."); - } - - try - { - PakIdentity baseIdentity = ReadPakIdentity(baseRecord.PreparedAssetPath); - PakIdentity overlayIdentity = ReadPakIdentity(overlayPath); - if (baseIdentity.FormatVersion != PakFormatVersion - || overlayIdentity.FormatVersion != PakFormatVersion) - { - return (null, "The base or overlay pak format is not supported."); - } - - if (baseIdentity.RecipeVersion != baseRecord.BakeToolVersion - || overlayIdentity.RecipeVersion != overlay.RecipeVersion) - { - return (null, "The base or overlay content recipe does not match its record."); - } - - if (!baseIdentity.SameDatSet(overlayIdentity)) - { - return (null, "The prepared-content overlay was built from a different DAT set."); - } - } - catch (Exception ex) when (ex is IOException - or UnauthorizedAccessException - or InvalidDataException) - { - return (null, $"The prepared-content package header is invalid: {ex.Message}"); - } - - if (forceFullVerification) - { - string baseSha = await _computeSha256( - baseRecord.PreparedAssetPath, - cancellationToken) - .ConfigureAwait(false); - if (!FileIntegrity.Matches(baseSha, baseRecord.PreparedAssetSha256)) - { - return (null, "The base prepared package SHA-256 does not match its record."); - } - - string overlaySha = await _computeSha256(overlayPath, cancellationToken) - .ConfigureAwait(false); - if (!FileIntegrity.Matches(overlaySha, overlay.Sha256)) - { - return (null, "The prepared-content overlay SHA-256 does not match its record."); - } - } - - return (state, null); - } - - public async Task SaveAtomicallyAsync( - LauncherInstallRecord baseRecord, - LauncherContentState state, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(baseRecord); - ArgumentNullException.ThrowIfNull(state); - string? error = ValidateContract(baseRecord, state); - if (error is not null) - { - throw new InvalidDataException(error); - } - - string overlayPath = GetOverlayPath(state.Overlay!); - string? candidateError = ValidateCandidate( - baseRecord, - overlayPath, - state.Overlay!); - if (candidateError is not null) - { - throw new InvalidDataException(candidateError); - } - - Directory.CreateDirectory(_pakDirectory); - string temporaryPath = StatePath + $".{Guid.NewGuid():N}.tmp"; - try - { - await using (FileStream stream = new( - temporaryPath, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - 4096, - FileOptions.Asynchronous | FileOptions.WriteThrough)) - { - await JsonSerializer.SerializeAsync( - stream, - state, - SerializerOptions, - cancellationToken) - .ConfigureAwait(false); - await stream.FlushAsync(cancellationToken).ConfigureAwait(false); - stream.Flush(flushToDisk: true); - } - - cancellationToken.ThrowIfCancellationRequested(); - File.Move(temporaryPath, StatePath, overwrite: true); - } - finally - { - LauncherInstallRecordStore.TryDelete(temporaryPath); - } - } - - public void Delete() => LauncherInstallRecordStore.TryDelete(StatePath); - - public string? ValidateCandidate( - LauncherInstallRecord baseRecord, - string overlayPath, - LauncherContentOverlay overlay) - { - ArgumentNullException.ThrowIfNull(baseRecord); - ArgumentException.ThrowIfNullOrWhiteSpace(overlayPath); - ArgumentNullException.ThrowIfNull(overlay); - if (!File.Exists(baseRecord.PreparedAssetPath)) - { - return "The base prepared package is missing."; - } - - if (!File.Exists(overlayPath) - || new FileInfo(overlayPath).Length != overlay.Size) - { - return "The prepared-content overlay candidate size changed."; - } - - try - { - PakIdentity baseIdentity = ReadPakIdentity(baseRecord.PreparedAssetPath); - PakIdentity overlayIdentity = ReadPakIdentity(overlayPath); - if (baseIdentity.FormatVersion != PakFormatVersion - || overlayIdentity.FormatVersion != PakFormatVersion - || baseIdentity.RecipeVersion != baseRecord.BakeToolVersion - || overlayIdentity.RecipeVersion != overlay.RecipeVersion - || !baseIdentity.SameDatSet(overlayIdentity)) - { - return "The prepared-content overlay candidate header does not " - + "match the base pak and requested recipe."; - } - } - catch (Exception ex) when (ex is IOException - or UnauthorizedAccessException - or InvalidDataException) - { - return $"The prepared-content overlay candidate is invalid: {ex.Message}"; - } - - return null; - } - - private static string? ValidateContract( - LauncherInstallRecord baseRecord, - LauncherContentState? state) - { - if (state is null) - { - return "The prepared-content update record is empty."; - } - - if (state.SchemaVersion != LauncherContentState.CurrentSchemaVersion) - { - return $"Prepared-content record version {state.SchemaVersion} is not supported."; - } - - if (!IsSha256(state.BaseSha256) - || !FileIntegrity.Matches(state.BaseSha256, baseRecord.PreparedAssetSha256)) - { - return "The prepared-content update record does not match the installed base pak."; - } - - if (state.Overlay is null) - { - return "The prepared-content update record is missing its overlay."; - } - - if (state.EffectiveRecipeVersion != state.Overlay.RecipeVersion - || state.EffectiveRecipeVersion <= baseRecord.BakeToolVersion) - { - return "The prepared-content overlay recipe is not a newer effective recipe."; - } - - if (!IsSha256(state.Overlay.Sha256) || state.Overlay.Size <= 0) - { - return "The prepared-content overlay is missing valid integrity metadata."; - } - - return ValidateOverlayFileName(state.Overlay.Path); - } - - private static string? ValidateOverlayFileName(string path) - { - if (string.IsNullOrWhiteSpace(path) - || Path.IsPathFullyQualified(path) - || path.Contains('/') - || path.Contains('\\') - || !string.Equals(path, Path.GetFileName(path), StringComparison.Ordinal) - || path is "." or ".." - || !path.EndsWith(".pak", StringComparison.OrdinalIgnoreCase)) - { - return "The prepared-content overlay path must be one pak filename beneath the pak directory."; - } - - return null; - } - - private static PakIdentity ReadPakIdentity(string path) - { - Span header = stackalloc byte[PakHeaderSize]; - using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read); - stream.ReadExactly(header); - uint magic = BinaryPrimitives.ReadUInt32LittleEndian(header[0..4]); - if (magic != PakMagic) - { - throw new InvalidDataException("pak magic does not match ACPK"); - } - - return new PakIdentity( - BinaryPrimitives.ReadUInt32LittleEndian(header[4..8]), - BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]), - BinaryPrimitives.ReadUInt32LittleEndian(header[12..16]), - BinaryPrimitives.ReadUInt32LittleEndian(header[16..20]), - BinaryPrimitives.ReadUInt32LittleEndian(header[20..24]), - BinaryPrimitives.ReadUInt32LittleEndian(header[36..40])); - } - - private static bool IsSha256(string value) => - value.Length == 64 && value.All(Uri.IsHexDigit); - - private readonly record struct PakIdentity( - uint FormatVersion, - uint PortalIteration, - uint CellIteration, - uint HighResIteration, - uint LanguageIteration, - uint RecipeVersion) - { - public bool SameDatSet(PakIdentity other) => - PortalIteration == other.PortalIteration - && CellIteration == other.CellIteration - && HighResIteration == other.HighResIteration - && LanguageIteration == other.LanguageIteration; - } -} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index 4d4b3ec6..1b52cf84 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -10,20 +10,15 @@ public enum InstallRecordVerificationState { Missing, Verified, - ContentUpdateRequired, Invalid, } public sealed record InstallRecordVerification( InstallRecordVerificationState State, LauncherInstallRecord? Record, - string Status, - ContentMigrationPlan? RequiredContentWork = null) + string Status) { public bool IsVerified => State == InstallRecordVerificationState.Verified; - - public bool RequiresContentUpdate => - State == InstallRecordVerificationState.ContentUpdateRequired; } /// @@ -35,9 +30,9 @@ public sealed record InstallRecordVerification( public sealed class LauncherInstallRecordStore { // Kept in lockstep with AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion - // Version 6 is pak format 2: global texture payload deduplication plus - // adaptive independent-blob compression. It is a mandatory full rebuild. - public const uint CurrentBakeToolVersion = 6; + // (#426, 2026-08-23: version 5 extracts untextured/solid-colour positive + // faces that versions <=4 dropped). + public const uint CurrentBakeToolVersion = 5; private static readonly JsonSerializerOptions SerializerOptions = new() { @@ -82,8 +77,7 @@ public sealed class LauncherInstallRecordStore /// passes false. public async Task LoadAndVerifyAsync( CancellationToken cancellationToken = default, - bool forceFullVerification = false, - IProgress? progress = null) + bool forceFullVerification = false) { await using InstallerTransactionLease lease = await InstallerTransactionLease.AcquireAsync( @@ -92,15 +86,13 @@ public sealed class LauncherInstallRecordStore .ConfigureAwait(false); return await LoadAndVerifyUnderLeaseAsync( cancellationToken, - forceFullVerification, - progress) + forceFullVerification) .ConfigureAwait(false); } internal async Task LoadAndVerifyUnderLeaseAsync( CancellationToken cancellationToken = default, - bool forceFullVerification = false, - IProgress? progress = null) + bool forceFullVerification = false) { if (!File.Exists(RecordPath)) { @@ -154,59 +146,18 @@ public sealed class LauncherInstallRecordStore string? contractError = ValidateRecordContract( record, - requireCanonicalSerializedPaths: true, - requireCurrentRecipe: false); + requireCanonicalSerializedPaths: true); if (contractError is not null) { return Invalid(contractError); } - if (record.BakeToolVersion > CurrentBakeToolVersion) - { - return Invalid( - $"Prepared content recipe {record.BakeToolVersion} is newer than " - + $"this launcher's recipe {CurrentBakeToolVersion}. Update the launcher."); - } - - if (record.BakeToolVersion < CurrentBakeToolVersion) - { - if (!File.Exists(record.PreparedAssetPath)) - { - return Invalid("The prepared package is missing."); - } - - if (new FileInfo(record.PreparedAssetPath).Length - != record.PreparedAssetSize) - { - return Invalid("The prepared package size changed."); - } - - ContentMigrationPlan plan; - try - { - plan = ContentMigrationCatalog.Resolve( - record.BakeToolVersion, - CurrentBakeToolVersion); - } - catch (InvalidOperationException ex) - { - return Invalid(ex.Message); - } - - return new InstallRecordVerification( - InstallRecordVerificationState.ContentUpdateRequired, - record, - $"World data update required: {plan.Reason}.", - plan); - } - string backupPath = GetBackupPath(record.PreparedAssetPath); FileVerification current = await VerifyFileAsync( record.PreparedAssetPath, record, cancellationToken, - allowCachedResult: !forceFullVerification, - progress) + allowCachedResult: !forceFullVerification) .ConfigureAwait(false); if (current.IsValid) { @@ -225,8 +176,7 @@ public sealed class LauncherInstallRecordStore backupPath, record, cancellationToken, - allowCachedResult: false, - progress) + allowCachedResult: false) .ConfigureAwait(false); if (backup.IsValid) { @@ -270,8 +220,7 @@ public sealed class LauncherInstallRecordStore LauncherInstallRecord normalized = NormalizeForSave(record); string? contractError = ValidateRecordContract( normalized, - requireCanonicalSerializedPaths: true, - requireCurrentRecipe: true); + requireCanonicalSerializedPaths: true); if (contractError is not null) { throw new InvalidDataException(contractError); @@ -314,37 +263,9 @@ public sealed class LauncherInstallRecordStore } } - /// Records the hash the installer just computed so the first - /// launch after a successful bake does not immediately hash the same - /// multi-gigabyte file again. - internal void RememberVerifiedPackage(LauncherInstallRecord record) - { - ArgumentNullException.ThrowIfNull(record); - try - { - var file = new FileInfo(record.PreparedAssetPath); - if (file.Exists && file.Length == record.PreparedAssetSize) - { - _verificationCache.Write( - record.PreparedAssetPath, - file.Length, - file.LastWriteTimeUtc, - record.PreparedAssetSha256); - } - } - catch (Exception ex) when (ex is IOException - or UnauthorizedAccessException - or ArgumentException - or NotSupportedException) - { - // The cache is only an optimization. Startup will hash visibly. - } - } - private string? ValidateRecordContract( LauncherInstallRecord record, - bool requireCanonicalSerializedPaths, - bool requireCurrentRecipe) + bool requireCanonicalSerializedPaths) { if (record.Version != LauncherInstallRecord.CurrentRecordVersion) { @@ -356,8 +277,7 @@ public sealed class LauncherInstallRecordStore return "The install record is missing SHA-256, size, or bake-tool metadata."; } - if (requireCurrentRecipe - && record.BakeToolVersion != CurrentBakeToolVersion) + if (record.BakeToolVersion != CurrentBakeToolVersion) { return $"Bake tool version {record.BakeToolVersion} is not supported; " + $"version {CurrentBakeToolVersion} is required."; @@ -502,8 +422,7 @@ public sealed class LauncherInstallRecordStore string path, LauncherInstallRecord record, CancellationToken cancellationToken, - bool allowCachedResult, - IProgress? progress) + bool allowCachedResult) { if (!File.Exists(path)) { @@ -534,12 +453,6 @@ public sealed class LauncherInstallRecordStore return new FileVerification(true, "Client content verified."); } - progress?.Report( - allowCachedResult - ? "The verification cache is missing or changed. Reading the " - + "whole world-data pak once; this can take around 30 seconds." - : "Reading the whole world-data pak for explicit verification; " - + "this can take around 30 seconds."); string sha256 = await _computeSha256(path, cancellationToken) .ConfigureAwait(false); if (!FileIntegrity.Matches(sha256, record.PreparedAssetSha256)) diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs index 1307ddab..4deae741 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -1,7 +1,6 @@ using AcDream.Launcher.Core.Integrity; using AcDream.Launcher.Core.Launching; using AcDream.Platform; -using System.Globalization; namespace AcDream.Launcher.Core.Installation; @@ -62,60 +61,27 @@ public interface ILauncherInstaller CancellationToken cancellationToken = default, bool forceFullVerification = false); - Task LoadExistingWithProgressAsync( - CancellationToken cancellationToken = default, - bool forceFullVerification = false, - IProgress? progress = null) => - LoadExistingAsync(cancellationToken, forceFullVerification); - Task InstallAsync( string datDirectory, int threads, IProgress? progress = null, CancellationToken cancellationToken = default); - - Task ApplyContentUpdateAsync( - string datDirectory, - int threads, - ContentMigrationPlan migration, - IProgress? progress = null, - CancellationToken cancellationToken = default) => - migration.Kind == ContentWorkKind.FullRebuild - ? InstallAsync( - datDirectory, - threads, - progress, - cancellationToken) - : Task.FromException( - new NotSupportedException( - "This installer does not support filtered content overlays.")); - - /// Clears the crash-safe gate left by a completed content - /// migration after the active client has been confirmed compatible. - void ConfirmClientCompatibility() - { - } } /// /// BCL-only first-run transaction. It invokes the GL-free bake executable as /// a child, consumes only its versioned JSONL records, verifies the published -/// pak, and atomically records the install. Long full rebuilds and filtered -/// overlays are written beside active content; the old package is touched only -/// during the final verified publication, so cancellation and child failure -/// leave the playable bytes in place. +/// pak, and atomically records the install. A prior verified package is moved +/// to an adjacent recovery slot and restored on every failure/cancellation +/// path, so a fake or crashed child cannot replace it with partial output. /// public sealed class LauncherInstaller : ILauncherInstaller { - public const long FullRebuildRequiredFreeBytes = 2L * 1024 * 1024 * 1024; - private readonly string _bakeExecutablePath; private readonly DatDirectoryLocator _datDirectories; private readonly LauncherInstallRecordStore _recordStore; - private readonly LauncherContentStateStore _contentStateStore; private readonly IBakeProcessRunner _processRunner; private readonly Func> _computeSha256; - private readonly Func _availableFreeSpace; private readonly SemaphoreSlim _installGate = new(1, 1); private LauncherInstallRecord? _verifiedRecord; @@ -130,10 +96,8 @@ public sealed class LauncherInstaller : ILauncherInstaller string bakeExecutablePath, DatDirectoryLocator? datDirectories = null, LauncherInstallRecordStore? recordStore = null, - LauncherContentStateStore? contentStateStore = null, IBakeProcessRunner? processRunner = null, - Func>? computeSha256 = null, - Func? availableFreeSpace = null) + Func>? computeSha256 = null) { ArgumentNullException.ThrowIfNull(paths); ArgumentException.ThrowIfNullOrWhiteSpace(bakeExecutablePath); @@ -147,10 +111,7 @@ public sealed class LauncherInstaller : ILauncherInstaller paths, _datDirectories, _computeSha256); - _contentStateStore = contentStateStore - ?? new LauncherContentStateStore(paths, _computeSha256); _processRunner = processRunner ?? new SystemBakeProcessRunner(); - _availableFreeSpace = availableFreeSpace ?? GetAvailableFreeSpace; } public IReadOnlyList DetectDatDirectories() => @@ -159,21 +120,9 @@ public sealed class LauncherInstaller : ILauncherInstaller public DatDirectoryValidation ValidateDatDirectory(string? directory) => _datDirectories.Validate(directory); - public void ConfirmClientCompatibility() => - _contentStateStore.ClearClientCompatibilityPending(); - public async Task LoadExistingAsync( CancellationToken cancellationToken = default, - bool forceFullVerification = false) => - await LoadExistingWithProgressAsync( - cancellationToken, - forceFullVerification) - .ConfigureAwait(false); - - public async Task LoadExistingWithProgressAsync( - CancellationToken cancellationToken = default, - bool forceFullVerification = false, - IProgress? progress = null) + bool forceFullVerification = false) { await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -187,28 +136,8 @@ public sealed class LauncherInstaller : ILauncherInstaller InstallRecordVerification verification = await RecoverExistingUnderPublicationGuardAsync( cancellationToken, - forceFullVerification, - progress) + forceFullVerification) .ConfigureAwait(false); - verification = await ResolveContentStateAsync( - verification, - forceFullVerification, - cancellationToken) - .ConfigureAwait(false); - if (verification.IsVerified - && verification.Record is not null - && _contentStateStore.IsClientCompatibilityPending) - { - verification = verification with - { - Record = verification.Record with - { - RequiresClientCompatibilityConfirmation = true, - }, - Status = "World data is verified; matching client confirmation is pending.", - }; - } - _verifiedRecord = verification.Record; return verification; } @@ -281,7 +210,6 @@ public sealed class LauncherInstaller : ILauncherInstaller } string outputPath = _recordStore.PreparedAssetPath; - string bakeOutputPath = GetFullRebuildCandidatePath(outputPath); string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath); InstallRecordVerification existing = await RecoverExistingUnderPublicationGuardAsync( @@ -293,56 +221,21 @@ public sealed class LauncherInstaller : ILauncherInstaller forceFullVerification: true) .ConfigureAwait(false); _verifiedRecord = existing.Record; - LauncherContentState? priorContentState = null; - if (existing.Record is not null) - { - (priorContentState, _) = await _contentStateStore.LoadAsync( - existing.Record, - forceFullVerification: false, - cancellationToken) - .ConfigureAwait(false); - } Directory.CreateDirectory( Path.GetDirectoryName(outputPath) ?? throw new InvalidOperationException( "The prepared package path has no parent directory.")); - long availableBytes; - try - { - availableBytes = _availableFreeSpace(outputPath); - } - catch (Exception exception) when (exception is IOException - or UnauthorizedAccessException - or ArgumentException) - { - string message = - $"Could not check free space for the world-data rebuild: {exception.Message}"; - Report(progress, LauncherInstallPhase.Failed, message); - throw new LauncherInstallException(message, exception); - } - - if (availableBytes < FullRebuildRequiredFreeBytes) - { - string message = - $"The optimized world-data rebuild needs at least " - + $"{FormatGiB(FullRebuildRequiredFreeBytes)} GiB free " - + $"beside the active package; only " - + $"{FormatGiB(availableBytes)} GiB is available."; - Report(progress, LauncherInstallPhase.Failed, message); - throw new LauncherInstallException(message); - } - Report( progress, LauncherInstallPhase.PreparingOutput, - "Preparing a one-time optimized world-data rebuild beside the active package. " - + "This can take several minutes; progress will update below..."); - LauncherInstallRecordStore.TryDelete(bakeOutputPath); - LauncherInstallRecordStore.TryDelete(backupPath); - bool previousPreserved = false; - bool canonicalReplaced = false; + "Preparing the atomic package transaction..."); + bool previousPreserved = PreservePreviousPackage(outputPath, backupPath); + if (!previousPreserved) + { + LauncherInstallRecordStore.TryDelete(backupPath); + } var parser = new BakeProgressJsonlParser(); var protocol = new BakeProgressProtocol(); @@ -396,12 +289,12 @@ public sealed class LauncherInstaller : ILauncherInstaller await using ( BakePublicationGuardContract.PublicationLease publication = await BakePublicationGuardContract.AcquireAsync( - bakeOutputPath, + outputPath, cancellationToken) .ConfigureAwait(false)) { BakePublicationGuardContract.Authorize( - bakeOutputPath, + outputPath, publicationNonce, publication); } @@ -409,7 +302,7 @@ public sealed class LauncherInstaller : ILauncherInstaller var request = new BakeProcessRequest( _bakeExecutablePath, validation.Directory, - bakeOutputPath, + outputPath, threads, publicationNonce); BakeProcessResult processResult = await _processRunner.RunAsync( @@ -475,13 +368,13 @@ public sealed class LauncherInstaller : ILauncherInstaller $"The bake completed with {completed.Failures:N0} failed assets."); } - if (!File.Exists(bakeOutputPath)) + if (!File.Exists(outputPath)) { throw new LauncherInstallException( "The bake tool reported success but did not publish acdream.pak."); } - long size = new FileInfo(bakeOutputPath).Length; + long size = new FileInfo(outputPath).Length; if (size <= 0 || size != completed.OutputBytes) { throw new LauncherInstallException( @@ -492,7 +385,7 @@ public sealed class LauncherInstaller : ILauncherInstaller progress, LauncherInstallPhase.VerifyingPackage, "Computing the prepared package SHA-256..."); - string sha256 = await _computeSha256(bakeOutputPath, cancellationToken) + string sha256 = await _computeSha256(outputPath, cancellationToken) .ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); @@ -505,31 +398,15 @@ public sealed class LauncherInstaller : ILauncherInstaller Report( progress, LauncherInstallPhase.SavingRecord, - "Activating the verified package..."); - previousPreserved = PreservePreviousPackage(outputPath, backupPath); - File.Move(bakeOutputPath, outputPath, overwrite: true); - canonicalReplaced = true; + "Saving the verified install record..."); await _recordStore.SaveAtomicallyUnderLeaseAsync( record, cancellationToken) .ConfigureAwait(false); - _recordStore.RememberVerifiedPackage(record); - - // A complete current-recipe base supersedes every overlay. Publish - // the base record first, then remove the optional sidecar so a - // crash can at worst leave a sidecar whose base digest no longer - // binds and which startup therefore rejects. - _contentStateStore.Delete(); - if (priorContentState?.Overlay is not null) - { - LauncherInstallRecordStore.TryDelete( - _contentStateStore.GetOverlayPath( - priorContentState.Overlay)); - } _verifiedRecord = record; await FinalizeSuccessfulPublicationAsync( - bakeOutputPath, + outputPath, backupPath, publicationNonce) .ConfigureAwait(false); @@ -544,11 +421,9 @@ public sealed class LauncherInstaller : ILauncherInstaller catch (OperationCanceledException) { await FinalizeFailedPublicationAsync( - bakeOutputPath, outputPath, backupPath, previousPreserved, - canonicalReplaced, publicationNonce) .ConfigureAwait(false); Report( @@ -560,11 +435,9 @@ public sealed class LauncherInstaller : ILauncherInstaller catch (Exception ex) { await FinalizeFailedPublicationAsync( - bakeOutputPath, outputPath, backupPath, previousPreserved, - canonicalReplaced, publicationNonce) .ConfigureAwait(false); Report( @@ -583,42 +456,9 @@ public sealed class LauncherInstaller : ILauncherInstaller private async Task RecoverExistingUnderPublicationGuardAsync( CancellationToken cancellationToken, - bool forceFullVerification = false, - IProgress? progress = null) + bool forceFullVerification = false) { string outputPath = _recordStore.PreparedAssetPath; - string candidatePath = GetFullRebuildCandidatePath(outputPath); - await using ( - BakePublicationGuardContract.PublicationLease candidatePublication = - await BakePublicationGuardContract.AcquireAsync( - candidatePath, - cancellationToken, - PublicationLeaseContentionObservedForTest) - .ConfigureAwait(false)) - { - BakePublicationGuardContract.Invalidate( - candidatePath, - candidatePublication); - LauncherInstallRecordStore.TryDelete(candidatePath); - BakeOutputStagingContract.DeleteOwnedStagingFiles(candidatePath); - } - - string overlayCandidatePath = _contentStateStore.OverlayCandidatePath; - await using ( - BakePublicationGuardContract.PublicationLease overlayPublication = - await BakePublicationGuardContract.AcquireAsync( - overlayCandidatePath, - cancellationToken) - .ConfigureAwait(false)) - { - BakePublicationGuardContract.Invalidate( - overlayCandidatePath, - overlayPublication); - LauncherInstallRecordStore.TryDelete(overlayCandidatePath); - BakeOutputStagingContract.DeleteOwnedStagingFiles( - overlayCandidatePath); - } - await using BakePublicationGuardContract.PublicationLease publication = await BakePublicationGuardContract.AcquireAsync( outputPath, @@ -632,476 +472,10 @@ public sealed class LauncherInstaller : ILauncherInstaller BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); return await _recordStore.LoadAndVerifyUnderLeaseAsync( cancellationToken, - forceFullVerification, - progress) + forceFullVerification) .ConfigureAwait(false); } - private static string FormatGiB(long bytes) => - (bytes / (1024d * 1024d * 1024d)).ToString("N1", CultureInfo.InvariantCulture); - - public async Task ApplyContentUpdateAsync( - string datDirectory, - int threads, - ContentMigrationPlan migration, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(migration); - if (migration.Kind is not ContentWorkKind.FullRebuild - and not ContentWorkKind.Overlay) - { - throw new LauncherInstallException( - $"Content work kind {migration.Kind} cannot build an update."); - } - - if (threads <= 0) - { - throw new ArgumentOutOfRangeException( - nameof(threads), - "Bake thread count must be positive."); - } - - await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); - bool compatibilityMarkerPublished = false; - try - { - await using InstallerTransactionLease lease = - await InstallerTransactionLease.AcquireAsync( - _recordStore.DataDirectory, - cancellationToken, - TransactionLeaseContentionObservedForTest) - .ConfigureAwait(false); - // Publish the tiny gate before changing any content bytes. A - // crash can therefore leave an unnecessary confirmation prompt, - // but can never forget a required one after the pak changes. - _contentStateStore.MarkClientCompatibilityPending(); - compatibilityMarkerPublished = true; - LauncherInstallResult result = migration.Kind == ContentWorkKind.FullRebuild - ? await InstallCoreAsync( - datDirectory, - threads, - progress, - cancellationToken) - .ConfigureAwait(false) - : await InstallOverlayCoreAsync( - datDirectory, - threads, - migration, - progress, - cancellationToken) - .ConfigureAwait(false); - LauncherInstallRecord gatedRecord = result.Record with - { - RequiresClientCompatibilityConfirmation = true, - }; - _verifiedRecord = gatedRecord; - return new LauncherInstallResult(gatedRecord); - } - catch - { - if (compatibilityMarkerPublished) - { - _contentStateStore.ClearClientCompatibilityPending(); - } - - throw; - } - finally - { - _installGate.Release(); - } - } - - private async Task InstallOverlayCoreAsync( - string datDirectory, - int threads, - ContentMigrationPlan migration, - IProgress? progress, - CancellationToken cancellationToken) - { - if (migration.TargetRecipeVersion - != LauncherInstallRecordStore.CurrentBakeToolVersion) - { - throw new LauncherInstallException( - $"Overlay target recipe {migration.TargetRecipeVersion} does not " - + $"match launcher recipe " - + $"{LauncherInstallRecordStore.CurrentBakeToolVersion}."); - } - - if (migration.EffectiveDatIds.Count == 0 - && migration.EffectiveLandblocks.Count == 0) - { - throw new LauncherInstallException( - "An overlay migration must name at least one DAT id or landblock."); - } - - DatDirectoryValidation validation = _datDirectories.Validate(datDirectory); - if (!validation.IsValid) - { - throw new LauncherInstallException( - validation.Message + FormatMissing(validation.MissingFileNames)); - } - - if (!File.Exists(_bakeExecutablePath)) - { - throw new LauncherInstallException( - $"The co-deployed bake tool is missing at '{_bakeExecutablePath}'."); - } - - InstallRecordVerification baseVerification = - await RecoverExistingUnderPublicationGuardAsync(cancellationToken) - .ConfigureAwait(false); - LauncherInstallRecord baseRecord = baseVerification.Record - ?? throw new LauncherInstallException( - "A verified base pak is required before building an overlay."); - if (baseRecord.BakeToolVersion != migration.FromRecipeVersion) - { - throw new LauncherInstallException( - $"The overlay plan starts at recipe {migration.FromRecipeVersion}, " - + $"but the installed base is recipe {baseRecord.BakeToolVersion}."); - } - - (LauncherContentState? priorState, string? priorStateError) = - await _contentStateStore.LoadAsync( - baseRecord, - forceFullVerification: false, - cancellationToken) - .ConfigureAwait(false); - if (priorStateError is not null) - { - throw new LauncherInstallException(priorStateError); - } - - string candidatePath = _contentStateStore.OverlayCandidatePath; - LauncherInstallRecordStore.TryDelete(candidatePath); - string? publicationNonce = null; - string? publishedOverlayPath = null; - bool statePublished = false; - var parser = new BakeProgressJsonlParser(); - var protocol = new BakeProgressProtocol(); - - void Observe(BakeProgressEvent progressEvent) - { - bool accepted = protocol.Observe(progressEvent); - switch (progressEvent) - { - case BakeWorkProgressEvent value when accepted: - LauncherInstallPhase phase = value.Phase == "collision" - ? LauncherInstallPhase.BakingCollision - : LauncherInstallPhase.BakingMeshes; - Report( - progress, - phase, - $"Building world-data overlay: {value.Completed:N0}/" - + $"{value.Total:N0}; failures: {value.Failures:N0}", - value.Completed, - value.Total, - value.Failures, - value.EtaSeconds); - break; - case BakeErrorEvent value when accepted: - Report(progress, LauncherInstallPhase.Failed, value.Message); - break; - case MalformedBakeProgressEvent value: - Report(progress, LauncherInstallPhase.Failed, value.Reason); - break; - } - } - - try - { - Report( - progress, - LauncherInstallPhase.PreparingOutput, - "Preparing a small filtered overlay beside active content..."); - publicationNonce = BakePublicationGuardPaths.CreateNonce(); - await using ( - BakePublicationGuardContract.PublicationLease publication = - await BakePublicationGuardContract.AcquireAsync( - candidatePath, - cancellationToken) - .ConfigureAwait(false)) - { - BakePublicationGuardContract.Authorize( - candidatePath, - publicationNonce, - publication); - } - - var request = new BakeProcessRequest( - _bakeExecutablePath, - validation.Directory, - candidatePath, - threads, - publicationNonce, - migration.EffectiveDatIds, - migration.EffectiveLandblocks); - BakeProcessResult processResult = await _processRunner.RunAsync( - request, - chunk => - { - foreach (BakeProgressEvent value in parser.Append(chunk)) - { - Observe(value); - } - }, - cancellationToken) - .ConfigureAwait(false); - foreach (BakeProgressEvent value in parser.Complete()) - { - Observe(value); - } - - protocol.CompleteInput(); - cancellationToken.ThrowIfCancellationRequested(); - if (protocol.Violation is not null) - { - throw new LauncherInstallException(protocol.Violation); - } - - if (processResult.ExitCode != 0) - { - throw new LauncherInstallException( - BuildChildFailure( - processResult.ExitCode, - protocol.Error?.Message, - processResult.StandardError)); - } - - if (protocol.Error is not null) - { - throw new LauncherInstallException(protocol.Error.Message); - } - - BakeStartedEvent started = protocol.Started - ?? throw new LauncherInstallException( - "The bake protocol did not report a started event."); - BakeCompletedEvent completed = protocol.Completed - ?? throw new LauncherInstallException( - "The bake protocol did not report a completed event."); - if (started.BakeToolVersion != migration.TargetRecipeVersion - || completed.BakeToolVersion != migration.TargetRecipeVersion - || completed.Failures != 0) - { - throw new LauncherInstallException( - "The filtered bake did not complete with the requested recipe."); - } - - if (!File.Exists(candidatePath)) - { - throw new LauncherInstallException( - "The filtered bake did not publish an overlay candidate."); - } - - long size = new FileInfo(candidatePath).Length; - if (size <= 0 || size != completed.OutputBytes) - { - throw new LauncherInstallException( - "The overlay candidate size does not match bake completion."); - } - - Report( - progress, - LauncherInstallPhase.VerifyingPackage, - "Verifying the small world-data overlay..."); - string sha256 = await _computeSha256(candidatePath, cancellationToken) - .ConfigureAwait(false); - string fileName = $"acdream-update-{migration.TargetRecipeVersion}-" - + $"{sha256[..12].ToLowerInvariant()}.pak"; - var overlay = new LauncherContentOverlay( - fileName, - sha256, - size, - migration.TargetRecipeVersion); - string? candidateError = _contentStateStore.ValidateCandidate( - baseRecord, - candidatePath, - overlay); - if (candidateError is not null) - { - throw new LauncherInstallException(candidateError); - } - - publishedOverlayPath = _contentStateStore.GetOverlayPath(overlay); - File.Move(candidatePath, publishedOverlayPath, overwrite: true); - await FinalizeSuccessfulPublicationAsync( - candidatePath, - backupPath: candidatePath + ".unused", - publicationNonce) - .ConfigureAwait(false); - var state = new LauncherContentState( - LauncherContentState.CurrentSchemaVersion, - baseRecord.PreparedAssetSha256, - migration.TargetRecipeVersion, - overlay); - Report( - progress, - LauncherInstallPhase.SavingRecord, - "Activating the verified world-data overlay..."); - await _contentStateStore.SaveAtomicallyAsync( - baseRecord, - state, - cancellationToken) - .ConfigureAwait(false); - statePublished = true; - - if (priorState?.Overlay is not null) - { - string priorPath = _contentStateStore.GetOverlayPath( - priorState.Overlay); - if (!PathsEqual(priorPath, publishedOverlayPath)) - { - LauncherInstallRecordStore.TryDelete(priorPath); - } - } - - var resolvedRecord = baseRecord with - { - PreparedAssetOverlayPath = publishedOverlayPath, - EffectiveBakeToolVersion = migration.TargetRecipeVersion, - }; - _verifiedRecord = resolvedRecord; - Report( - progress, - LauncherInstallPhase.Completed, - "World data overlay installed and verified.", - 1, - 1); - return new LauncherInstallResult(resolvedRecord); - } - catch (OperationCanceledException) - { - await FinalizeOverlayFailureAsync( - candidatePath, - publishedOverlayPath, - statePublished, - publicationNonce) - .ConfigureAwait(false); - Report( - progress, - LauncherInstallPhase.Cancelled, - "World data update cancelled; active content was preserved."); - throw; - } - catch (Exception ex) - { - await FinalizeOverlayFailureAsync( - candidatePath, - publishedOverlayPath, - statePublished, - publicationNonce) - .ConfigureAwait(false); - Report( - progress, - LauncherInstallPhase.Failed, - $"World data update failed: {ex.Message}"); - if (ex is LauncherInstallException) - { - throw; - } - - throw new LauncherInstallException("World data update failed.", ex); - } - } - - private async Task ResolveContentStateAsync( - InstallRecordVerification baseVerification, - bool forceFullVerification, - CancellationToken cancellationToken) - { - LauncherInstallRecord? record = baseVerification.Record; - if (record is null) - { - return baseVerification; - } - - if (baseVerification.IsVerified - && record.BakeToolVersion - == LauncherInstallRecordStore.CurrentBakeToolVersion) - { - // A complete current-recipe base is sufficient by itself. A - // sidecar from a newer launcher may remain across client rollback; - // this client safely ignores it instead of rejecting the base. - return baseVerification; - } - - (LauncherContentState? state, string? error) = - await _contentStateStore.LoadAsync( - record, - forceFullVerification, - cancellationToken) - .ConfigureAwait(false); - if (error is not null) - { - if (!forceFullVerification - && baseVerification.RequiresContentUpdate) - { - return baseVerification with - { - Status = baseVerification.Status - + " The previous overlay was ignored because it is invalid.", - }; - } - - return new InstallRecordVerification( - InstallRecordVerificationState.Invalid, - null, - error); - } - - if (state?.Overlay is not null) - { - if (state.EffectiveRecipeVersion - > LauncherInstallRecordStore.CurrentBakeToolVersion) - { - return new InstallRecordVerification( - InstallRecordVerificationState.Invalid, - null, - $"Prepared content recipe {state.EffectiveRecipeVersion} " - + $"does not match required recipe " - + $"{LauncherInstallRecordStore.CurrentBakeToolVersion}."); - } - - if (state.EffectiveRecipeVersion - < LauncherInstallRecordStore.CurrentBakeToolVersion) - { - ContentMigrationPlan migration; - try - { - migration = ContentMigrationCatalog.Resolve( - record.BakeToolVersion, - LauncherInstallRecordStore.CurrentBakeToolVersion); - } - catch (InvalidOperationException ex) - { - return new InstallRecordVerification( - InstallRecordVerificationState.Invalid, - null, - ex.Message); - } - - return new InstallRecordVerification( - InstallRecordVerificationState.ContentUpdateRequired, - record, - $"World data update required: {migration.Reason}.", - migration); - } - - return new InstallRecordVerification( - InstallRecordVerificationState.Verified, - record with - { - PreparedAssetOverlayPath = - _contentStateStore.GetOverlayPath(state.Overlay), - EffectiveBakeToolVersion = state.EffectiveRecipeVersion, - }, - "Base and overlay client content verified."); - } - - return baseVerification; - } - private static async Task FinalizeSuccessfulPublicationAsync( string outputPath, string backupPath, @@ -1121,58 +495,22 @@ public sealed class LauncherInstaller : ILauncherInstaller } private static async Task FinalizeFailedPublicationAsync( - string publicationPath, string outputPath, string backupPath, bool previousPreserved, - bool canonicalReplaced, string? publicationNonce) { await using BakePublicationGuardContract.PublicationLease publication = await BakePublicationGuardContract.AcquireAsync( - publicationPath, + outputPath, CancellationToken.None) .ConfigureAwait(false); BakePublicationGuardContract.Invalidate( - publicationPath, + outputPath, publication, publicationNonce); - if (canonicalReplaced) - { - LauncherInstallRecordStore.TryDelete(outputPath); - } - - if (previousPreserved && File.Exists(backupPath)) - { - File.Move(backupPath, outputPath, overwrite: true); - } - - LauncherInstallRecordStore.TryDelete(publicationPath); - LauncherInstallRecordStore.TryDelete(backupPath); - BakeOutputStagingContract.DeleteOwnedStagingFiles(publicationPath); - } - - private static async Task FinalizeOverlayFailureAsync( - string candidatePath, - string? publishedOverlayPath, - bool statePublished, - string? publicationNonce) - { - await using BakePublicationGuardContract.PublicationLease publication = - await BakePublicationGuardContract.AcquireAsync( - candidatePath, - CancellationToken.None) - .ConfigureAwait(false); - BakePublicationGuardContract.Invalidate( - candidatePath, - publication, - publicationNonce); - LauncherInstallRecordStore.TryDelete(candidatePath); - BakeOutputStagingContract.DeleteOwnedStagingFiles(candidatePath); - if (!statePublished && publishedOverlayPath is not null) - { - LauncherInstallRecordStore.TryDelete(publishedOverlayPath); - } + RestorePreviousPackage(outputPath, backupPath, previousPreserved); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); } private bool PreservePreviousPackage(string outputPath, string backupPath) @@ -1189,17 +527,19 @@ public sealed class LauncherInstaller : ILauncherInstaller return true; } - internal static string GetFullRebuildCandidatePath(string outputPath) => - outputPath + ".candidate"; - - private static long GetAvailableFreeSpace(string path) + private static void RestorePreviousPackage( + string outputPath, + string backupPath, + bool previousPreserved) { - string fullPath = Path.GetFullPath(path); - string root = Path.GetPathRoot(fullPath) - ?? throw new ArgumentException( - $"Path '{fullPath}' has no filesystem root.", - nameof(path)); - return new DriveInfo(root).AvailableFreeSpace; + if (previousPreserved && File.Exists(backupPath)) + { + File.Move(backupPath, outputPath, overwrite: true); + return; + } + + LauncherInstallRecordStore.TryDelete(outputPath); + LauncherInstallRecordStore.TryDelete(backupPath); } private static string BuildChildFailure( diff --git a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs index 92b71a12..c89c279a 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace AcDream.Launcher.Core.Launching; /// @@ -25,29 +23,4 @@ public sealed record LauncherInstallRecord( && PreparedAssetSha256.Length == 64 && PreparedAssetSize > 0 && BakeToolVersion > 0; - - /// - /// Resolved overlay metadata is runtime-only. It is explicitly excluded - /// from install.json so old strict-schema launchers continue accepting the - /// base record after a newer launcher publishes content.current.json. - /// - [JsonIgnore] - public string? PreparedAssetOverlayPath { get; init; } - - [JsonIgnore] - public uint EffectiveBakeToolVersion { get; init; } - - /// - /// Transient launcher gate restored from a tiny marker beside the pak. - /// It is not part of strict schema-1 install.json and is never copied into - /// a client session configuration. - /// - [JsonIgnore] - public bool RequiresClientCompatibilityConfirmation { get; init; } - - [JsonIgnore] - public uint ResolvedBakeToolVersion => - EffectiveBakeToolVersion == 0 - ? BakeToolVersion - : EffectiveBakeToolVersion; } diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs index 3eef5614..d691ff1d 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -114,7 +114,6 @@ public static class SessionConfigComposer ArgumentNullException.ThrowIfNull(install); ArgumentNullException.ThrowIfNull(paths); ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - EnsureClientCompatibilityConfirmed(install); (string configFilePath, string statusFilePath, string stderrLogPath) = BuildSessionPaths(paths, sessionId); @@ -155,15 +154,6 @@ public static class SessionConfigComposer { DatDirectory = install.DatDirectory, PreparedAssetPath = install.PreparedAssetPath, - PreparedAssetOverlayPath = install.PreparedAssetOverlayPath, - PreparedAssetBaseRecipeVersion = - install.PreparedAssetOverlayPath is null - ? null - : install.BakeToolVersion, - PreparedAssetEffectiveRecipeVersion = - install.PreparedAssetOverlayPath is null - ? null - : install.ResolvedBakeToolVersion, }, }, Sessions = [descriptor], @@ -198,7 +188,6 @@ public static class SessionConfigComposer ArgumentNullException.ThrowIfNull(install); ArgumentNullException.ThrowIfNull(paths); ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - EnsureClientCompatibilityConfirmed(install); (string configFilePath, string statusFilePath, string stderrLogPath) = BuildSessionPaths(paths, sessionId); @@ -230,15 +219,6 @@ public static class SessionConfigComposer { DatDirectory = install.DatDirectory, PreparedAssetPath = install.PreparedAssetPath, - PreparedAssetOverlayPath = install.PreparedAssetOverlayPath, - PreparedAssetBaseRecipeVersion = - install.PreparedAssetOverlayPath is null - ? null - : install.BakeToolVersion, - PreparedAssetEffectiveRecipeVersion = - install.PreparedAssetOverlayPath is null - ? null - : install.ResolvedBakeToolVersion, }, }, Sessions = [descriptor], @@ -324,17 +304,6 @@ public static class SessionConfigComposer return [.. configured]; } - private static void EnsureClientCompatibilityConfirmed( - LauncherInstallRecord install) - { - if (install.RequiresClientCompatibilityConfirmation) - { - throw new InvalidOperationException( - "Prepared content cannot be launched until the matching client " - + "has been confirmed or installed."); - } - } - private static ComposedSessionConfig Write(ComposedSessionConfig composed) { string? directory = Path.GetDirectoryName(composed.ConfigFilePath); diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs index 83d00df8..1fc31f4d 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs @@ -70,15 +70,6 @@ public sealed class SessionContentDescriptor public string DatDirectory { get; init; } = string.Empty; public string PreparedAssetPath { get; init; } = string.Empty; - - /// Present only for a layered prepared-content session. - public string? PreparedAssetOverlayPath { get; init; } - - /// Present with an overlay so the client can validate both pak - /// headers without assuming the base uses its current recipe. - public uint? PreparedAssetBaseRecipeVersion { get; init; } - - public uint? PreparedAssetEffectiveRecipeVersion { get; init; } } public sealed class SessionDescriptor diff --git a/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs index 377d9f03..e52c443f 100644 --- a/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs @@ -27,17 +27,6 @@ public interface ILauncherOrchestrator : IDisposable void SetInstallRecord(LauncherInstallRecord? installRecord); - /// - /// Publishes the exact result of asynchronous launcher content discovery. - /// The default keeps test/injected implementations source-compatible; - /// production additionally retains - /// so a failed check is not flattened into a misleading first-run message. - /// - void SetInstallationState( - LauncherInstallRecord? installRecord, - string installationStatus) => - SetInstallRecord(installRecord); - void AddServer(string name, string host, int port); void EditServer(string name, string newName, string newHost, int newPort); diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index 8939e2a4..d52acae7 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -242,23 +242,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator public void SetInstallRecord(LauncherInstallRecord? installRecord) { - SetInstallationState( - installRecord, - installRecord is null - ? FirstRunRequired - : "Client content SHA-256, size, and bake-tool version verified."); - } - - public void SetInstallationState( - LauncherInstallRecord? installRecord, - string installationStatus) - { - ArgumentException.ThrowIfNullOrWhiteSpace(installationStatus); lock (_gate) { ThrowIfDisposed(); _installRecord = installRecord; - _installationStatus = installationStatus; + _installationStatus = installRecord is null + ? FirstRunRequired + : "Client content SHA-256, size, and bake-tool version verified."; } RaiseStateChanged(); diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index d0f49756..73ed6bb3 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -49,6 +49,23 @@ public sealed partial class App : Application Path.Combine( AppContext.BaseDirectory, "acdream-bake" + executableSuffix)); + InstallRecordVerification verification; + try + { + // Hashing the package before constructing the orchestrator is + // intentional: no launch action is enabled until the persisted + // size/SHA/tool-version record has been verified. + verification = installer.LoadExistingAsync() + .GetAwaiter() + .GetResult(); + } + catch (Exception ex) + { + verification = new InstallRecordVerification( + InstallRecordVerificationState.Invalid, + null, + $"Client content verification failed: {ex.Message}"); + } LauncherUpdateComposition updates = LauncherUpdateComposition.Create( paths, @@ -64,8 +81,8 @@ public sealed partial class App : Application profiles, paths, updates.Executables, - installRecord: null, - installationStatus: "Checking installed game content…", + verification.Record, + installationStatus: verification.Status, updateSessionBarrier: updates.Versions.Barrier); // LU2: a launcher update installs and restarts by itself. The // helper waits on THIS process id and cannot replace files the @@ -91,32 +108,18 @@ public sealed partial class App : Application updates.Updater, applyLauncherUpdate, () => desktop.Shutdown()); - var mainWindow = new MainWindow + _viewModel.Initialize(); + + desktop.MainWindow = new MainWindow { DataContext = _viewModel, }; - desktop.MainWindow = mainWindow; - _viewModel.Initialize(); - mainWindow.Opened += OnMainWindowOpened; desktop.Exit += OnDesktopExit; } base.OnFrameworkInitializationCompleted(); } - private void OnMainWindowOpened(object? sender, EventArgs e) - { - if (sender is MainWindow window) - { - window.Opened -= OnMainWindowOpened; - } - - if (_viewModel is not null) - { - _ = _viewModel.StartBackgroundInitializationAsync(); - } - } - private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) { _viewModel?.Dispose(); diff --git a/src/AcDream.Launcher/LauncherUpdateComposition.cs b/src/AcDream.Launcher/LauncherUpdateComposition.cs index b2b86391..e828c8b6 100644 --- a/src/AcDream.Launcher/LauncherUpdateComposition.cs +++ b/src/AcDream.Launcher/LauncherUpdateComposition.cs @@ -71,15 +71,9 @@ internal sealed class LauncherUpdateComposition : IDisposable ReleaseManifestClient? manifestClient = null; try { - // Production initialization is deliberately deferred until after - // MainWindow.Opened. Client-version recovery verifies every file - // in the active version and is therefore not composition-root - // work. The injectable callback remains only for focused failure - // composition tests. - if (initialize is not null) - { - _ = initialize(versions, rid); - } + _ = initialize is null + ? versions.LoadAndRecoverAsync(rid).GetAwaiter().GetResult() + : initialize(versions, rid); artifactClient = new HttpClient( new HttpClientHandler { diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index 5b05836e..7d877b3d 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -56,15 +56,14 @@ + IsVisible="{Binding IsFirstRunRequired}"> - +