From f6fe0f2a4f40c43c0556e2ff598dbc6f0bf2a846 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 26 Aug 2026 20:45:11 +0200 Subject: [PATCH] fix(client): restore retail interaction parity Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation. --- docs/ISSUES.md | 283 ++++++- .../retail-divergence-register.md | 33 +- .../2026-08-10-options-panel-campaign.md | 12 +- ...-08-slice6-vendor-transactions-research.md | 55 +- ...-08-10-keyboard-config-and-gameplay-tab.md | 7 + .../2026-08-26-combined-client-parity-gate.md | 131 +++ ...26-08-26-issues-444-445-447-test-script.md | 43 + ...8-26-retail-inventory-interaction-audit.md | 745 ++++++++++++++++++ ...026-08-26-retail-keyboard-routing-audit.md | 115 +++ .../InteractionRetainedUiComposition.cs | 63 +- .../LivePresentationComposition.cs | 21 +- .../Composition/SessionPlayerComposition.cs | 8 +- .../SettingsDevToolsComposition.cs | 4 +- .../Diagnostics/FrameScreenshotController.cs | 30 + .../Input/CameraPointerInputController.cs | 82 ++ .../Input/DispatcherCameraInputSource.cs | 24 +- .../Input/GameplayInputActionRouter.cs | 132 +++- .../Input/GameplayInputCommandController.cs | 83 +- .../Input/GameplayInputFrameController.cs | 35 +- src/AcDream.App/Input/MouseLookController.cs | 4 +- .../Input/RetailEmoteMotionTable.cs | 129 +++ src/AcDream.App/Input/RetailKeymapFile.cs | 601 ++++++++++++++ .../SelectionInteractionController.cs | 188 ++++- .../Interaction/WorldSelectionQuery.cs | 219 ++++- src/AcDream.App/Net/DatChatPoseCatalog.cs | 81 ++ .../Net/LiveSessionCommandRouter.cs | 10 +- .../Net/LiveSessionRuntimeFactory.cs | 21 +- .../Rendering/CameraFrameController.cs | 22 + src/AcDream.App/Rendering/ChaseCamera.cs | 145 +++- src/AcDream.App/Rendering/GameWindow.cs | 42 +- .../Rendering/PaperdollFramePresenter.cs | 10 +- .../Rendering/PaperdollViewportRenderer.cs | 2 + .../PrivateEntityViewportRenderer.cs | 39 +- .../Rendering/RetailChaseCamera.cs | 178 ++++- .../Wb/WbDrawDispatcher.PackedOracle.cs | 10 +- .../Rendering/Wb/WbDrawDispatcher.Rhi.cs | 4 +- .../Rendering/Wb/WbDrawDispatcher.cs | 38 +- .../CurrentGameRuntimeCommandAdapter.cs | 22 + .../LocalPlayerTeleportController.cs | 39 +- src/AcDream.App/UI/AutoWieldController.cs | 73 +- .../UI/ItemInteractionController.cs | 217 ++++- .../UI/Layout/CharacterStatController.cs | 19 +- .../UI/Layout/ChatTranscriptRenderer.cs | 86 +- .../UI/Layout/ChatWindowController.cs | 39 + src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 41 +- .../UI/Layout/ExternalContainerController.cs | 138 +++- .../UI/Layout/InventoryController.cs | 285 ++++--- .../UI/Layout/JournalPanelController.cs | 3 + .../UI/Layout/KeyboardConfigController.cs | 346 +++++--- .../UI/Layout/MapHousePanelController.cs | 6 + src/AcDream.App/UI/Layout/OptionPageModel.cs | 18 +- .../UI/Layout/OptionsPanelController.cs | 22 + .../UI/Layout/PaperdollController.cs | 12 +- .../RetailConfirmationMenuDialogView.cs | 122 +++ src/AcDream.App/UI/Layout/RetailDialogData.cs | 32 + .../UI/Layout/RetailDialogFactory.cs | 30 +- src/AcDream.App/UI/Layout/RetailKeyNames.cs | 72 +- .../UI/Layout/SelectedObjectController.cs | 42 +- .../UI/Layout/SocialPanelController.cs | 4 + .../UI/Layout/SpellcastingUiController.cs | 24 +- .../UI/Layout/ToolbarInputController.cs | 17 + .../UI/Layout/VendorUiController.cs | 388 +++++++-- src/AcDream.App/UI/RetailUiRuntime.cs | 495 ++++++++++-- src/AcDream.App/UI/UiRoot.cs | 54 +- .../Messages/ClientCommandRequests.cs | 5 + src/AcDream.Core.Net/WorldSession.cs | 7 + .../Chat/ChatCommandTargetState.cs | 51 +- .../Chat/InventoryFailureMessages.cs | 9 +- src/AcDream.Core/Input/RetailActionMap.cs | 30 +- .../Items/ExternalContainerState.cs | 39 +- .../InventoryContainerPlacementPolicy.cs | 158 ++++ .../Items/InventoryTransactionState.cs | 2 + .../Items/ItemInteractionPolicy.cs | 72 +- src/AcDream.Core/Items/VendorStagingList.cs | 21 + src/AcDream.Core/Physics/MotionInterpreter.cs | 5 +- src/AcDream.Core/Physics/RawMotionState.cs | 26 + .../Chat/LiveChatCommandRoute.cs | 26 +- .../Chat/RetailPublicChatParser.cs | 76 ++ src/AcDream.Runtime/GameRuntimeActionViews.cs | 3 +- src/AcDream.Runtime/GameRuntimeCommands.cs | 6 + src/AcDream.Runtime/GameRuntimeViews.cs | 4 + .../Gameplay/LocalPlayerOutboundController.cs | 3 + .../Gameplay/PlayerMovementController.cs | 61 +- .../Gameplay/RuntimeActionState.cs | 3 +- .../Gameplay/RuntimeCombatAttackState.cs | 1 + .../Gameplay/RuntimeInventoryState.cs | 8 + .../RuntimeLocalPlayerMovementState.cs | 23 + .../DirectGameRuntimeCommandAdapter.cs | 19 + .../Input/InputAction.cs | 214 ++++- .../Input/InputDispatcher.cs | 268 +++++-- .../Input/InputScope.cs | 13 +- .../Input/KeyBindings.cs | 111 +-- .../Input/RetailActionIdentityTable.cs | 398 ++++++++-- .../Input/RetailScanCodeMap.cs | 177 ++++- .../Input/RetailUnmappedKeyBindings.cs | 16 +- .../Panels/Chat/ChatVM.cs | 6 + ...WorldLifecycleAutomationControllerTests.cs | 27 + .../CameraPointerInputControllerTests.cs | 42 +- .../Input/GameplayInputActionRouterTests.cs | 72 +- .../GameplayInputCommandControllerTests.cs | 64 +- .../GameplayInputFrameControllerTests.cs | 1 + .../Input/RetailEmoteMotionTableTests.cs | 45 ++ .../Input/RetailKeymapFileTests.cs | 113 +++ .../SelectionInteractionControllerTests.cs | 38 + .../Interaction/WorldSelectionQueryTests.cs | 194 ++++- .../Rendering/PaperdollFramePresenterTests.cs | 12 +- .../Rendering/RetailChaseCameraTests.cs | 54 ++ .../Runtime/CurrentGameRuntimeAdapterTests.cs | 9 + .../LocalPlayerTeleportControllerTests.cs | 31 + .../UI/AutoWieldGenerationTests.cs | 3 +- .../UI/DragDropSpineTests.cs | 32 + .../UI/ItemInteractionControllerTests.cs | 249 +++++- .../UI/Layout/CharacterStatControllerTests.cs | 24 +- .../UI/Layout/ChatTranscriptRunsTests.cs | 41 + .../UI/Layout/InventoryControllerTests.cs | 74 +- .../Layout/KeyboardConfigControllerTests.cs | 487 ++++++++++-- ...boardConfigInstalledDatConformanceTests.cs | 184 +++++ .../KeyboardConfigLiveMountProbeTests.cs | 13 +- .../UI/Layout/MapHousePanelControllerTests.cs | 16 + .../UI/Layout/OptionsPanelControllerTests.cs | 16 + .../UI/Layout/PaperdollControllerTests.cs | 14 +- .../UI/Layout/RetailDialogFactoryTests.cs | 86 +- .../UI/Layout/RetailKeyNamesTests.cs | 33 +- .../Layout/SelectedObjectControllerTests.cs | 25 +- .../Layout/SpellcastingShortcutInputTests.cs | 47 ++ .../UI/Layout/ToolbarInputControllerTests.cs | 30 + .../UI/Layout/VendorUiControllerTests.cs | 227 +++++- .../UI/RetailUiInteractionFlowTests.cs | 35 +- .../AcDream.App.Tests/UI/UiRootInputTests.cs | 38 + .../Messages/ClientCommandRequestsTests.cs | 1 + .../Messages/ServerMessageTests.cs | 16 + .../WorldSessionChatTests.cs | 12 + .../Chat/ChatCommandTargetStateTests.cs | 18 + .../Chat/InventoryFailureMessagesTests.cs | 6 + .../RetailActionIdentityRoundTripTests.cs | 119 +-- .../Input/RetailActionMapReaderTests.cs | 39 + .../Items/ExternalContainerStateTests.cs | 34 + .../InventoryContainerPlacementPolicyTests.cs | 76 ++ .../Items/ItemInteractionPolicyTests.cs | 55 +- .../Items/VendorStagingListTests.cs | 22 + .../Chat/LiveChatCommandRouteTests.cs | 65 ++ .../Chat/RetailPublicChatParserTests.cs | 35 + .../Gameplay/RuntimeCombatAttackStateTests.cs | 2 + .../Gameplay/RuntimeInventoryStateTests.cs | 20 + .../RuntimeLocalPlayerMovementStateTests.cs | 101 +++ .../Input/InputDispatcherCaptureTests.cs | 41 +- .../Input/InputDispatcherTests.cs | 40 + .../Input/KeyBindingsJsonTests.cs | 53 +- .../Input/KeyBindingsRetailTests.cs | 25 +- tools/dump-keymap/Program.cs | 50 ++ tools/run-release-gate.ps1 | 11 +- 151 files changed, 10162 insertions(+), 1211 deletions(-) create mode 100644 docs/research/2026-08-26-combined-client-parity-gate.md create mode 100644 docs/research/2026-08-26-issues-444-445-447-test-script.md create mode 100644 docs/research/2026-08-26-retail-inventory-interaction-audit.md create mode 100644 docs/research/2026-08-26-retail-keyboard-routing-audit.md create mode 100644 src/AcDream.App/Input/RetailEmoteMotionTable.cs create mode 100644 src/AcDream.App/Input/RetailKeymapFile.cs create mode 100644 src/AcDream.App/Net/DatChatPoseCatalog.cs create mode 100644 src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs create mode 100644 src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs create mode 100644 src/AcDream.Runtime/Chat/RetailPublicChatParser.cs create mode 100644 tests/AcDream.App.Tests/Input/RetailEmoteMotionTableTests.cs create mode 100644 tests/AcDream.App.Tests/Input/RetailKeymapFileTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/KeyboardConfigInstalledDatConformanceTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/SpellcastingShortcutInputTests.cs create mode 100644 tests/AcDream.Core.Tests/Items/InventoryContainerPlacementPolicyTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Chat/RetailPublicChatParserTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 5bb2231a..199cd8da 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,9 +24,251 @@ 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. +## #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 / OWNER-ACCEPTED 2026-08-25. +**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 @@ -59,6 +301,25 @@ 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 @@ -4004,8 +4265,7 @@ switching stays #376. ## #373 — Configure Keyboard: DAT `ActionMap.ConflictingMaps` not consulted — the combat cluster raises false conflict prompts -**Status:** OPEN — filed 2026-08-11 at Campaign OP slice OP8's re-review -round 2 (R1's scope boundary). +**Status:** DONE 2026-08-26 — fixed as the first #446 keyboard-parity slice. The DAT ActionMap (DID `0x26000000`) carries a `ConflictingMaps` table retail's `UIOption_ActionKeyMap` consults when deciding whether two rows @@ -4022,17 +4282,22 @@ 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 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 +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 `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). The gate script's §OP8 warns the user off treating the -false prompts as new breakage until this lands. +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. ## #372 — Options panel: Character/Chat/Config tabs render BLANK on screen and most Gameplay buttons do nothing (connected-gate failure) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1c82befa..0fec343b 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-22 +# Retail Divergence Register — current through 2026-08-26 **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, @@ -215,12 +215,33 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 164 active rows (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-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) +## 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) 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` | @@ -238,9 +259,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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 | **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-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-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` | @@ -372,7 +393,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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 | **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-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-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` | diff --git a/docs/plans/2026-08-10-options-panel-campaign.md b/docs/plans/2026-08-10-options-panel-campaign.md index 31591d10..14458996 100644 --- a/docs/plans/2026-08-10-options-panel-campaign.md +++ b/docs/plans/2026-08-10-options-panel-campaign.md @@ -94,8 +94,9 @@ 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) but persist to `keybinds.json`; retail `.keymap` - file interchange is a register-row deferral. + 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. - **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 @@ -360,8 +361,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:** `.keymap` file interchange not implemented (D4); any -retail column/behaviour consciously narrowed. +**Register rows:** any retail column/behaviour consciously narrowed. The +former D4 `.keymap` deferral was retired by #446 on 2026-08-26. **Gate:** connected — rebind a movement key, conflict prompt on a taken chord, persistence across relaunch, reset restores retail defaults. @@ -394,7 +395,8 @@ 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). -- Retail `.keymap` file read/write (D4 register row). +- None for retail `.keymap` file read/write; #446 implemented it on + 2026-08-26 and retired AP-202. - 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/research/2026-08-08-slice6-vendor-transactions-research.md b/docs/research/2026-08-08-slice6-vendor-transactions-research.md index 47052444..af23e0c3 100644 --- a/docs/research/2026-08-08-slice6-vendor-transactions-research.md +++ b/docs/research/2026-08-08-slice6-vendor-transactions-research.md @@ -337,44 +337,18 @@ nothing wrong" surprise, matching the register's existing framing of the ### B.2 — Double-click -**No dedicated double-click-to-buy mechanism was found for vendor shop -items.** Evidence, not absence-of-search: +**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. -- `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. +**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. ### B.3 — The quantity slider @@ -735,10 +709,9 @@ 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** — 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. +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. 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 9240d7b4..aaa846da 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,6 +5,13 @@ 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-26-combined-client-parity-gate.md b/docs/research/2026-08-26-combined-client-parity-gate.md new file mode 100644 index 00000000..ed1db04e --- /dev/null +++ b/docs/research/2026-08-26-combined-client-parity-gate.md @@ -0,0 +1,131 @@ +# 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 new file mode 100644 index 00000000..7ec5bac3 --- /dev/null +++ b/docs/research/2026-08-26-issues-444-445-447-test-script.md @@ -0,0 +1,43 @@ +# 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-retail-inventory-interaction-audit.md b/docs/research/2026-08-26-retail-inventory-interaction-audit.md new file mode 100644 index 00000000..028c7a3b --- /dev/null +++ b/docs/research/2026-08-26-retail-inventory-interaction-audit.md @@ -0,0 +1,745 @@ +# 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 new file mode 100644 index 00000000..75af11e7 --- /dev/null +++ b/docs/research/2026-08-26-retail-keyboard-routing-audit.md @@ -0,0 +1,115 @@ +# 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/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 116ff7ba..278737cf 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -96,7 +96,8 @@ internal sealed record InteractionRetainedUiDependencies( Func CurrentCalendar, AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null, Func? - RenderPackDiagnostics = null) + RenderPackDiagnostics = null, + string? ScreenshotsDirectory = null) { public RuntimeActionState Actions => Runtime.ActionOwner; @@ -388,9 +389,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory container, placement, amount), + sendStackableMerge: (source, target, amount) => + session.CurrentSession?.SendStackableMerge(source, target, amount), requestExternalContainer: guid => { - d.Inventory.ExternalContainers.RequestOpen(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); }, requestUse: selection.RequestUse, // Slice 6.3: ItemInteractionController.TryBuy owns the @@ -663,16 +670,20 @@ 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); - FrameScreenshotController? screenshots = null; - if (d.Options.UiProbeEnabled - && d.Options.AutomationArtifactDirectory is { } artifactDirectory) - { - screenshots = new FrameScreenshotController( - d.BackbufferReader, - Path.Combine(artifactDirectory, "screenshots"), - ProbeLog, - d.RenderPackDiagnostics); - } + 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); checkpoint(InteractionRetainedUiCompositionPoint.UiProbeCreated); var assets = new RetailUiAssets( @@ -1157,11 +1168,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.GameRuntime.CharacterSelectionConfirmDelete, late.GameRuntime.CharacterSelectionRestore, late.GameRuntime.CharacterSelectionCancel, - // 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. + // 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. d.Window.Close), // Campaign CC slice CC4: same late-bound generation-capturing // seam as CharacterSelection above. RequestExit here is a @@ -1203,7 +1213,24 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance, RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing, GetSkillScore: chargenSkillScoreResolver.Resolve, - OpenOnStart: d.Options.OpenCharacterCreationOnStart)); + 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); + } + }); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index aa9391f8..3ba05a6a 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -63,6 +63,7 @@ internal sealed record LivePresentationDependencies( CellVisibility CellVisibility, LiveWorldOriginState WorldOrigin, LocalPlayerIdentityState PlayerIdentity, + ChaseCameraInputState ChaseCameraInput, PointerPositionState PointerPosition, PlayerApproachCompletionState PlayerApproachCompletions, GameRenderResourceLifetime RenderResourceLifetime, @@ -808,7 +809,12 @@ internal sealed class LivePresentationCompositionPhase d.RetailAlphaQueue, alphaScratchBudgets.DispatcherBytes, foundation.TerrainAtlas?.BuildingDetailTexture ?? default, - () => d.Settings.DisplayPreview.BuildingDetailTextures), + () => d.Settings.DisplayPreview.BuildingDetailTextures, + serverGuid => serverGuid != 0u + && serverGuid == d.PlayerIdentity.ServerGuid + ? d.ChaseCameraInput.Retail?.PlayerTranslucency + ?? (d.ChaseCameraInput.Legacy?.IsInHead == true ? 1f : 0f) + : 0f), static value => value.Dispose()); var selectionQuery = new WorldSelectionQuery( liveEntities, @@ -845,7 +851,11 @@ internal sealed class LivePresentationCompositionPhase localEntityId => d.EffectPoses.TryGetRootPose(localEntityId, out Matrix4x4 childRoot) ? childRoot - : null); + : null, + hasOpenedCorpse: + d.Runtime.InventoryOwner.ExternalContainers.HasCorpseBeenOpened, + combatMode: () => d.Runtime.ActionOwner.Combat.CurrentMode, + isFellow: guid => d.Runtime.Fellowship.TryGetMember(guid, out _)); var radarSnapshotProvider = new RadarSnapshotProvider( d.EntityObjects.Objects, liveEntities, @@ -876,7 +886,12 @@ internal sealed class LivePresentationCompositionPhase () => d.PlayerController.Controller, d.PlayerApproachCompletions), d.Toast, - d.PlayerApproachCompletions); + d.PlayerApproachCompletions, + splitStack: guid => + interaction.RetainedUi?.Runtime.SelectedObjectController? + .FocusSplitStackEntry(guid) ?? false, + fellowshipMembers: () => + d.Runtime.Fellowship.GetMembers().Select(static member => member.Guid)); selectionInteractionSource.Bind(selectionInteractions); bindings.Adopt( "world selection", diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 5f934e36..2317eb30 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -1169,6 +1169,7 @@ internal sealed class SessionPlayerCompositionPhase live.SelectionInteractions), new LiveSessionWorldRuntime( content.Dats, + d.DatLock, content.Audio?.Engine is { } sessionAudioEngine ? new AcDream.App.Audio.WorldAudioSessionGate( sessionAudioEngine, @@ -1279,14 +1280,10 @@ internal sealed class SessionPlayerCompositionPhase new RetainedGameplayWindowCommands( interaction.RetainedUi?.Runtime), runtimeDiagnostics, - new PlayerModeGameplayCommands( - d.PlayerMode, - playerMode), + new PlayerModeGameplayCommands(playerMode), new ItemTargetModeCommands(interaction.ItemInteraction), - new GameplayCameraModeCommands(host.CameraController), gameRuntime, gameRuntime.Combat, - new GameplayWindowCommands(d.Window.Close), toggleAudioMute: content.Audio?.Engine is { } audioEngine ? () => { @@ -1305,6 +1302,7 @@ internal sealed class SessionPlayerCompositionPhase gameRuntime, gameRuntime.Selection, gameRuntime.MovementCommands, + gameRuntime.CharacterCommands, commands); GameplayInputActionRouter gameplayActions = GameplayInputActionRouter.Create( diff --git a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs index 15e7df79..526df5f3 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 to -/// keybinds.json (not retail's .keymap format — register row AP-202). +/// is Campaign OP slice OP8's Configure Keyboard screen, persisting retail +/// *.keymap profiles with keybinds.json as the host-command mirror. /// internal sealed record SettingsDevToolsResult( AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality) diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs index a2588d5b..9d1d0396 100644 --- a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs +++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs @@ -63,6 +63,36 @@ 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 aa2672da..9104323f 100644 --- a/src/AcDream.App/Input/CameraPointerInputController.cs +++ b/src/AcDream.App/Input/CameraPointerInputController.cs @@ -316,6 +316,88 @@ 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 dae50088..94a6ea8f 100644 --- a/src/AcDream.App/Input/DispatcherCameraInputSource.cs +++ b/src/AcDream.App/Input/DispatcherCameraInputSource.cs @@ -15,7 +15,9 @@ internal readonly record struct ChaseCameraAdjustmentInput( bool ZoomIn, bool ZoomOut, bool Raise, - bool Lower); + bool Lower, + bool RotateLeft, + bool RotateRight); internal interface ICameraFrameInputSource { @@ -71,9 +73,21 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource return default; return new ChaseCameraAdjustmentInput( - dispatcher.IsActionHeld(InputAction.CameraZoomIn), - dispatcher.IsActionHeld(InputAction.CameraZoomOut), - dispatcher.IsActionHeld(InputAction.CameraRaise), - dispatcher.IsActionHeld(InputAction.CameraLower)); + 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)); } } diff --git a/src/AcDream.App/Input/GameplayInputActionRouter.cs b/src/AcDream.App/Input/GameplayInputActionRouter.cs index 9840e0b1..303b91b0 100644 --- a/src/AcDream.App/Input/GameplayInputActionRouter.cs +++ b/src/AcDream.App/Input/GameplayInputActionRouter.cs @@ -3,6 +3,7 @@ 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; @@ -14,6 +15,8 @@ internal interface IGameplayInputActionSurface void RemoveFired(Action callback); void SetCombatScope(InputScope? scope); + + void SetCameraAlternateScope(bool active); } internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher) @@ -30,6 +33,9 @@ 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 @@ -66,6 +72,8 @@ internal interface IGameplayInputPriorityTargets bool HandleRetainedUiAction(InputAction action); + bool HandleCharacterOptionAction(InputAction action); + bool HandleSelectionAction(InputAction action); bool HandlePressedMovementAction(InputAction action); @@ -87,6 +95,7 @@ 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( @@ -97,6 +106,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets IGameRuntimeView runtimeView, IRuntimeSelectionCommands runtimeSelection, IRuntimeMovementCommands runtimeMovement, + IRuntimeCharacterCommands runtimeCharacter, IGameplayInputCommandTarget commands) { _frame = frame ?? throw new ArgumentNullException(nameof(frame)); @@ -109,11 +119,14 @@ 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); + _frame.HandlePointerAction(action, activation) + || _pointer.HandleCameraAction(action, activation); public void HandleScroll(InputAction action) => _pointer.HandleScroll(action); @@ -122,10 +135,74 @@ internal sealed class RuntimeGameplayInputPriorityTargets _frame.HandleCombatAction(action, activation); public bool HandleRetainedUiAction(InputAction action) => - _retainedUi?.HandleInputAction(action) == true; + 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; + } 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 => @@ -149,15 +226,32 @@ 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) { - RuntimeMovementCommand? command = action switch + if (RetailEmoteMotionTable.TryGetMotion(action, out uint motion)) { - InputAction.MovementRunLock => - RuntimeMovementCommand.ToggleRunLock, - InputAction.MovementStop => RuntimeMovementCommand.Stop, - _ => null, - }; + _runtimeMovement.ExecuteMotion( + _runtimeView.Generation, + motion); + return true; + } + + RuntimeMovementCommand? command = ResolvePressedMovementCommand(action); if (command is { } typed) { _runtimeMovement.Execute(_runtimeView.Generation, typed); @@ -167,6 +261,18 @@ 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); } @@ -298,6 +404,14 @@ 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; @@ -320,6 +434,8 @@ 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 f3fbb3a1..246c52b4 100644 --- a/src/AcDream.App/Input/GameplayInputCommandController.cs +++ b/src/AcDream.App/Input/GameplayInputCommandController.cs @@ -1,6 +1,5 @@ using AcDream.App.Combat; using AcDream.App.Diagnostics; -using AcDream.App.Rendering; using AcDream.App.UI; using AcDream.Runtime; using AcDream.UI.Abstractions.Input; @@ -24,6 +23,12 @@ internal interface IRetainedGameplayWindowCommands /// RetailUiRuntime.BindToolbarPanelButtons. /// void ToggleOptionsPanel(); + + void ToggleGameplayOptionsPage(); + + void FocusChatEntry(); + + void LogOutCharacter(); } internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime) @@ -39,35 +44,31 @@ 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( - ILocalPlayerModeSource mode, - PlayerModeController controller) : IPlayerModeGameplayCommands +internal sealed class PlayerModeGameplayCommands(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 @@ -88,37 +89,6 @@ 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); @@ -135,10 +105,8 @@ 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( @@ -146,21 +114,17 @@ 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; } @@ -206,10 +170,12 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg _playerMode.TogglePlayerMode(); return true; case InputAction.ToggleChatEntry: - // 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. + 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(); return true; case InputAction.ToggleOptionsPanel: // Campaign OP slice OP3 (D1): F11 opens the RETAIL Options @@ -227,6 +193,9 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg _runtimeView.Generation, RuntimeCombatCommand.ToggleMode); return true; + case InputAction.LOGOUT: + _retained.LogOutCharacter(); + return true; case InputAction.EscapeKey: HandleEscape(); return true; @@ -239,9 +208,7 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg { if (_targetMode.IsAnyTargetModeActive) _targetMode.CancelTargetMode(); - else if (_playerMode.IsPlayerMode) - _playerMode.ExitPlayerMode(); else - _window.Close(); + _retained.ToggleGameplayOptionsPage(); } } diff --git a/src/AcDream.App/Input/GameplayInputFrameController.cs b/src/AcDream.App/Input/GameplayInputFrameController.cs index f5b34fd0..4f87b484 100644 --- a/src/AcDream.App/Input/GameplayInputFrameController.cs +++ b/src/AcDream.App/Input/GameplayInputFrameController.cs @@ -9,6 +9,7 @@ internal interface ICombatInputFrameController { void Tick(); void HandleMovementInput(InputAction action, ActivationType activation); + void AbortAutomaticAttack(); bool HandleInputAction(InputAction action, ActivationType activation); } @@ -38,6 +39,11 @@ 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 @@ -52,16 +58,37 @@ 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 == ActivationType.Press - ? RuntimeInputActivation.Press - : RuntimeInputActivation.Release)); + activation switch + { + ActivationType.Press => RuntimeInputActivation.Press, + ActivationType.Release => RuntimeInputActivation.Release, + _ => RuntimeInputActivation.Press, + })); } } @@ -114,6 +141,8 @@ 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 998969ae..d3b28f79 100644 --- a/src/AcDream.App/Input/MouseLookController.cs +++ b/src/AcDream.App/Input/MouseLookController.cs @@ -149,7 +149,9 @@ internal sealed class MouseLookController : IMouseLookInputFrameController return true; } - if (action != InputAction.CameraInstantMouseLook) + if (action is not ( + InputAction.CameraInstantMouseLook + or InputAction.CameraActivateAlternateMode)) return false; if (activation == ActivationType.Press) diff --git a/src/AcDream.App/Input/RetailEmoteMotionTable.cs b/src/AcDream.App/Input/RetailEmoteMotionTable.cs new file mode 100644 index 00000000..0e7979d4 --- /dev/null +++ b/src/AcDream.App/Input/RetailEmoteMotionTable.cs @@ -0,0 +1,129 @@ +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 new file mode 100644 index 00000000..66130110 --- /dev/null +++ b/src/AcDream.App/Input/RetailKeymapFile.cs @@ -0,0 +1,601 @@ +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/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 35f40b98..d70ace45 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -24,6 +24,8 @@ 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, @@ -32,7 +34,9 @@ internal sealed class SelectionInteractionController IRuntimeInteractionTransport transport, IPlayerInteractionMovementSink movement, Action? toast = null, - PlayerApproachCompletionState? approachCompletions = null) + PlayerApproachCompletionState? approachCompletions = null, + Func? splitStack = null, + Func>? fellowshipMembers = null) { _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _query = query ?? throw new ArgumentNullException(nameof(query)); @@ -43,14 +47,96 @@ 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: - SelectClosestCombatTarget(showToast: true); + 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(); return true; case InputAction.SelectionPreviousSelection: _selection.SelectPrevious(); @@ -87,11 +173,109 @@ 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); diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index 883504bd..485ff89f 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -6,7 +6,9 @@ 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; @@ -25,6 +27,22 @@ 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, @@ -36,6 +54,7 @@ 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); @@ -46,6 +65,16 @@ 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); @@ -111,6 +140,9 @@ 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, @@ -122,7 +154,10 @@ internal sealed class WorldSelectionQuery Func playerPose, Func setupCylinder, Func selectionSphere, - Func childRootPose) + Func childRootPose, + Func? hasOpenedCorpse = null, + Func? combatMode = null, + Func? isFellow = null) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _objects = objects ?? throw new ArgumentNullException(nameof(objects)); @@ -134,8 +169,13 @@ 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(); @@ -293,6 +333,183 @@ 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 diff --git a/src/AcDream.App/Net/DatChatPoseCatalog.cs b/src/AcDream.App/Net/DatChatPoseCatalog.cs new file mode 100644 index 00000000..39649058 --- /dev/null +++ b/src/AcDream.App/Net/DatChatPoseCatalog.cs @@ -0,0 +1,81 @@ +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/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index c2969e76..2a72e03a 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -74,7 +74,10 @@ internal sealed record LiveSessionCommandBindings( Action SendAllegianceKick, Action SendAllegianceInfoRequest, Action SendAllegianceUpdateRequest, - Action? Log = null); + Action? Log = null, + Func? ResolvePose = null, + Action? ExecuteMotion = null, + Action? SendSoulEmote = null); internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry); internal readonly record struct RemoveShortcutRuntimeCmd(uint Index); @@ -185,7 +188,10 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting bindings.SendTell, bindings.SendChannel, bindings.SendTurbineChat, - bindings.Log)); + bindings.Log, + bindings.ResolvePose, + bindings.ExecuteMotion, + bindings.SendSoulEmote)); // Campaign CH slice CH4 (2026-08-09): the 22 unregistered // ChannelSystem::GetChannelID fallback tags — bypasses // ChatChannelKind/ChannelResolver entirely and sends the raw diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 9aa33819..6ea3a7b6 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -67,6 +67,7 @@ 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. @@ -114,6 +115,7 @@ 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 @@ -162,6 +164,7 @@ 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( @@ -217,6 +220,15 @@ 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 @@ -786,7 +798,14 @@ internal sealed class LiveSessionRuntimeFactory SendAllegianceKick: session.SendAllegianceKick, SendAllegianceInfoRequest: session.SendAllegianceInfoRequest, SendAllegianceUpdateRequest: session.SendAllegianceUpdateRequest, - Log: _log); + 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); } private static double ClientTimerNow() => diff --git a/src/AcDream.App/Rendering/CameraFrameController.cs b/src/AcDream.App/Rendering/CameraFrameController.cs index 949942d7..a0cb3202 100644 --- a/src/AcDream.App/Rendering/CameraFrameController.cs +++ b/src/AcDream.App/Rendering/CameraFrameController.cs @@ -85,6 +85,28 @@ 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)) diff --git a/src/AcDream.App/Rendering/ChaseCamera.cs b/src/AcDream.App/Rendering/ChaseCamera.cs index 10bf2111..f8cf01b4 100644 --- a/src/AcDream.App/Rendering/ChaseCamera.cs +++ b/src/AcDream.App/Rendering/ChaseCamera.cs @@ -10,6 +10,21 @@ 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. @@ -108,10 +123,35 @@ public sealed class ChaseCamera : ICamera float horizontalDist = Distance * MathF.Cos(Pitch); float verticalDist = Distance * MathF.Sin(Pitch); - Position = new Vector3( - playerPosition.X - forwardX * horizontalDist, - playerPosition.Y - forwardY * horizontalDist, - _trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne) + 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) + } } /// @@ -119,6 +159,8 @@ public sealed class ChaseCamera : ICamera /// public void AdjustPitch(float delta) { + ExitLookDownForAdjustment(); + ExitInHeadForAdjustment(); Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); } @@ -127,6 +169,101 @@ 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/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 11ef0f3a..b454dd94 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,5 +1,6 @@ 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; @@ -17,6 +18,7 @@ using DatReaderWriter; using Silk.NET.Input; using Silk.NET.Maths; using Silk.NET.Windowing; +using AcDream.UI.Abstractions.Input; namespace AcDream.App.Rendering; @@ -599,14 +601,18 @@ 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.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path); - Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {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}"); return bindings; } @@ -1522,7 +1528,8 @@ public sealed class GameWindow : hostInputCamera.GpuFrameLifetime, () => WorldTime.CurrentCalendar, settingsDevTools.RenderPacks, - _renderPackDiagnostics.CaptureDiagnostics), + _renderPackDiagnostics.CaptureDiagnostics, + _applicationPaths.ScreenshotsDirectory), _retailUiLease, this).Compose( platformResult, @@ -1569,6 +1576,7 @@ public sealed class GameWindow : _cellVisibility, _liveWorldOrigin, _localPlayerIdentity, + _chaseCameraInput, _pointerPosition, _playerApproachCompletions, _renderResourceLifetime, @@ -1821,6 +1829,7 @@ 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. @@ -1861,6 +1870,33 @@ 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/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index b2502a1a..815a794c 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -14,6 +14,8 @@ internal interface IPaperdollDollRenderer { void SetDoll(WorldEntity? doll); + void Prepare(); + uint Render(int width, int height); } @@ -73,9 +75,6 @@ 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)) @@ -101,6 +100,11 @@ 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 03309a72..f5aa80ff 100644 --- a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs @@ -42,6 +42,8 @@ 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/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs index a5bc2e6a..84961aab 100644 --- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs @@ -94,6 +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; @@ -170,6 +175,29 @@ 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 @@ -219,6 +247,16 @@ internal sealed class PrivateEntityViewportRenderer : 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; @@ -254,7 +292,6 @@ internal sealed class PrivateEntityViewportRenderer : UploadCreatureLight(); - IReadOnlyList drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity); var entries = new (uint, Vector3, Vector3, IReadOnlyList, IReadOnlyDictionary?)[] diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index 991466e1..5362b457 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -29,6 +29,12 @@ 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; } @@ -75,6 +81,20 @@ 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 @@ -172,8 +192,13 @@ 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) = ComputeDesiredPose( - pivotWorld, heading, Distance, Pitch, viewerYawOffset); + (Vector3 targetEye, Vector3 targetForward) = _inHead + ? ComputeInHeadPose(pivotWorld, heading) + : _targetDirectionLocal is { } localDirection + ? ComputeTargetDirectionPose( + pivotWorld, heading, Distance, Pitch, localDirection) + : ComputeDesiredPose( + pivotWorld, heading, Distance, Pitch, viewerYawOffset); // 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera // (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the @@ -279,16 +304,120 @@ public sealed class RetailChaseCamera : ICamera /// ... Mirrors /// legacy ChaseCamera.AdjustDistance. /// - public void AdjustDistance(float delta) => + public void AdjustDistance(float delta) + { + ExitLookDownForAdjustment(); + ExitInHeadForAdjustment(); 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) => + public void AdjustPitch(float delta) + { + ExitLookDownForAdjustment(); + ExitInHeadForAdjustment(); 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 @@ -436,6 +565,47 @@ 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/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs index e701d3fc..d3df7aef 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs @@ -473,6 +473,7 @@ public sealed unsafe partial class WbDrawDispatcher } float opacity = PackedPartOpacity( + entity.ServerGuid, entity.LocalEntityId, (uint)setupPartIndex); if (opacity < 1f) @@ -527,6 +528,7 @@ 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) @@ -585,20 +587,24 @@ 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 1f; + return opacity; } return translucency >= 1f ? 0f - : 1f - translucency; + : opacity * (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 25d3dc17..30b22946 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs @@ -176,7 +176,8 @@ public sealed unsafe partial class WbDrawDispatcher RetailAlphaQueue? alphaQueue = null, long? alphaScratchBudgetBytes = null, TerrainAtlas.RetailDetailTextureBinding buildingDetail = default, - Func? buildingDetailEnabled = null) + Func? buildingDetailEnabled = null, + Func? hierarchicalTranslucency = null) { _device = device ?? throw new ArgumentNullException(nameof(device)); _frames = frames ?? throw new ArgumentNullException(nameof(frames)); @@ -192,6 +193,7 @@ 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 3c1f4bd9..3ea377bb 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -91,6 +91,7 @@ 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; @@ -1784,11 +1785,12 @@ 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 = 1.0f; + float opacityMultiplier = EntityOpacity(entity.ServerGuid); + if (opacityMultiplier <= 0f) continue; 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)) @@ -1818,12 +1820,14 @@ 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 = 1.0f; + float opacityMultiplier = EntityOpacity(entity.ServerGuid); 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) @@ -1901,6 +1905,32 @@ 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/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index dd8c811b..2e15bffe 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -317,6 +317,28 @@ 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) diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 480e3f7a..22e87446 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -736,6 +736,16 @@ 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, @@ -984,6 +994,8 @@ internal sealed class LocalPlayerTeleportController return false; } + _logoutStreamingRetirementPrepared = false; + if (!_transit.TryBeginLogoutRequest(_logout.IsLocalPlayerKiller)) return false; @@ -1088,8 +1100,23 @@ internal sealed class LocalPlayerTeleportController private void CompleteLogoutHandoff(long generation) { - if (!_transit.CompleteLogout() || _lifetimeGeneration != 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"); @@ -1102,6 +1129,8 @@ 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 @@ -1927,6 +1956,11 @@ internal sealed class LocalPlayerTeleportController bool clearSession, bool resetCanonicalTransit = false) { + bool streamingRetirementPrepared = clearSession + && _logoutStreamingRetirementPrepared; + if (clearSession) + _logoutStreamingRetirementPrepared = false; + long generation = checked(++_lifetimeGeneration); _pendingCell = 0u; @@ -1951,7 +1985,8 @@ internal sealed class LocalPlayerTeleportController if (clearSession) _loginPlacementCompleted = false; - _streaming.ResetRecenter(clearSession); + if (!streamingRetirementPrepared) + _streaming.ResetRecenter(clearSession); if (_lifetimeGeneration != generation) return generation; diff --git a/src/AcDream.App/UI/AutoWieldController.cs b/src/AcDream.App/UI/AutoWieldController.cs index e0cd8d66..ea21a67a 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) + Action? sendChangeCombatMode = null, + InventoryTransactionState? transactions = 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,8 +238,20 @@ internal sealed class AutoWieldController : IDisposable : BestAvailableEquipMask(item); if (mask == EquipMask.None) { - _toast?.Invoke("That slot is already in use"); - return false; + // 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); } return SendWield(item, mask, combatModeAfterWield: null); @@ -252,10 +264,7 @@ 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) @@ -272,8 +281,17 @@ internal sealed class AutoWieldController : IDisposable // is the transaction boundary and preserves its stance-specific motion. _systemMessage?.Invoke( $"Moving {blockingItem.GetAppropriateName()} to your backpack"); - _sendPutItemInContainer(blockingItem.ObjectId, player, 0); - return true; + bool dispatched = DispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + blockingItem.ObjectId, + () => + { + _sendPutItemInContainer(blockingItem.ObjectId, player, 0); + return true; + }); + if (!dispatched) + _pendingSwitch = null; + return dispatched; } private bool SendWield( @@ -288,17 +306,26 @@ internal sealed class AutoWieldController : IDisposable BlockingItemId: 0, RequestedMask: mask, CombatModeAfterWield: combatModeAfterWield); - if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask)) - { - _pendingSwitch = null; - return false; - } - // Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590. - _sendWield(item.ObjectId, (uint)mask); - return true; + bool dispatched = DispatchInventoryRequest( + InventoryRequestKind.Wield, + item.ObjectId, + () => + { + _sendWield(item.ObjectId, (uint)mask); + return true; + }); + if (!dispatched) + _pendingSwitch = null; + return dispatched; } + private bool DispatchInventoryRequest( + InventoryRequestKind kind, + uint itemId, + Func dispatch) + => _transactions?.TryDispatch(kind, itemId, dispatch) ?? dispatch(); + private void OnObjectMoved(ClientObjectMove move) { if (_pendingSwitch is not { } pending @@ -454,6 +481,14 @@ 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 a0515a53..6e767cab 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -43,6 +43,7 @@ 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; @@ -118,7 +119,8 @@ public sealed class ItemInteractionController : IDisposable Func? sendBuy = null, Func, uint, bool>? sendBuyAll = null, Func, bool>? sendSell = null, - Action? interfaceText = null) + Action? interfaceText = null, + Action? sendStackableMerge = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -130,6 +132,7 @@ public sealed class ItemInteractionController : IDisposable _sendSplitToWorld = sendSplitToWorld; _sendPutItemInContainer = sendPutItemInContainer; _sendSplitToContainer = sendSplitToContainer; + _sendStackableMerge = sendStackableMerge; _sendGive = sendGive; _nowMs = nowMs ?? (() => Environment.TickCount64); _toast = toast; @@ -168,10 +171,10 @@ public sealed class ItemInteractionController : IDisposable _playerGuid, _sendWield, sendPutItemInContainer, - _toast, _systemMessage, combatState, - sendChangeCombatMode); + sendChangeCombatMode, + _transactions); _interactionState.Changed += OnInteractionModeChanged; _transactions.StateChanged += OnTransactionStateChanged; _transactions.RequestCompleted += OnInventoryRequestCompleted; @@ -182,6 +185,12 @@ 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 @@ -457,6 +466,40 @@ 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; + }); + } + /// /// Increments retail's shared ClientUISystem busy reference after a /// request issued by another retained controller has been sent. The @@ -486,6 +529,29 @@ 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. @@ -689,15 +755,46 @@ public sealed class ItemInteractionController : IDisposable /// publishes the waiting destination slot before issuing the move request, /// exactly like double-click pickup through ItemHolder. /// - public bool PlaceWorldItemInBackpack(uint itemGuid) + public bool PlaceWorldItemInBackpack(uint itemGuid, bool mainPack = false) { if (itemGuid == 0u || _placeInBackpack is null) return false; - uint containerId = _backpackContainerId(); + uint containerId = mainPack ? _playerGuid() : _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, @@ -716,6 +813,67 @@ 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, @@ -1043,6 +1201,14 @@ 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 @@ -1052,9 +1218,17 @@ public sealed class ItemInteractionController : IDisposable { ArgumentNullException.ThrowIfNull(payload); - if (payload.SourceKind == ItemDragSource.ShortcutBar) + return PlaceIn3D(payload.ObjId, payload.SourceKind, targetGuid); + } + + private bool PlaceIn3D( + uint itemGuid, + ItemDragSource sourceKind, + uint targetGuid) + { + if (sourceKind == ItemDragSource.ShortcutBar) return false; - if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item) + if (itemGuid == 0 || _objects.Get(itemGuid) is not { } item) return false; if (!EnsureInventoryRequestReady()) return false; @@ -1154,7 +1328,7 @@ public sealed class ItemInteractionController : IDisposable break; case ItemPolicyActionKind.Reject: if (!string.IsNullOrWhiteSpace(action.Message)) - _toast?.Invoke(action.Message); + ReportClientLocal(action.Message); break; case ItemPolicyActionKind.OpenSecureTrade: // Use-on-player (ItemHolder::DetermineUseResult @@ -1169,8 +1343,9 @@ public sealed class ItemInteractionController : IDisposable PolicyActionRequested?.Invoke(action); bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null; if (!handled) - _toast?.Invoke(PolicyActionMessage(action)); - acted |= handled || _toast is not null; + ReportClientLocal(PolicyActionMessage(action)); + acted |= handled || _interfaceText is not null + || _systemMessage is not null || _toast is not null; break; } } @@ -1199,14 +1374,8 @@ public sealed class ItemInteractionController : IDisposable action.ObjectId, () => { - if (_sendDrop is null - || !_objects.MoveItemOptimistic( - action.ObjectId, - newContainerId: 0u, - newSlot: -1)) - { + if (_sendDrop is null) return false; - } _sendDrop(action.ObjectId); return true; }); @@ -1290,13 +1459,13 @@ public sealed class ItemInteractionController : IDisposable } case ItemPolicyActionKind.Reject: if (!string.IsNullOrWhiteSpace(action.Message)) - _toast?.Invoke(action.Message); + ReportClientLocal(action.Message); break; default: _auxiliaryAction?.Invoke(action); PolicyActionRequested?.Invoke(action); if (_auxiliaryAction is null && PolicyActionRequested is null) - _toast?.Invoke(PolicyActionMessage(action)); + ReportClientLocal(PolicyActionMessage(action)); break; } } @@ -1308,7 +1477,7 @@ public sealed class ItemInteractionController : IDisposable _interactionState.EnterUseItemOnTarget(sourceGuid); var name = _objects.Get(sourceGuid)?.Name; if (!string.IsNullOrWhiteSpace(name)) - _toast?.Invoke($"Choose a target for the {name}"); + ReportClientLocal($"Choose a target for the {name}"); } private void ClearTargetMode() @@ -1387,8 +1556,6 @@ 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; @@ -1407,7 +1574,7 @@ public sealed class ItemInteractionController : IDisposable if (InventoryFailureMessages.Compose(request.Kind, name, weenieError) is { } text) { - _interfaceText(text, RetailLogTextType.ClientLocal); + ReportClientLocal(text); } } @@ -1443,6 +1610,7 @@ public sealed class ItemInteractionController : IDisposable _transactions.RequestCompleted -= OnInventoryRequestCompleted; _transactions.StateChanged -= OnTransactionStateChanged; WorldDropDispatched = null; + MergeAttempted = null; _autoWield.Dispose(); } @@ -1575,7 +1743,8 @@ public sealed class ItemInteractionController : IDisposable stackSize, stackSize, IsIn3DView: item.ContainerId == 0 && item.WielderId == 0 - && item.ObjectId != _playerGuid()); + && item.ObjectId != _playerGuid(), + Name: item.GetAppropriateName()); } /// diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index dd48e83d..d6097c21 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -255,13 +255,23 @@ public static class CharacterStatController // RetailScrollbarChrome (2026-08-24: the previous local constants seated // the DOWN-arrow art on the top button). - private enum CharacterStatTab + public 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, @@ -386,7 +396,7 @@ public static class CharacterStatController /// next click. The caller invokes this from the sheet-changed /// subscription. /// - public static Action Bind( + public static Binding Bind( ImportedLayout layout, Func data, UiDatFont? datFont = null, @@ -881,7 +891,10 @@ public static class CharacterStatController // luminance-award quality change. } - return () => RefreshAfterRaise(null); + return new Binding( + () => RefreshAfterRaise(null), + SwitchTab, + () => activeTab[0]); } private static UiScrollbar? PrepareSkillScrollbar( diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs index 21e9071d..a8f407c9 100644 --- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs +++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs @@ -100,9 +100,10 @@ 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. 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. + /// 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. /// /// public const int MaxTranscriptCharacters = 0x2710; @@ -119,6 +120,14 @@ 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--) @@ -127,11 +136,68 @@ internal static class ChatTranscriptRenderer continue; // +1 for the newline retail stores between lines. - used += detailed[i].Text.Length + 1; - if (used > budget) - return i + 1; + 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); } - return 0; + 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 }; } /// @@ -257,12 +323,14 @@ internal static class ChatTranscriptRenderer // (defaultColor), matching retail's DoFontReset — not the color table's // unrelated index-0x00 slot. Vector4 currentColor = defaultColor; - int firstLine = FirstLineWithinBudget(detailed, accept); - for (int lineIndex = firstLine; lineIndex < detailed.Count; lineIndex++) + BudgetStart start = FindBudgetStart(detailed, accept); + for (int lineIndex = start.LineIndex; 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 ff31b824..cc465bc2 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -5,6 +5,7 @@ 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; @@ -1029,6 +1030,44 @@ 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/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index c81113b5..5091ec3f 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 => new UiMenu(), // UIElement_Menu (reg :120163) + 6 => BuildMenu(info, resolve, elementFont, fontResolve), // 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,6 +133,7 @@ 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) @@ -163,7 +164,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 => new UiMenu(), + 0x10000038u => BuildMenu(info, resolve, elementFont, fontResolve), // 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 @@ -209,6 +210,42 @@ 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 diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs index 75d9a9f5..48e92883 100644 --- a/src/AcDream.App/UI/Layout/ExternalContainerController.cs +++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs @@ -43,6 +43,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine private readonly UiItemList _contentsList; private uint _openContainer; + private PendingBackpackPlacement? _pendingPlacement; private bool _closeRequested; private bool _disposed; @@ -115,6 +116,9 @@ 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(); } @@ -210,13 +214,14 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine UiItemSlot targetCell, ItemDragPayload payload) { + if (payload.SourceKind == ItemDragSource.ShortcutBar) + return ItemDragAcceptance.None; if (!ReferenceEquals(targetList, _contentsList) - || payload.SourceKind == ItemDragSource.ShortcutBar - || payload.ObjId == 0u - || _openContainer == 0u - || payload.ObjId == _openContainer) + || _openContainer == 0u) return ItemDragAcceptance.Reject; - return ItemDragAcceptance.Accept; + return EvaluateDrop(payload.ObjId) == InventoryContainerPlacementRejection.None + ? ItemDragAcceptance.Accept + : ItemDragAcceptance.Reject; } public void HandleDropRelease( @@ -224,8 +229,19 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine UiItemSlot targetCell, ItemDragPayload payload) { - if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept) + 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); + } return; + } if (!_itemInteraction.EnsureInventoryRequestReady()) return; if (_objects.Get(payload.ObjId) is not { } item) @@ -246,17 +262,30 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine InventoryRequestKind kind = amount < fullStack ? InventoryRequestKind.SplitToContainer : InventoryRequestKind.PutInContainer; - _itemInteraction.TryDispatchInventoryRequest( - kind, - item.ObjectId, - () => - { - if (amount < fullStack) + if (amount < fullStack) + { + _itemInteraction.TryDispatchInventoryRequest( + kind, + item.ObjectId, + () => + { _sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount); - else + return true; + }); + } + else + { + _itemInteraction.TryDispatchPendingBackpackPlacement( + item.ObjectId, + _openContainer, + placement, + kind, + () => + { _sendPutItemInContainer(item.ObjectId, _openContainer, placement); - return true; - }); + return true; + }); + } } private void OnExternalContainerChanged(ExternalContainerTransition transition) @@ -314,10 +343,29 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine AddContainerCell(guid); } + var visibleContents = new List(); foreach (uint guid in _objects.GetContents(_openContainer)) { if (!IsContainer(_objects.Get(guid))) - AddContentsCell(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); } ApplyIndicators(); } @@ -334,15 +382,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) + private void AddContentsCell(uint guid, bool waiting = false) { UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground); cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid); + cell.SetWaitingState(waiting); cell.DragAcceptSprite = 0x060011F9u; cell.DragRejectSprite = 0x060011F8u; _contentsList.AddItem(cell); @@ -387,7 +435,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine { if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive) return true; - Select(guid); + if (IsContainer(_objects.Get(guid)) && guid != _state.CurrentContainerId) + OpenNestedContainer(guid); + else + Select(guid); return false; } @@ -406,7 +457,8 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine bool pendingSource = _itemInteraction.IsPendingSource(cell.ItemId); cell.Selected = cell.ItemId != 0u && cell.ItemId == _selection.SelectedObjectId - && !pendingSource; + && !pendingSource + && !_itemInteraction.IsPendingInventorySource(cell.ItemId); cell.IsOpenContainer = cell.ItemId != 0u && cell.ItemId == _openContainer; } } @@ -486,7 +538,48 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine } private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators(); - private void OnInteractionStateChanged() => 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 static bool IsContainer(ClientObject? item) => item is not null @@ -573,6 +666,9 @@ 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 7fb41022..e03a5fc5 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -116,6 +116,8 @@ 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); @@ -299,14 +301,13 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (containerId == EffectiveOpen() || containerId == _playerGuid()) Populate(); } - private void OnInteractionStateChanged() => ApplyIndicators(); + private void OnInteractionStateChanged() => Populate(); private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending) { if (_pendingListPlacement is not null || pending.ItemId == 0u - || pending.ContainerId != EffectiveOpen() - || _objects.Get(pending.ItemId) is not { } item - || IsBag(item)) + || pending.ContainerId == 0u + || _objects.Get(pending.ItemId) is null) { return; } @@ -375,12 +376,33 @@ 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) AddCell(_containerList, guid, isContainer: true); + 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); } // Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has @@ -394,20 +416,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 = pending is { } waitingProjection + bool waiting = IsWaitingSource(guid) + || pending is { } waitingProjection && waitingProjection.ContainerId == open && waitingProjection.ItemId == guid; AddCell(_contentsGrid, guid, isContainer: false, waiting); @@ -455,8 +477,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); } @@ -474,6 +496,21 @@ 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. @@ -499,12 +536,15 @@ 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); } @@ -513,7 +553,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (_itemInteraction?.OfferPrimaryClick(guid) is not null and not ItemPrimaryClickResult.NotActive) return true; - SelectItem(guid); + if (_objects.Get(guid) is { } item && IsBag(item)) + OpenContainer(guid); + else + SelectItem(guid); return false; } @@ -522,7 +565,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (_itemInteraction?.OfferSelfPrimaryClick() is not null and not ItemPrimaryClickResult.NotActive) return true; - SelectItem(guid); + OpenContainer(guid); return false; } @@ -556,11 +599,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0; if (cap <= 0) { cell.CapacityFill = -1f; return; } - int n = _objects.GetContents(containerGuid).Count; + // 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); cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f); } - // ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ────────────── + // ── IItemListDragHandler (B-Drag) — request first; server owns placement ──────────────────── /// 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. @@ -583,35 +630,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo // remove-on-lift stands. if (payload.SourceKind == ItemDragSource.ShortcutBar) return ItemDragAcceptance.None; - 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; + return EvaluateDrop(targetList, targetCell, payload.ObjId, out _, out _) + == InventoryContainerPlacementRejection.None + ? ItemDragAcceptance.Accept + : 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 remains optimistic. Retail: + /// for the server-created object's guid; a whole move displays only the + /// destination list's waiting projection until the server responds. Retail: /// ItemHolder::AttemptToPlaceInContainer @ 0x00588140. public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { @@ -627,8 +654,24 @@ 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. - if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept) + 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); + } return; + } // UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every // release while m_pendingItem exists, before merge, split, or ordinary @@ -662,7 +705,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 - : _objects.GetContents(container).Count; // first empty = append + : CountLooseContents(container); // first empty = append after visible loose items } else if (targetList == _containerList || targetList == _topContainer) { @@ -697,79 +740,65 @@ 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. - DispatchInventoryRequest( - InventoryRequestKind.SplitToContainer, - item, - () => - { - if (_sendStackableSplitToContainer is null) - return false; - _sendStackableSplitToContainer( - item, - container, - (uint)placement, - splitSize); - return true; - }); + 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; } } - // 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) + // 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) { - 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, + InventoryRequestKind kind = payload.SourceKind == ItemDragSource.Ground + ? InventoryRequestKind.Pickup + : InventoryRequestKind.PutInContainer; + if (!_itemInteraction.TryDispatchPendingBackpackPlacement( item, + container, + placement, + kind, () => { - if (_sendPutItemInContainer is null - || !_objects.MoveItemOptimistic(item, container, placement)) - { + if (_sendPutItemInContainer is null) return false; - } _sendPutItemInContainer(item, container, placement); return true; - }); + })) + { return; } - _objects.MoveItemOptimistic(item, container, placement); + return; } + + if (_pendingListPlacement is not null) + return; + _pendingListPlacement = new PendingListPlacement(0u, item, container, placement); + Populate(); _sendPutItemInContainer?.Invoke(item, container, placement); } @@ -832,9 +861,57 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { int cap = _objects.Get(container)?.ItemsCapacity ?? 0; if (cap <= 0) return false; - return _objects.GetContents(container).Count >= cap; + return CountLooseContents(container) >= 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) @@ -895,7 +972,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { var cell = list.GetItem(i); if (cell is null) continue; - bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true; + bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true + || IsWaitingSource(cell.ItemId); cell.Selected = cell.ItemId != 0 && cell.ItemId == _selection.SelectedObjectId && !pendingTargetSource; @@ -1012,6 +1090,7 @@ 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 141a1485..f0846720 100644 --- a/src/AcDream.App/UI/Layout/JournalPanelController.cs +++ b/src/AcDream.App/UI/Layout/JournalPanelController.cs @@ -180,6 +180,9 @@ 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 c1c67d33..a04b4739 100644 --- a/src/AcDream.App/UI/Layout/KeyboardConfigController.cs +++ b/src/AcDream.App/UI/Layout/KeyboardConfigController.cs @@ -66,19 +66,17 @@ 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. 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 +/// 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 /// 's bindings for that action: a rebind here takes /// effect immediately for live gameplay dispatch through the SAME -/// 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). +/// 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. /// /// /// @@ -90,8 +88,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 -/// / if the action -/// starts wholly unbound) and reapplies it to every chord this row ever writes — +/// the retail identity table 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 @@ -113,9 +111,8 @@ 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 via -/// exactly like retail's distinct OpenCantOverwriteBindingDialog, with no -/// dialog (a hard stop, matching the DAT-verified refusal string). A genuine +/// F-keys, ...) — refused through retail's type-3 +/// OpenCantOverwriteBindingDialog with the exact DAT template. 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; @@ -123,15 +120,10 @@ namespace AcDream.App.UI.Layout; /// /// /// -/// 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. +/// 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. /// /// public sealed class KeyboardConfigController @@ -204,8 +196,14 @@ public sealed class KeyboardConfigController Action> BeginCapture, Action Save, Action Toggle, - Action DisplaySystemMessage, - string NonBindableRefusalText, + // 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, // 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); @@ -221,25 +219,36 @@ 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); + 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); 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 - /// (Defaults/Revert/OK/Cancel; Load/Save File are INERT — D4, no - /// .keymap interchange). Returns null if the layout's window root + /// (Load File/Save As/Defaults/Revert/OK/Cancel). Returns null if the layout's window root /// did not import (a missing/malformed LayoutDesc). /// public static KeyboardConfigController? Bind( @@ -266,6 +275,7 @@ 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 @@ -397,11 +407,9 @@ 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). 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). + // (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. var captionText = new UiText { Left = 0f, @@ -423,39 +431,41 @@ 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 - // own defaults (Press/Game) only when the action starts wholly unbound. + // retail action-identity metadata 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) - : (ActivationType.Press, InputScope.Game); + : ( + RetailActionIdentityTable.ActivationFor(row.InputMapId, row.ActionId), + RetailActionIdentityTable.ScopeForInputMap(row.InputMapId)); IReadOnlyList defaults = DatDefaultsToChords(row.DefaultBindings); IReadOnlyList storedUnmapped = mapped ? Array.Empty() : bindings.CurrentForUnmapped((row.InputMapId, row.ActionId)); - // 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). + // An unknown future-DAT row with no persisted chords displays its DAT + // defaults. Installed EoR rows always take the mapped branch. IReadOnlyList initial = mapped ? liveBindings.Select(b => b.Chord).ToArray() : storedUnmapped.Count > 0 ? storedUnmapped : defaults; var model = new ActionKeyMapOptionRow(initial, defaults, apply: value => { - // 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. + // 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. IReadOnlyList real = value.Where(c => c != default).ToArray(); if (mapped) bindings.SetForAction( @@ -474,7 +484,6 @@ 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); } @@ -516,10 +525,36 @@ public sealed class KeyboardConfigController for (int i = 0; i < view.KeyButtons.Count; i++) { bool bound = i < current.Count && current[i] != default; - view.KeyButtons[i].Label = bound ? _describe(current[i]) : null; + 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 }); } } + 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. @@ -548,13 +583,34 @@ public sealed class KeyboardConfigController } } - bindings.BeginCapture(captured => + void ArmCapture() => 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) { @@ -564,18 +620,23 @@ 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's ported refusal, no dialog. - bindings.DisplaySystemMessage(bindings.NonBindableRefusalText); + // 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); 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 names = string.Join(", ", conflictRows.Select(r => r.Label ?? "?")); - string message = - $"'{_describe(chord)}' is already bound to {names}. " - + $"Reassign it to '{view.Label}'?"; + string? message = ComposeOverwriteMessage(chord, conflictRows, bindings); + if (message is null) + return; bindings.ConfirmOverwrite(message, accepted => { if (!accepted) return; @@ -593,13 +654,76 @@ 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); - while (updated.Count <= slot) updated.Add(default); - updated[slot] = chord; + // 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; ReplaceSlotValue(view, updated); RefreshRowButtons(view); } @@ -616,13 +740,9 @@ public sealed class KeyboardConfigController private static void ReplaceSlotValue(RowView view, IReadOnlyList value) { - // 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. + // 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. int lastReal = -1; for (int i = 0; i < value.Count; i++) if (value[i] != default) lastReal = i; @@ -639,9 +759,8 @@ 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 - /// (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. + /// (all 306 installed EoR rows are mapped) is collected in full, not just + /// the first match. /// private (ConflictOutcome Outcome, List Rows) FindConflicts(KeyChord chord, RowView exclude) { @@ -659,15 +778,20 @@ public sealed class KeyboardConfigController foreach (RowView other in _rows) { if (ReferenceEquals(other, exclude)) continue; - // 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. + // 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. if (other.MappedAction is null) continue; + if (_snapshot?.InputMapsConflict( + exclude.InputMapId, + other.InputMapId) != true) + { + continue; + } if (other.Model.Current.Contains(chord)) rows.Add(other); } @@ -677,24 +801,42 @@ public sealed class KeyboardConfigController private static void WireScreenButtons( ImportedLayout layout, KeyboardConfigController controller, Bindings bindings) { - // 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); + 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); + } 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(); @@ -702,16 +844,29 @@ public sealed class KeyboardConfigController controller.RefreshRowButtons(row); }; - // 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). + // 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. 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(); }; @@ -724,4 +879,17 @@ 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 b8b5c943..7b0ddb56 100644 --- a/src/AcDream.App/UI/Layout/MapHousePanelController.cs +++ b/src/AcDream.App/UI/Layout/MapHousePanelController.cs @@ -140,6 +140,12 @@ 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 682a67b2..804f4074 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); - /// 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. + /// 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. public void SetDefaultValue(IReadOnlyList value) => _default = value; /// The capture/erase entry point — writes m_current and applies @@ -574,6 +574,16 @@ 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 29d1d423..90c4b520 100644 --- a/src/AcDream.App/UI/Layout/OptionsPanelController.cs +++ b/src/AcDream.App/UI/Layout/OptionsPanelController.cs @@ -134,6 +134,28 @@ 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 4d925ed9..c061e0ac 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -115,6 +115,7 @@ 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) @@ -216,6 +217,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo Populate(); } private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators(); + private void OnInteractionStateChanged() => Populate(); private void OnObjectsCleared() { ApplyAetheriaVisibility(); @@ -225,8 +227,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 (live/optimistic wield, set by - /// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved carries the + /// has WielderId==p (login, from CreateObject) or ContainerId==p, 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) { @@ -256,6 +258,8 @@ 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(); @@ -278,7 +282,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo foreach (var (_, list) in _slots) { list.Cell.Selected = list.Cell.ItemId != 0 - && list.Cell.ItemId == _selection.SelectedObjectId; + && list.Cell.ItemId == _selection.SelectedObjectId + && !_itemInteraction.IsPendingInventorySource(list.Cell.ItemId); } } @@ -369,6 +374,7 @@ 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/RetailConfirmationMenuDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs new file mode 100644 index 00000000..ba951cf5 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs @@ -0,0 +1,122 @@ +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 3dd769e5..75361a59 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogData.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs @@ -14,6 +14,11 @@ 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. @@ -97,6 +102,19 @@ 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; @@ -148,4 +166,18 @@ 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 9efc211c..1970e18a 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -169,20 +169,28 @@ 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) + public uint MakeWait( + string message, + uint queueKey = DefaultQueueKey, + bool priority = false) { 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) + uint queueKey = DefaultQueueKey, + bool priority = false) { RetailDialogData data = RetailDialogData.Message(message) .Set(RetailDialogProperty.QueueKey, queueKey); + if (priority) + data.Set(RetailDialogProperty.Priority, true); return MakeDialog(data, callback); } @@ -196,6 +204,17 @@ 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. @@ -395,7 +414,8 @@ public sealed class RetailDialogFactory : IDisposable if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait or RetailDialogType.Message - or RetailDialogType.ConfirmationTextInput)) + or RetailDialogType.ConfirmationTextInput + or RetailDialogType.ConfirmationMenu)) { throw new NotSupportedException( $"Retail dialog type {(uint)type} does not have a ported presenter yet."); @@ -415,6 +435,10 @@ 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 770bb90b..a641fa61 100644 --- a/src/AcDream.App/UI/Layout/RetailKeyNames.cs +++ b/src/AcDream.App/UI/Layout/RetailKeyNames.cs @@ -37,8 +37,7 @@ 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; acdream's -/// carries the wire-side self-modifier bit) shows only the key name — never +/// walk-mode DIK_LSHIFT row has meta-mode 0) shows only the key name — never /// "Shift+ShiftLeft". /// /// @@ -79,21 +78,34 @@ public sealed class RetailKeyNames /// /// Display name for one bound chord — retail - /// 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). + /// 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". /// 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; the - // chord's stored self bit is acdream's encoding, not display truth). + // (retail's walk-mode LSHIFT row carries meta-mode 0 on the wire). foreach ((ModifierMask flag, Key metaKey) in MetaOrder) { if ((chord.Modifiers & flag) == 0 || IsSelfModifier(chord.Key, flag)) @@ -104,10 +116,36 @@ public sealed class RetailKeyNames composed.Append(_delimiter); } - composed.Append(LookupName(dikName!, dik, KeyNameTableId)); + composed.Append(keyName); 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) @@ -126,6 +164,7 @@ 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) @@ -134,15 +173,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 (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 + /// '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 /// same split Win32's GetKeyNameText expects in bit 24. /// private static bool TryGetDik(Key key, out byte dik, out string? name) @@ -206,6 +245,7 @@ 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"), @@ -233,10 +273,15 @@ 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"), @@ -247,6 +292,9 @@ 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/SelectedObjectController.cs b/src/AcDream.App/UI/Layout/SelectedObjectController.cs index 1f4ba9c7..34d0aa42 100644 --- a/src/AcDream.App/UI/Layout/SelectedObjectController.cs +++ b/src/AcDream.App/UI/Layout/SelectedObjectController.cs @@ -94,6 +94,8 @@ 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; @@ -128,7 +130,9 @@ public sealed class SelectedObjectController : IRetainedPanelController StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, Action> unsubscribeObjectUpdated, - Func isVendorSplitExempt) + Func isVendorSplitExempt, + Func? isCoinstack, + Func? coinTotal) { _isHealthTarget = isHealthTarget; _isOwnedByPlayer = isOwnedByPlayer; @@ -143,6 +147,8 @@ 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; @@ -319,7 +325,9 @@ public sealed class SelectedObjectController : IRetainedPanelController StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, Action> unsubscribeObjectUpdated, - Func isVendorSplitExempt) + Func isVendorSplitExempt, + Func? isCoinstack = null, + Func? coinTotal = null) => new SelectedObjectController( layout, selection, subscribeHealthChanged, unsubscribeHealthChanged, @@ -327,7 +335,7 @@ public sealed class SelectedObjectController : IRetainedPanelController isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize, sendQueryHealth, manaPercent, sendQueryItemMana, datFont, splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated, - isVendorSplitExempt); + isVendorSplitExempt, isCoinstack, coinTotal); /// /// Port of gmToolbarUI::HandleSelectionChanged (:198635): @@ -373,9 +381,11 @@ 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 = stackSize > 1u && !string.IsNullOrEmpty(objectName) - ? $"{stackSize} {objectName}" - : objectName; + _currentName = _isCoinstack(g) && _isOwnedByPlayer(g) + ? $"{stackSize} {objectName} (of {_coinTotal()})" + : stackSize > 1u && !string.IsNullOrEmpty(objectName) + ? $"{stackSize} {objectName}" + : objectName; // ── 3. Selection overlay: brief flash (retail container ObjectSelected // = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ────────────── @@ -522,6 +532,26 @@ 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 2ae0d719..50316f5f 100644 --- a/src/AcDream.App/UI/Layout/SocialPanelController.cs +++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs @@ -243,6 +243,8 @@ 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 @@ -264,6 +266,8 @@ 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 2d4bc0ac..dc888539 100644 --- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs +++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs @@ -216,9 +216,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController public bool Handle(InputAction action) { - if (action is >= InputAction.UseSpellSlot_1 and <= InputAction.UseSpellSlot_9) + if (TryMapSpellShortcut(action, out int index)) { - int index = (int)action - (int)InputAction.UseSpellSlot_1; IReadOnlyList spells = _spellbook.GetFavorites(_activeTab); if (index < spells.Count) { @@ -243,6 +242,27 @@ 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 d298da41..4c9605b5 100644 --- a/src/AcDream.App/UI/Layout/ToolbarInputController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarInputController.cs @@ -56,6 +56,23 @@ 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 f6dd3231..247268f9 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -381,10 +381,18 @@ 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, @@ -499,6 +507,7 @@ 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; @@ -528,6 +537,16 @@ 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 @@ -637,7 +656,10 @@ 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(); @@ -661,6 +683,7 @@ 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 @@ -866,6 +889,14 @@ 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; @@ -894,6 +925,9 @@ 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(); @@ -910,6 +944,8 @@ 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(); @@ -920,6 +956,8 @@ 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(); @@ -1112,15 +1150,10 @@ 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); - // 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.Clicked = () => + _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); + // gmVendorUI::HandleMousePresses @ 0x004C40D0: a + // double-click in the browse list calls BuySingleItem. cell.DoubleClicked = () => { _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); @@ -1252,14 +1285,23 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag SetPlainText(_itemNameText, nameText); VendorShopProfile profile = _vendor.Profile; - int rawValue = item.Value ?? 0; - int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize); - int price = VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, quantity); + int price = ComputeShopItemPrice(item, 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 @@ -1276,6 +1318,13 @@ 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. @@ -1390,6 +1439,15 @@ 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( @@ -1463,11 +1521,10 @@ 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; - /// 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). + /// 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. /// /// private string BuildCostText(VendorShopProfile profile, int quantity, int price) @@ -1479,7 +1536,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag "This item costs {0} {1}. You have {2} {1}.", price, profile.AlternateCurrencyPluralName, - (int)profile.AlternateCurrencyAmount); + ResolveAlternateCurrencyAmount(profile)); } int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0; @@ -1576,11 +1633,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag return; uint quantity = ResolveBuyQuantity(shopItem); - _itemInteraction.TryBuy( - _vendor.VendorId, - shopItem.ItemGuid, - (int)quantity, - _vendor.Profile.AlternateCurrencyWcid); + VendorShopProfile profile = _vendor.Profile; + if (_itemInteraction.TryBuy( + _vendor.VendorId, + shopItem.ItemGuid, + (int)quantity, + profile.AlternateCurrencyWcid)) + { + RecordAlternateCurrencyPurchase( + profile, + ComputeShopItemPrice(shopItem, (int)quantity)); + } } private bool TryFindShopItem(uint guid, out VendorShopItem shopItem) @@ -1653,6 +1716,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag (int)quantity, _vendor.Profile.AlternateCurrencyWcid)) { + RecordAlternateCurrencyPurchase( + _vendor.Profile, + ComputeShopItemPrice(shopItem, (int)quantity)); _buyStaging.Remove(shopItem.ItemGuid, BuyStagingRemovalAmount(shopItem)); } } @@ -1684,11 +1750,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// /// pyreal affordability — transaction total vs. purse /// (pc:204017: m_transactionValue <= m_totalValue). - /// 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. + /// alt-currency affordability — vs. the authoritative held trade + /// currency minus m_last_sale (pc:204032). /// container-slot capacity (pc:204053: /// containerSlotsNeeded > player.ContainersCapacity - containersUsed). /// item-slot capacity (pc:204067: the same shape for @@ -1753,7 +1816,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag return; } } - else if (transactionValue > (int)profile.AlternateCurrencyAmount) + else if (transactionValue > ResolveAlternateCurrencyAmount(profile)) { _systemMessage?.Invoke(NotEnoughMoneyMessage); return; @@ -1778,7 +1841,10 @@ 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. @@ -1904,7 +1970,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag return string.Format( CultureInfo.InvariantCulture, "You have {0} {1}.", - (int)profile.AlternateCurrencyAmount, + ResolveAlternateCurrencyAmount(profile), profile.AlternateCurrencyPluralName); } int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0; @@ -1961,8 +2027,52 @@ 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 @@ -1972,6 +2082,57 @@ 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 @@ -2216,7 +2377,9 @@ 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.Clicked = () => + _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); + cell.DoubleClicked = () => RemoveOneBuyingUnit(captured.ItemGuid); list.AddItem(cell); } } @@ -2249,13 +2412,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems(), - AllowDragSource = false, + AllowDragSource = true, + SourceKind = ItemDragSource.Inventory, 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.Clicked = () => + _selection.Select(captured, SelectionChangeSource.Vendor); + cell.DoubleClicked = () => RemoveSellingEntry(captured); list.AddItem(cell); } } @@ -2267,15 +2433,62 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // gate, pc:204229-204246) ────────────────────────────────────────────── /// - /// 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. + /// 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. /// 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( @@ -2335,8 +2548,9 @@ 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, stage it. Purely client-local — sends nothing to the - /// server, matching the Buying tab's "Add to List". + /// 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. /// public void HandleDropRelease( UiItemList targetList, @@ -2356,6 +2570,29 @@ 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); } @@ -2366,18 +2603,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// is the staged quantity a successful drop /// would use. /// - /// 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. + /// 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. /// /// private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity) @@ -2401,10 +2632,44 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag item.PublicWeenieBitfield ?? 0u); if (rejection == VendorSellRejection.None) - quantity = (int)Math.Max(1, item.StackSize); + { + uint fullStack = (uint)Math.Max(1, item.StackSize); + quantity = (int)_splitQuantity.GetObjectSplitSize( + itemGuid, + _selection.SelectedObjectId ?? 0u, + fullStack); + } 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 @@ -2564,8 +2829,12 @@ 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; @@ -2581,6 +2850,17 @@ 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/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index f8b77788..8ac69a8e 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -12,6 +12,7 @@ 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; @@ -441,7 +442,8 @@ 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 path (D4 — no .keymap file interchange). Null +/// keybinds.json mirror path. Retail *.keymap profiles live in +/// Documents/Asheron's Call and the selected profile is reloaded at startup. 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. @@ -464,11 +466,10 @@ 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 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. +/// 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. /// public sealed record CharacterSelectionRuntimeBindings( Func View, @@ -529,7 +530,8 @@ public sealed record RetailUiRuntimeBindings( KeyboardRuntimeBindings? Keyboard = null, CharacterSelectionRuntimeBindings? CharacterSelection = null, // Campaign CC slice CC4: sibling of CharacterSelection above. - CharacterCreationRuntimeBindings? CharacterCreation = null); + CharacterCreationRuntimeBindings? CharacterCreation = null, + Action? CaptureScreenshot = null); /// /// Composition owner for the production retained gameplay UI. GameWindow supplies @@ -742,6 +744,7 @@ 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; } @@ -1006,56 +1009,278 @@ public sealed class RetailUiRuntime : IDisposable { if (SpellcastingUiController?.Handle(action) == true) return true; - if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel) + + switch (action) { - 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; + 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; } + 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 void OpenSocialPanel(bool showAllegiance) + private enum SocialPanelPage { Friends, Allegiance, Fellowship } + + private void OpenSocialPanel(SocialPanelPage page) { bool visible = Host.IsWindowVisible(WindowNames.SocialPanel); - bool onTargetTab = showAllegiance - ? SocialPanelController?.IsShowingAllegiance == true - : SocialPanelController?.IsShowingFellowship == true; + bool onTargetTab = page switch + { + SocialPanelPage.Friends => SocialPanelController?.IsShowingFriends == true, + SocialPanelPage.Allegiance => SocialPanelController?.IsShowingAllegiance == true, + SocialPanelPage.Fellowship => SocialPanelController?.IsShowingFellowship == true, + _ => false, + }; if (visible && onTargetTab) { CloseWindow(WindowNames.SocialPanel); return; } - if (showAllegiance) - SocialPanelController?.ShowAllegiance(); - else - SocialPanelController?.ShowFellowship(); + switch (page) + { + case SocialPanelPage.Friends: SocialPanelController?.ShowFriends(); break; + case SocialPanelPage.Allegiance: SocialPanelController?.ShowAllegiance(); break; + case SocialPanelPage.Fellowship: SocialPanelController?.ShowFellowship(); break; + } _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); @@ -1853,7 +2078,10 @@ public sealed class RetailUiRuntime : IDisposable StackSplitQuantity, handler => b.Objects.ObjectUpdated += handler, handler => b.Objects.ObjectUpdated -= handler, - b.IsVendorSplitExempt); + b.IsVendorSplitExempt, + isCoinstack: guid => b.Objects.Get(guid)?.WeenieClassId == 273u, + coinTotal: () => b.Objects.Get(b.PlayerGuid())?.Properties.GetInt( + (uint)PropertyInt.CoinValue) ?? 0); UiElement root = layout.Root; RetailWindowHandle handle = RetailWindowFrame.Mount( @@ -3072,12 +3300,88 @@ public sealed class RetailUiRuntime : IDisposable string unmappedPath = UnmappedKeyBindingsPath(keyboard.KeyBindingsFilePath); var unmapped = RetailUnmappedKeyBindings.LoadOrEmpty(unmappedPath); + var keymaps = new RetailKeymapProfileStore(keyboard.KeyBindingsFilePath); - // 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")); + 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; + } + } Layout.KeyboardConfigController? controller = Layout.KeyboardConfigController.Bind( layout, @@ -3120,15 +3424,12 @@ 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 { - dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath); - unmapped.SaveToFile(unmappedPath); + HandleSaveResult( + keymaps.SaveActive(dispatcher.Bindings), + keymaps.CurrentFileName, + static () => { }); } catch (Exception failure) { @@ -3136,11 +3437,23 @@ public sealed class RetailUiRuntime : IDisposable } }, Toggle: () => ToggleWindow(WindowNames.KeyboardConfig), - DisplaySystemMessage: text => + ResolveTemplate: (key, variables) => { - if (!string.IsNullOrEmpty(text)) _bindings.Options.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); }, - NonBindableRefusalText: refusalText ?? string.Empty, // M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — // confirm through the SAME RetailDialogFactory/MakeConfirmation // seam GameplayConfirmationController already uses, before @@ -3153,7 +3466,9 @@ public sealed class RetailUiRuntime : IDisposable if (DialogFactory is null) { onResult(false); return; } DialogFactory.MakeConfirmation( message, - data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult))); + data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult)), + queueKey: 0x10000001u, + priority: true); }, // OP8 re-gate (2026-08-14): retail's capture-instruction dialog // (InitiateBinding @ 0x004899D0 → OpenMapWarnDialog @ 0x00488A00): @@ -3179,7 +3494,10 @@ public sealed class RetailUiRuntime : IDisposable // `text` arrives with real line breaks. try { - return DialogFactory.MakeWait(text, queueKey: 0x10000001u); + return DialogFactory.MakeWait( + text, + queueKey: 0x10000001u, + priority: true); } catch (Exception failure) { @@ -3196,7 +3514,64 @@ public sealed class RetailUiRuntime : IDisposable } }, CloseCaptureInstructions: context => - DialogFactory?.CloseDialog(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); + }), resolveTemplateFont: (templateLayoutId, templateElementId) => { lock (_bindings.Assets.DatLock) @@ -4073,7 +4448,7 @@ public sealed class RetailUiRuntime : IDisposable lock (_bindings.Assets.DatLock) return RetailDataIdResolver.Resolve(_bindings.Assets.Dats, enumValue, category); } - Action refreshRows = CharacterStatController.Bind( + _characterStatBinding = CharacterStatController.Bind( layout, () => currentSheet, _bindings.Assets.DefaultFont, @@ -4090,7 +4465,7 @@ public sealed class RetailUiRuntime : IDisposable _characterSheetSubscription = provider.SubscribeChanged(() => { currentSheet = provider.BuildSheet(); - refreshRows(); + _characterStatBinding?.Refresh(); }); // CT3 (2026-08-24): the Titles page's row template lives in a diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index be9e107f..3f0752a2 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -184,6 +184,12 @@ 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; } @@ -497,7 +503,18 @@ public sealed class UiRoot : UiElement internal void OnSubtreeRemoving(UiElement subtree) { - ClearSubtreeOwnership(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); WindowManager.OnSubtreeRemoving(subtree); } @@ -511,13 +528,16 @@ public sealed class UiRoot : UiElement internal void OnElementVisibilityChanged(UiElement element, bool visible) => ElementVisibilityChanged?.Invoke(element, visible); - internal void ClearSubtreeOwnership(UiElement subtree) + internal void ClearSubtreeOwnership(UiElement subtree, bool preserveDetachedDrag = false) { if (IsWithinSubtree(KeyboardFocus, subtree)) SetKeyboardFocus(null); if (IsWithinSubtree(Captured, subtree)) { - ReleaseCapture(); + if (preserveDetachedDrag && ReferenceEquals(Captured, DragSource)) + SetCapture(this); + else + ReleaseCapture(); _dragCandidate = false; } if (IsWithinSubtree(DefaultTextInput, subtree)) @@ -527,10 +547,13 @@ public sealed class UiRoot : UiElement if (IsWithinSubtree(DragSource, subtree)) { DragSource?.SetDragSourceActive(false, DragPayload); - DragSource = null; - DragPayload = null; - _dragGhost = null; - _dragCandidate = false; + if (!preserveDetachedDrag) + { + DragSource = null; + DragPayload = null; + _dragGhost = null; + _dragCandidate = false; + } } if (IsWithinSubtree(_hoverWidget, subtree)) { @@ -1090,13 +1113,15 @@ 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.KeypadEnter)) + || vk == (int)Silk.NET.Input.Key.Enter)) { SetKeyboardFocus(DefaultTextInput); return; @@ -1125,6 +1150,11 @@ 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, @@ -1136,12 +1166,18 @@ 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.Core.Net/Messages/ClientCommandRequests.cs b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs index 4ee9d358..35ff578d 100644 --- a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs +++ b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs @@ -24,6 +24,7 @@ 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; @@ -139,6 +140,10 @@ 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/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 35f5888e..08efe5c0 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2681,6 +2681,13 @@ 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 diff --git a/src/AcDream.Core/Chat/ChatCommandTargetState.cs b/src/AcDream.Core/Chat/ChatCommandTargetState.cs index 47a955cc..fda55957 100644 --- a/src/AcDream.Core/Chat/ChatCommandTargetState.cs +++ b/src/AcDream.Core/Chat/ChatCommandTargetState.cs @@ -16,6 +16,8 @@ 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) @@ -42,6 +44,26 @@ 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 @@ -61,6 +83,8 @@ public sealed class ChatCommandTargetState : IDisposable { _lastIncomingTellSender = null; _lastOutgoingTellTarget = null; + _lastMonarchSender = null; + _lastPatronSender = null; } } @@ -77,17 +101,34 @@ public sealed class ChatCommandTargetState : IDisposable private void OnEntryAppended(ChatEntry entry) { - if (entry.Kind != ChatKind.Tell || string.IsNullOrEmpty(entry.Sender)) + if (string.IsNullOrEmpty(entry.Sender)) return; lock (_gate) { if (_disposed) return; - if (entry.SenderGuid != 0u) - _lastIncomingTellSender = entry.Sender; - else - _lastOutgoingTellTarget = entry.Sender; + 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; } } } diff --git a/src/AcDream.Core/Chat/InventoryFailureMessages.cs b/src/AcDream.Core/Chat/InventoryFailureMessages.cs index c05c3269..7b7cab46 100644 --- a/src/AcDream.Core/Chat/InventoryFailureMessages.cs +++ b/src/AcDream.Core/Chat/InventoryFailureMessages.cs @@ -33,18 +33,19 @@ public static class InventoryFailureMessages string itemName, uint weenieError) { - // 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. + // 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. 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 9ec2083c..22f8115d 100644 --- a/src/AcDream.Core/Input/RetailActionMap.cs +++ b/src/AcDream.Core/Input/RetailActionMap.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using AcDream.Core.Content; using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; namespace AcDream.Core.Input; @@ -124,7 +125,23 @@ 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); +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)); +} /// /// Retail's 19 named InputMapID -> ID_InputMap_* string-table keys @@ -220,7 +237,16 @@ public static class RetailActionMapReader } } - return new RetailActionMapSnapshot(rows); + 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); } private static void CollectDefaults( diff --git a/src/AcDream.Core/Items/ExternalContainerState.cs b/src/AcDream.Core/Items/ExternalContainerState.cs index d42eebc4..6ec69aa8 100644 --- a/src/AcDream.Core/Items/ExternalContainerState.cs +++ b/src/AcDream.Core/Items/ExternalContainerState.cs @@ -24,14 +24,31 @@ 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; - public bool RequestOpen(uint containerId) + /// + /// 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) { - if (containerId == 0u || RequestedContainerId == 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) return false; uint previous = CurrentContainerId; @@ -54,6 +71,19 @@ 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) @@ -98,9 +128,12 @@ public sealed class ExternalContainerState public bool Reset() { uint previous = CurrentContainerId; - bool changed = previous != 0u || RequestedContainerId != 0u; + bool changed = previous != 0u + || RequestedContainerId != 0u + || _openedCorpses.Count != 0; 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 new file mode 100644 index 00000000..eccb97e9 --- /dev/null +++ b/src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs @@ -0,0 +1,158 @@ +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 bf6ab57d..8e4c75c0 100644 --- a/src/AcDream.Core/Items/InventoryTransactionState.cs +++ b/src/AcDream.Core/Items/InventoryTransactionState.cs @@ -6,8 +6,10 @@ 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 2eb3d4bc..0e4dbc29 100644 --- a/src/AcDream.Core/Items/ItemInteractionPolicy.cs +++ b/src/AcDream.Core/Items/ItemInteractionPolicy.cs @@ -20,13 +20,22 @@ public enum PublicWeenieFlags : uint 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 @@ -37,6 +46,8 @@ 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, @@ -79,7 +90,8 @@ public readonly record struct ItemPolicyObject( int TradeState, int StackSize, int MaxSplitSize, - bool IsIn3DView) + bool IsIn3DView, + string Name = "item") { public bool IsPlayer => (Flags & PublicWeenieFlags.Player) != 0; } @@ -249,11 +261,11 @@ public static class ItemInteractionPolicy } if (source.TradeState == 1) - return Reject("You cannot use an item while it is being traded."); + return Reject($"You cannot use the {NameOf(source)} because you are trading it"); if (source.CurrentLocation == EquipMask.None && ItemUseability.LeastLimitedSourceUse(source.Useability) == ItemUseability.Wielded) - return Reject("You must wield that item before you can use it."); + return Reject($"You must wield the {NameOf(source)} to use it"); if (ItemUseability.IsTargeted(source.Useability)) { @@ -261,9 +273,9 @@ public static class ItemInteractionPolicy return Consumed(new ItemPolicyAction(ItemPolicyActionKind.EnterTargetMode, source.Id)); if (input.SelectedTarget is not { } target) - 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."); + return Reject($"Select your target before using the {NameOf(source)}"); + if (TargetCompatibilityFailure(source, target, input.PlayerId) is { } failure) + return Reject(failure); var actions = new List { @@ -305,13 +317,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 cannot open or close that object right now."); + return Reject($"You can't open or close this {NameOf(source)} that way"); if ((source.Flags & PublicWeenieFlags.Attackable) != 0 && input.InNonCombatMode) - return Reject("You must switch to a combat mode before attacking that target."); + return Reject($"To attack {NameOf(source)}, click on the dove icon first"); if ((source.Flags & PublicWeenieFlags.Attackable) == 0 || input.InNonCombatMode) - return Reject("That object cannot be used."); + return Reject($"The {NameOf(source)} cannot be used"); return new ItemUsePolicyDecision(false, Array.Empty()); } @@ -319,9 +331,18 @@ 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 false; + 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"; uint flags = ItemUseability.TargetFlags(source.Useability); if (!target.OwnedByPlayer) @@ -330,18 +351,20 @@ public static class ItemInteractionPolicy if ((least & ItemUseability.Contained) != 0) { if (!(target.Id == playerId && (flags & ItemUseability.Self) != 0)) - return false; + return $"You can't use the {NameOf(source)} on what you don't own"; } else if ((least & ItemUseability.Wielded) != 0) { - return false; + return $"You can't use the {NameOf(source)} on what you aren't wielding"; } } if (target.Id == playerId && (flags & ItemUseability.Self) == 0) - return false; + return $"Cannot use the {NameOf(source)} on yourself"; - return (source.TargetType & (uint)target.Type) != 0; + return (source.TargetType & (uint)target.Type) != 0 + ? null + : $"Cannot use the {NameOf(source)} with the {NameOf(target)}"; } public static ItemPlacementPolicyDecision DecidePlacement( @@ -353,9 +376,10 @@ 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 that item.")); + return Placement(false, RejectAction($"You must first pick up the {NameOf(input.Item)}")); if (input.Item.TradeState != 0) - return Placement(false, RejectAction("You cannot move an item while it is being traded.")); + return Placement(false, RejectAction( + $"You are trading the {NameOf(input.Item)}, it cannot be dropped")); if (input.TargetId == 0) return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false); @@ -370,7 +394,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("Split the stack before selling part of it.")); + return Placement(false, RejectAction("You must split the stack before selling it.")); } if (input.DragOnPlayerOpensSecureTrade && target.IsPlayer) @@ -384,16 +408,17 @@ public static class ItemInteractionPolicy if (target.IsContainer) { if ((target.Flags & PublicWeenieFlags.Openable) == 0) - return Placement(false, RejectAction("That container is locked.")); + return Placement(false, RejectAction($"The {NameOf(target)} is locked")); if (target.Id != input.GroundObjectId) - return Placement(false, RejectAction("You must open that container first.")); + return Placement(false, RejectAction($"You must open the {NameOf(target)} 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("You cannot give that item to this target.")); + return Placement(false, RejectAction( + $"Cannot give {NameOf(input.Item)} to {NameOf(target)}")); } private static IReadOnlyList BuildUsingItemActions( @@ -436,15 +461,18 @@ 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/VendorStagingList.cs b/src/AcDream.Core/Items/VendorStagingList.cs index d89f102f..1d1af85f 100644 --- a/src/AcDream.Core/Items/VendorStagingList.cs +++ b/src/AcDream.Core/Items/VendorStagingList.cs @@ -127,6 +127,27 @@ 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/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index 5aed4aec..df221885 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -2269,8 +2269,9 @@ public sealed class MotionInterpreter : IMotionDoneSink if (PhysicsObj is null) return false; - bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact) - && PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable); + const TransientStateFlags groundedMask = + TransientStateFlags.Contact | TransientStateFlags.OnWalkable; + bool grounded = (PhysicsObj.TransientState & groundedMask) == groundedMask; if (!grounded) return false; diff --git a/src/AcDream.Core/Physics/RawMotionState.cs b/src/AcDream.Core/Physics/RawMotionState.cs index 315ee1ff..fa12e9aa 100644 --- a/src/AcDream.Core/Physics/RawMotionState.cs +++ b/src/AcDream.Core/Physics/RawMotionState.cs @@ -74,6 +74,32 @@ 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.Runtime/Chat/LiveChatCommandRoute.cs b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs index b4128b39..9296a7c0 100644 --- a/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs +++ b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs @@ -21,7 +21,10 @@ public sealed record LiveChatCommandBindings( Action SendTell, Action SendChannel, Action SendTurbineChat, - Action? Log = null); + Action? Log = null, + Func? ResolvePose = null, + Action? ExecuteMotion = null, + Action? SendSoulEmote = null); /// /// One generation's active binding for the four chat-core records. The route @@ -153,7 +156,7 @@ public sealed class LiveChatCommandRoute switch (command.Channel) { case ChatChannelKind.Say: - SendIfActive(() => bindings.SendTalk(command.Text)); + RoutePublicChat(bindings, command.Text); return; case ChatChannelKind.Tell: @@ -191,6 +194,25 @@ public sealed class LiveChatCommandRoute RouteLegacyChannel(bindings, command.Channel, command.Text); } + private void RoutePublicChat( + LiveChatCommandBindings bindings, + string text) + { + string spoken = RetailPublicChatParser.ExtractPoses( + text, + bindings.ResolvePose, + pose => + { + bindings.ExecuteMotion?.Invoke(pose.MotionCommand); + if (!string.IsNullOrEmpty(pose.OthersText)) + bindings.SendSoulEmote?.Invoke(pose.OthersText); + if (!string.IsNullOrEmpty(pose.SelfText)) + bindings.Chat.OnSoulEmote("You", pose.SelfText, 0u); + }); + if (!string.IsNullOrEmpty(spoken)) + SendIfActive(() => bindings.SendTalk(spoken)); + } + private void RouteTurbineChat( LiveChatCommandBindings bindings, ChatChannelKindLite kind, diff --git a/src/AcDream.Runtime/Chat/RetailPublicChatParser.cs b/src/AcDream.Runtime/Chat/RetailPublicChatParser.cs new file mode 100644 index 00000000..839a6a65 --- /dev/null +++ b/src/AcDream.Runtime/Chat/RetailPublicChatParser.cs @@ -0,0 +1,76 @@ +namespace AcDream.Runtime.Chat; + +/// One DAT-backed ChatPoseTable resolution. +public readonly record struct RetailChatPose( + uint MotionCommand, + string SelfText, + string OthersText); + +/// +/// Ports ClientCommunicationSystem::PublicChat @ 0x005810F0 and +/// RemoveTextBetween @ 0x00580FD0. Valid pose tokens are consumed; +/// unknown or unmatched delimiters remain ordinary speech. +/// +public static class RetailPublicChatParser +{ + public static string ExtractPoses( + string text, + Func? resolve, + Action? execute) + { + ArgumentNullException.ThrowIfNull(text); + if (resolve is null || execute is null || text.Length == 0) + return text.Trim(); + + string remaining = text; + int cursor = 0; + while (cursor < remaining.Length) + { + int star = remaining.IndexOf('*', cursor); + int angle = remaining.IndexOf('<', cursor); + int open; + char close; + if (star < 0) + { + open = angle; + close = '>'; + } + else if (angle < 0 || star <= angle) + { + open = star; + close = '*'; + } + else + { + open = angle; + close = '>'; + } + + if (open < 0) + break; + int end = remaining.IndexOf(close, open + 1); + if (end < 0) + { + cursor = open + 1; + continue; + } + + string command = remaining[(open + 1)..end]; + RetailChatPose? pose = resolve(command); + if (pose is { MotionCommand: not 0u } resolved) + { + execute(resolved); + remaining = remaining.Remove(open, end - open + 1); + cursor = open; + } + else + { + // Pose() returned false: retail leaves the complete literal + // token in the talk text and advances past this pair. + cursor = end + 1; + } + } + + return remaining.Trim(); + } +} diff --git a/src/AcDream.Runtime/GameRuntimeActionViews.cs b/src/AcDream.Runtime/GameRuntimeActionViews.cs index f648986c..c8726b86 100644 --- a/src/AcDream.Runtime/GameRuntimeActionViews.cs +++ b/src/AcDream.Runtime/GameRuntimeActionViews.cs @@ -10,7 +10,8 @@ public readonly record struct RuntimeCombatAttackSnapshot( float PowerBarLevel, bool BuildInProgress, bool RequestInProgress, - float RequestedPower); + float RequestedPower, + bool RepeatAttackInProgress = false); public readonly record struct RuntimeSpellCastSnapshot( long Revision, diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index d593910f..0ce06b23 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -67,6 +67,8 @@ public enum RuntimeMovementCommand Sit, Crouch, Sleep, + StopCompletely, + FinishJump, } public enum RuntimeChatChannel @@ -160,6 +162,10 @@ public interface IRuntimeMovementCommands RuntimeGenerationToken expectedGeneration, RuntimeMovementCommand command); + RuntimeCommandResult ExecuteMotion( + RuntimeGenerationToken expectedGeneration, + uint motionCommand); + RuntimeCommandResult SetIntent( RuntimeGenerationToken expectedGeneration, in Gameplay.MovementInput input); diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs index 3c9baf30..f3570372 100644 --- a/src/AcDream.Runtime/GameRuntimeViews.cs +++ b/src/AcDream.Runtime/GameRuntimeViews.cs @@ -86,6 +86,10 @@ public readonly record struct RuntimeMovementSnapshot( public interface IRuntimeMovementView { RuntimeMovementSnapshot Snapshot { get; } + + bool IsStandingStill { get; } + + Gameplay.JumpChargeSnapshot JumpCharge { get; } } public enum RuntimePortalKind diff --git a/src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs b/src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs index 0053e737..4e3d2fa9 100644 --- a/src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs +++ b/src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs @@ -231,6 +231,9 @@ public sealed class LocalPlayerOutboundController public static RawMotionState BuildRawMotionState(MovementResult movement) { + if (movement.RawMotionStateOverride is { } rawMotionState) + return new RawMotionState(rawMotionState); + HoldKey axisHoldKey = movement.IsRunning ? HoldKey.Run : HoldKey.None; return new RawMotionState { diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index 66ed3c81..487d70db 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -110,7 +110,11 @@ public readonly record struct MovementResult( // MovementManager's complete RawMotionState into MoveToStatePack. An // absent style bit unpacks as NonCombat, so the canonical raw style must // travel with every input-boundary snapshot sent to ACE. - uint CurrentStyle = 0x8000003Du); + uint CurrentStyle = 0x8000003Du, + // Retail SendMovementEvent snapshots the COMPLETE RawMotionState + // synchronously. Command-originated motions use this one-shot override so + // the action FIFO/state survives the render-tick input projection. + RawMotionState? RawMotionStateOverride = null); /// /// Portal-space state for the player movement controller. @@ -530,6 +534,25 @@ public sealed class PlayerMovementController /// public JumpChargeSnapshot JumpCharge => new(_jumpCharging, _jumpCharging ? _jumpExtent : 0f); + + /// + /// Retail CommandInterpreter::IsStandingStill: the exact motion- + /// interpreter predicate consumed by Escape before it reaches selection + /// or the Gameplay Options fallback. + /// + internal bool IsStandingStill => _motion.IsStandingStill(); + + /// + /// Retail ClientCombatSystem::FinishJump (0x0056A9B0): end an + /// in-progress jump power build without executing the jump and clear the + /// standing-long-jump arm on the motion interpreter. + /// + internal void FinishJump() + { + _jumpCharging = false; + _jumpExtent = 0f; + _motion.StandingLongJump = false; + } // Matching v11.4186 x86 resolves GetPowerBarLevel's collapsed x87 // operands: ATTACK_POWERUP_TIME=1.0 s, DUAL_WIELD_POWERUP_TIME=0.8 s. // Jump uses the same shared powerbar function, so its normal fill rate is @@ -656,6 +679,8 @@ public sealed class PlayerMovementController private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame _positionManagerDeltaScratch = new(); private bool _externalMovementEventPending; + private RawMotionState? _externalRawMotionStatePending; + private uint _localActionStamp; // ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ── // The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase, @@ -1441,6 +1466,34 @@ public sealed class PlayerMovementController return true; } + /// + /// Retail ACCmdInterp::SetMotion (0x0058B310) with + /// start=true: submit one raw command through the local physics-object + /// boundary and publish the resulting movement edge on the next turn. + /// + internal bool RequestCommandMotion(uint motion) + { + EnsurePublishedForRuntimeOperation(); + TakeControlFromServer(); + var parameters = + new AcDream.Core.Physics.Motion.MovementParameters + { + Autonomous = true, + ActionStamp = _localActionStamp, + }; + if (DoMotionAtPhysicsObjectBoundary(motion, parameters) + != WeenieError.None) + { + return false; + } + + if ((motion & 0x10000000u) != 0u) + _localActionStamp++; + _externalRawMotionStatePending = new RawMotionState(_motion.RawState); + _externalMovementEventPending = true; + return true; + } + public void SetCharacterSkills(int runSkill, int jumpSkill) { EnsureConfigurationMutable(); @@ -2452,6 +2505,9 @@ public sealed class PlayerMovementController bool externallyRequestedMovementEvent = _externalMovementEventPending; _externalMovementEventPending = false; + RawMotionState? externalRawMotionState = + _externalRawMotionStatePending; + _externalRawMotionStatePending = null; bool motionEdgeFired = false; bool movementEventRequested = externallyRequestedMovementEvent; @@ -3189,7 +3245,8 @@ public sealed class PlayerMovementController SidestepUsesRunHold: _activeInputSidestepUsesRunHold && outSidestepCmd.HasValue, IsMouseLookMovementEvent: mouseMovementEventDue, - CurrentStyle: _motion.RawState.CurrentStyle); + CurrentStyle: _motion.RawState.CurrentStyle, + RawMotionStateOverride: externalRawMotionState); } /// diff --git a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs index 61ca4bb3..fcd76db7 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs @@ -253,7 +253,8 @@ public sealed class RuntimeActionState : IDisposable owner.CombatAttack.PowerBarLevel, owner.CombatAttack.BuildInProgress, owner.CombatAttack.AttackRequestInProgress, - owner.CombatAttack.RequestedAttackPower), + owner.CombatAttack.RequestedAttackPower, + owner.CombatAttack.RepeatAttackInProgress), new RuntimeSpellCastSnapshot( Interlocked.Read(ref owner._magicIntentRevision), owner.SpellCast.LastRequestedSpellId ?? 0u, diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs index 20bedded..6c0eccb7 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs @@ -152,6 +152,7 @@ public sealed class RuntimeCombatAttackState : IDisposable public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium; public float DesiredPower { get; private set; } = InitialDesiredPower; public bool AttackRequestInProgress => _attackRequestInProgress; + public bool RepeatAttackInProgress => _repeatAttacking; public float RequestedAttackPower => _requestedAttackPower; public bool BuildInProgress => _buildInProgress; public bool IsDisposed => _disposed; diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs index 9769b844..73f85a60 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs @@ -16,6 +16,7 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot( int ShortcutSubscriberCount, long ShortcutDispatchFailureCount, long TransactionDispatchFailureCount, + int OpenedCorpseCount, // Slice 5.3: the sole open vendor shop id, 0 when no session is open. uint VendorId, // Slice 6.1: guids VendorShopItemMaterializer currently owns in @@ -36,6 +37,7 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot( && ItemManaCount == 0 && ShortcutCount == 0 && ShortcutSubscriberCount == 0 + && OpenedCorpseCount == 0 && VendorId == 0u && MaterializedVendorItemCount == 0; } @@ -55,6 +57,7 @@ public sealed class RuntimeInventoryState : IDisposable _entityObjects = entityObjects ?? throw new ArgumentNullException(nameof(entityObjects)); ExternalContainers = new ExternalContainerState(); + _entityObjects.Objects.ObjectRemoved += OnObjectRemoved; ItemMana = new ItemManaState(); Shortcuts = new ShortcutStore(); Transactions = new InventoryTransactionState(_entityObjects.Objects); @@ -98,6 +101,7 @@ public sealed class RuntimeInventoryState : IDisposable Shortcuts.SubscriberCount, Shortcuts.DispatchFailureCount, Transactions.DispatchFailureCount, + ExternalContainers.OpenedCorpseCount, Vendor.VendorId, VendorItems.OwnedCount); @@ -170,6 +174,7 @@ public sealed class RuntimeInventoryState : IDisposable List? failures = null; try { + _entityObjects.Objects.ObjectRemoved -= OnObjectRemoved; Try(() => ExternalContainers.Reset(), ref failures); // Vendor.Reset() must run BEFORE VendorItems.Dispose() — // Reset() fires Changed synchronously, which is what drives the @@ -208,6 +213,9 @@ public sealed class RuntimeInventoryState : IDisposable } } + private void OnObjectRemoved(ClientObject item) + => ExternalContainers.SetCorpseDeleted(item.ObjectId); + private sealed class InventoryStateView(RuntimeInventoryState owner) : IRuntimeInventoryStateView { diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs index 991204e2..d354b974 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs @@ -172,6 +172,8 @@ public sealed class RuntimeLocalPlayerMovementState public long Revision => Interlocked.Read(ref _revision); public ulong ControllerOwnershipEpoch { get; private set; } public IRuntimeMovementView View => this; + public bool IsStandingStill => _controller?.IsStandingStill ?? true; + public JumpChargeSnapshot JumpCharge => _controller?.JumpCharge ?? default; internal RuntimeLocalPlayerPhysicsPublicationState PhysicsPublication => _physicsPublication ?? throw new InvalidOperationException( @@ -246,6 +248,16 @@ public sealed class RuntimeLocalPlayerMovementState CancelAutoRun(); ClearCommandInput(); return true; + case RuntimeMovementCommand.StopCompletely: + CancelAutoRun(); + ClearCommandInput(); + _ = _controller?.StopCompletelyAtPhysicsObjectBoundary(); + Interlocked.Increment(ref _revision); + return true; + case RuntimeMovementCommand.FinishJump: + _controller?.FinishJump(); + Interlocked.Increment(ref _revision); + return true; case RuntimeMovementCommand.Ready: case RuntimeMovementCommand.Sit: case RuntimeMovementCommand.Crouch: @@ -270,6 +282,17 @@ public sealed class RuntimeLocalPlayerMovementState } } + /// + /// Executes a retail command-interpreter motion on the canonical local + /// player. Keyboard emotes use this exact route; the caller owns the + /// ActionMap-to-motion allowlist. + /// + public bool ExecuteMotion(uint motionCommand) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _controller?.RequestCommandMotion(motionCommand) == true; + } + public bool CancelAutoRun() { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index e70ece3c..9c596a29 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -403,6 +403,25 @@ public sealed class DirectGameRuntimeCommandAdapter status); } + public RuntimeCommandResult ExecuteMotion( + RuntimeGenerationToken expectedGeneration, + uint motionCommand) + { + RuntimeCommandStatus gate = + Validate(expectedGeneration, out _); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + RuntimeCommandStatus status = + _runtime.MovementOwner.ExecuteMotion(motionCommand) + ? RuntimeCommandStatus.Accepted + : RuntimeCommandStatus.Unsupported; + return EmitResult( + RuntimeCommandDomain.Movement, + operation: 0x102, + status, + motionCommand); + } + public RuntimeCommandResult SetIntent( RuntimeGenerationToken expectedGeneration, in MovementInput input) diff --git a/src/AcDream.UI.Abstractions/Input/InputAction.cs b/src/AcDream.UI.Abstractions/Input/InputAction.cs index be2228f8..32913a0b 100644 --- a/src/AcDream.UI.Abstractions/Input/InputAction.cs +++ b/src/AcDream.UI.Abstractions/Input/InputAction.cs @@ -8,10 +8,9 @@ namespace AcDream.UI.Abstractions.Input; /// debug bindings that have no retail equivalent. /// /// -/// K.1a defined the enum and K.1c flipped the bindings table to the full -/// retail preset. Runtime controllers subscribe by subsystem; actions whose -/// owning panel has not landed yet (for example UseSpellSlot_*) may -/// intentionally remain undispatched. +/// The installed Sept-2013 ActionMap's 306 user-bindable rows each have one +/// distinct enum identity and one live subsystem consumer. Low, non-bindable +/// MasterInputMap commands remain separate infrastructure actions. /// /// public enum InputAction @@ -92,7 +91,10 @@ public enum InputAction // ── UICommands ──────────────────────────────────────── /// Use the selected item / interact (retail R). UseSelected, - /// Cancel the topmost UI / clear selection / open log-out menu. + /// + /// Retail Escape priority: cancel focused UI/targeting/movement, clear + /// selection, then toggle the Gameplay Options page. + /// EscapeKey, /// Log out of the game (retail Shift+Esc). LOGOUT, @@ -169,7 +171,7 @@ public enum InputAction // ── Combat ──────────────────────────────────────────── /// Toggle combat-stance on/off (retail Grave / `). CombatToggleCombat, - // Mode-dependent (dormant in K — Phase L lights them up) + // Mode-dependent retail combat actions. CombatDecreaseAttackPower, CombatIncreaseAttackPower, CombatLowAttack, @@ -267,7 +269,8 @@ public enum InputAction AcdreamToggleAudioMute, /// F (existing) toggles between fly camera and orbit/chase mode. AcdreamToggleFlyMode, - /// Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c). + /// Legacy acdream player-mode toggle. Intentionally unbound; + /// retail Tab is . AcdreamTogglePlayerMode, /// Hold-RMB chase-camera orbit (debug-only, not user-rebindable). /// Camera orbits around the player while held; never drives character yaw. @@ -285,4 +288,201 @@ public enum InputAction CameraRaise, /// Camera lower (held key, integrates Pitch−= adjSpeed·dt·0.02). Default unbound. CameraLower, + + // ── Remaining Sept-2013 retail ActionMap identities ──────────── + // Appended after every pre-existing member so persisted numeric enum values + // remain stable. These are distinct even where retail reuses the same + // Action id in another InputMap (notably CameraAlternateControls). + + CameraAlternateMoveToward, + CameraAlternateMoveAway, + CameraAlternateRotateLeft, + CameraAlternateRotateRight, + CameraAlternateRotateUp, + CameraAlternateRotateDown, + CameraAlternateViewDefault, + CameraAlternateViewFirstPerson, + CameraAlternateViewLookDown, + CameraAlternateViewMapMode, + + UseSpellSlot_10, + UseSpellSlot_11, + UseSpellSlot_12, + + EmoteAfkState, + EmoteAkimbo, + EmoteAToyotState, + EmoteAkimboState, + EmoteAtEaseState, + EmoteBeckon, + EmoteBeSeeingYou, + EmoteBlowKiss, + EmoteBowDeep, + EmoteBowDeepState, + EmoteClapHands, + EmoteClapHandsState, + EmoteCringe, + EmoteCrossArmsState, + EmoteCurtseyState, + EmoteDrudgeDance, + EmoteDrudgeDanceState, + EmoteHaveASeat, + EmoteHaveASeatState, + EmoteHeartyLaugh, + EmoteHelper, + EmoteKneel, + EmoteKneelState, + EmoteKnock, + EmoteLeanState, + EmoteMeditateState, + EmoteMimeDrinking, + EmoteMimeEating, + EmoteMock, + EmoteNod, + EmoteNudgeLeft, + EmoteNudgeRight, + EmotePlead, + EmotePleadState, + EmotePoint, + EmotePointDown, + EmotePointDownState, + EmotePointLeft, + EmotePointLeftState, + EmotePointRight, + EmotePointRightState, + EmotePossumState, + EmotePray, + EmotePrayState, + EmoteReadState, + EmoteSalute, + EmoteSaluteState, + EmoteScanHorizon, + EmoteScratchHead, + EmoteScratchHeadState, + EmoteShakeFist, + EmoteShakeFistState, + EmoteShakeHead, + EmoteShiver, + EmoteShiverState, + EmoteShoo, + EmoteShrug, + EmoteSitState, + EmoteSitBackState, + EmoteSitCrossleggedState, + EmoteSlouch, + EmoteSlouchState, + EmoteSmackHead, + EmoteSnowAngelState, + EmoteSpit, + EmoteSurrender, + EmoteSurrenderState, + EmoteTalkToTheHandState, + EmoteTapFoot, + EmoteTapFootState, + EmoteTeapot, + EmoteThinkerState, + EmoteWarmHands, + EmoteWaveState, + EmoteWaveLow, + EmoteWaveHigh, + EmoteWinded, + EmoteWindedState, + EmoteWoah, + EmoteWoahState, + EmoteYawnAndStretch, + EmoteYmca, + + SelectionSelf, + SelectionPlaceInInventory, + SelectionUseClosestUnopenedCorpse, + SelectionUseNextUnopenedCorpse, + SelectionGiveToTarget, + SelectionDrop, + SelectionPlaceInMainPack, + SelectionClosestUnopenedCorpse, + SelectionNextUnopenedCorpse, + + ToggleAbuseReportingPanel, + ToggleCharacterInfoPanel, + TogglePositiveMagicPanel, + ToggleNegativeMagicPanel, + ToggleLinkStatusPanel, + ToggleUrgentAssistancePanel, + ToggleVitaePanel, + ToggleSocialPanel, + ToggleSpellManagementPanel, + ToggleCharacterDetailPanel, + ToggleMapPage, + ToggleHousePage, + ToggleGameplayOptionsPage, + ToggleCharacterSettingsPage, + ToggleConfigurationPage, + ToggleCompass, + ToggleKeyboardConfiguration, + ToggleFriendsPage, + ToggleCharacterTitlesPage, + ToggleQuestDetailPanel, + ToggleQuestJournalPage, + ToggleJournalPageList, + ToggleContractsPage, + + ChatMonarchReply, + ChatPatronReply, + ChatReply, + ChatStartCommand, + ChatTellToSelected, + + UseQuickSlot_10, + UseQuickSlot_11, + UseQuickSlot_12, + UseQuickSlot_13, + + ToggleCharacterOptionAutoRepeatAttack, + ToggleCharacterOptionIgnoreAllegianceRequests, + ToggleCharacterOptionIgnoreFellowshipRequests, + ToggleCharacterOptionIgnoreTradeRequests, + ToggleCharacterOptionPersistentAtDay, + ToggleCharacterOptionAllowGive, + ToggleCharacterOptionViewCombatTarget, + ToggleCharacterOptionShowTooltips, + ToggleCharacterOptionUseDeception, + ToggleCharacterOptionToggleRun, + ToggleCharacterOptionStayInChatMode, + ToggleCharacterOptionAdvancedCombatUi, + ToggleCharacterOptionAutoTarget, + ToggleCharacterOptionVividTargetingIndicator, + ToggleCharacterOptionFellowshipShareXp, + ToggleCharacterOptionAcceptLootPermits, + ToggleCharacterOptionFellowshipShareLoot, + ToggleCharacterOptionFellowshipAutoAcceptRequests, + ToggleCharacterOptionCoordinatesOnRadar, + ToggleCharacterOptionSpellDuration, + ToggleCharacterOptionDisableHouseRestrictionEffects, + ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade, + ToggleCharacterOptionDisplayAllegianceLogonNotifications, + ToggleCharacterOptionUseChargeAttack, + ToggleCharacterOptionUseCraftSuccessDialog, + ToggleCharacterOptionListenToAllegianceChat, + ToggleCharacterOptionDisplayDateOfBirth, + ToggleCharacterOptionDisplayAge, + ToggleCharacterOptionDisplayChessRank, + ToggleCharacterOptionDisplayFishingSkill, + ToggleCharacterOptionDisplayNumberDeaths, + ToggleCharacterOptionDisplayTimeStamps, + ToggleCharacterOptionSalvageMultiple, + ToggleCharacterOptionListenToGeneralChat, + ToggleCharacterOptionListenToTradeChat, + ToggleCharacterOptionListenToLfgChat, + ToggleCharacterOptionListenToRoleplayChat, + ToggleCharacterOptionDisplayNumberCharacterTitles, + ToggleCharacterOptionMainPackPreferred, + ToggleCharacterOptionLeadMissileTargets, + ToggleCharacterOptionUseFastMissiles, + ToggleCharacterOptionFilterLanguage, + ToggleCharacterOptionConfirmVolatileRareUse, + ToggleCharacterOptionListenToSocietyChat, + ToggleCharacterOptionShowHelm, + ToggleCharacterOptionDisableDistanceFog, + ToggleCharacterOptionShowCloak, + ToggleCharacterOptionSideBySideVitals, } diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 80cfc680..22b7da11 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -22,12 +22,9 @@ namespace AcDream.UI.Abstractions.Input; /// /// /// -/// K.1a wiring: GameWindow constructs a dispatcher alongside the -/// existing IsKeyPressed + event-handler paths. Nothing -/// subscribes to yet except a diagnostic console -/// logger — the dispatcher is observable but doesn't drive any -/// behavior. K.1b cuts the existing handlers over to the dispatcher's -/// action stream. +/// The production gameplay router is the sole gameplay subscriber; retained +/// UI, selection, camera, combat, movement, and commands all receive semantic +/// actions through this stream. /// /// public sealed class InputDispatcher : IDisposable @@ -38,6 +35,7 @@ public sealed class InputDispatcher : IDisposable private KeyBindings _bindings; private readonly Stack _scopes = new(); private InputScope? _combatScope; + private bool _cameraAlternateScope; private readonly HashSet _heldHoldChords = new(); private readonly HashSet _automationHeldActions = new(); private readonly Dictionary _mouseClickTravel = new(); @@ -55,16 +53,28 @@ public sealed class InputDispatcher : IDisposable private const long DoubleClickThresholdMs = 500; private const float ClickDragThresholdPixels = 3f; - /// K.3 modal-rebind hook: when non-null, the next non-modifier - /// chord is reported via this callback INSTEAD of firing actions. Esc - /// cancels (callback receives default(KeyChord)). + /// K.3 modal-rebind hook: when non-null, the next complete key or + /// mouse chord is reported via this callback INSTEAD of firing actions. + /// A modifier key is deferred until release so it can be captured alone or + /// used as a prefix. Esc cancels (callback receives + /// default(KeyChord)). private Action? _captureCallback; + private Key? _captureModifierCandidate; + private KeyChord? _currentPhysicalChord; /// Fires every time a binding matches a press, release, hold, /// complete click, or double-click. /// Multicast — every subscriber gets every event in subscription order. public event Action? Fired; + /// + /// The keyboard chord whose native key-down callback is synchronously + /// publishing , or outside that + /// callback. This lets retained UI suppress the raw tail of the same key + /// after a semantic action has just moved keyboard focus. + /// + public KeyChord? CurrentPhysicalChord => _currentPhysicalChord; + private InputDispatcher( IKeyboardSource keyboard, IMouseSource mouse, @@ -158,9 +168,11 @@ public sealed class InputDispatcher : IDisposable { Interlocked.Exchange(ref _active, 0); _captureCallback = null; + _captureModifierCandidate = null; _heldHoldChords.Clear(); _automationHeldActions.Clear(); _mouseClickTravel.Clear(); + _cameraAlternateScope = false; } public void Dispose() @@ -226,9 +238,24 @@ public sealed class InputDispatcher : IDisposable } /// Topmost scope on the stack — what the dispatcher looks up first. - public InputScope ActiveScope => _scopes.Peek() == InputScope.Game && _combatScope is { } combat - ? combat - : _scopes.Peek(); + public InputScope ActiveScope => _cameraAlternateScope + ? InputScope.Camera + : _scopes.Peek() == InputScope.Game && _combatScope is { } combat + ? combat + : _scopes.Peek(); + + /// + /// Installs retail InputMap 6 while the camera-mode chord (F2 or keypad + /// divide by default) is physically held. Retail registers this map at + /// priority 2000 over the ordinary priority-1000 maps, so its arrow-key + /// bindings shadow movement without replacing the normal scope stack. + /// + public void SetCameraAlternateScope(bool active) + { + if (_cameraAlternateScope == active) return; + ReleaseHeldHoldBindings(); + _cameraAlternateScope = active; + } /// Set the mode-dependent combat layer that shadows normal game chords. public void SetCombatScope(InputScope? scope) @@ -243,34 +270,72 @@ public sealed class InputDispatcher : IDisposable private Binding? FindActive(KeyChord chord, ActivationType activation) { + IReadOnlyList bindings = FindActiveBindings(chord, activation); + return bindings.Count == 0 ? null : bindings[0]; + } + + /// + /// Returns every binding in the highest-priority active retail InputMap. + /// Retail's shipped map deliberately assigns Alt+1..4 in both UICommands + /// and QuickslotCommands; ICIDM emits both actions because those maps are + /// simultaneously active. A first-match lookup made half of those exact + /// defaults unreachable. + /// + private IReadOnlyList FindActiveBindings( + KeyChord chord, + ActivationType activation) + { + if (_cameraAlternateScope) + { + Binding[] camera = FindInScope(InputScope.Camera, chord, activation); + if (camera.Length != 0) + return camera; + } + foreach (InputScope scope in _scopes) { - if (scope == InputScope.Game && _combatScope is { } combat - && _bindings.Find(chord, activation, combat) is { } combatBinding) - return combatBinding; - if (_bindings.Find(chord, activation, scope) is { } binding) - return binding; + if (scope == InputScope.Game && _combatScope is { } combat) + { + Binding[] combatBindings = FindInScope(combat, chord, activation); + if (combatBindings.Length != 0) + return combatBindings; + } + + Binding[] bindings = FindInScope(scope, chord, activation); + if (bindings.Length != 0) + return bindings; } - return null; + return Array.Empty(); } + private Binding[] FindInScope( + InputScope scope, + KeyChord chord, + ActivationType activation) => + _bindings.All + .Where(binding => + binding.Scope == scope + && binding.Chord == chord + && binding.Activation == activation) + .DistinctBy(static binding => binding.Action) + .ToArray(); + /// True iff a is in progress. public bool IsCapturing => _captureCallback is not null; /// - /// Enter modal capture mode. The next non-modifier chord pressed - /// (with whatever modifiers are held at that moment) is reported - /// via and the dispatcher does NOT - /// fire normal action events for that chord. Esc cancels — - /// receives a sentinel - /// default(KeyChord). Modifier-only key transitions - /// (Shift / Ctrl / Alt / Win held alone) are NOT captured; only a - /// non-modifier key down completes capture, so the user can dial - /// in modifier combinations before pressing the trigger key. + /// Enter modal capture mode. The next keyboard key or mouse button + /// (with whatever modifiers are held at that moment) is reported via + /// and the dispatcher does NOT fire normal + /// actions for that chord. Shift/Ctrl/Alt/Win are deferred until key-up: + /// pressing another key first makes them a prefix; releasing the modifier + /// first captures the modifier-only binding. Esc cancels and reports + /// default(KeyChord). /// public void BeginCapture(Action onCaptured) { _captureCallback = onCaptured ?? throw new ArgumentNullException(nameof(onCaptured)); + _captureModifierCandidate = null; } /// @@ -283,6 +348,7 @@ public sealed class InputDispatcher : IDisposable var cb = _captureCallback; if (cb is null) return; _captureCallback = null; + _captureModifierCandidate = null; cb(default); } @@ -440,8 +506,7 @@ public sealed class InputDispatcher : IDisposable if (_heldHoldChords.Count == 0) return; var releases = new List(_heldHoldChords.Count); foreach (KeyChord chord in _heldHoldChords) - if (FindActive(chord, ActivationType.Hold) is { } binding) - releases.Add(binding); + releases.AddRange(FindActiveBindings(chord, ActivationType.Hold)); _heldHoldChords.Clear(); foreach (Binding binding in releases) Fired?.Invoke(binding.Action, ActivationType.Release); @@ -469,9 +534,8 @@ public sealed class InputDispatcher : IDisposable // chord; never dispatch a stale snapshot entry afterward. if (!_heldHoldChords.Contains(chord)) continue; - var hold = FindActive(chord, ActivationType.Hold); - if (hold is not null) - Fired?.Invoke(hold.Value.Action, ActivationType.Hold); + foreach (Binding hold in FindActiveBindings(chord, ActivationType.Hold)) + Fired?.Invoke(hold.Action, ActivationType.Hold); } } @@ -480,50 +544,62 @@ public sealed class InputDispatcher : IDisposable if (Volatile.Read(ref _active) == 0) return; // K.3 modal capture (used by Settings panel's "Rebind" UX) takes // precedence over both WantCaptureKeyboard gating AND normal - // binding lookup. Esc cancels capture; modifier-only keys don't - // complete it (so the user can dial in Shift/Ctrl/Alt before - // pressing the trigger key); every other key completes capture - // with the current modifier state. + // binding lookup. Esc cancels capture. A modifier key is deferred + // until its key-up so it can either become the primary binding by + // itself (retail's walk-mode default) or remain a prefix when the + // user presses a non-modifier key before releasing it. if (_captureCallback is not null) { if (key == Key.Escape) { var cb = _captureCallback; _captureCallback = null; + _captureModifierCandidate = null; cb(default); return; } - if (IsModifierKey(key)) return; // dial more mods, don't complete + if (IsModifierKey(key)) + { + _captureModifierCandidate = key; + return; + } var captured = new KeyChord(key, mods, Device: 0); var cb2 = _captureCallback; _captureCallback = null; + _captureModifierCandidate = null; cb2(captured); return; // SUPPRESS the action — don't run binding lookup below } if (_mouse.WantCaptureKeyboard) return; - var chord = new KeyChord(key, mods, Device: 0); - - var press = FindActive(chord, ActivationType.Press); - if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press); - - var click = FindActive(chord, ActivationType.Click); - if (click is not null) Fired?.Invoke(click.Value.Action, ActivationType.Click); - - var hold = FindActive(chord, ActivationType.Hold); - if (hold is not null) + var chord = KeyboardChord(key, mods); + _currentPhysicalChord = chord; + try { - // Emit a Press transition so subscribers can latch state, then - // record the chord so Tick() will re-fire Hold every frame. - Fired?.Invoke(hold.Value.Action, ActivationType.Press); - _heldHoldChords.Add(chord); + foreach (Binding press in FindActiveBindings(chord, ActivationType.Press)) + Fired?.Invoke(press.Action, ActivationType.Press); + + foreach (Binding click in FindActiveBindings(chord, ActivationType.Click)) + Fired?.Invoke(click.Action, ActivationType.Click); + + IReadOnlyList holds = FindActiveBindings(chord, ActivationType.Hold); + if (holds.Count != 0) + { + // Emit a Press transition so subscribers can latch state, then + // record the chord so Tick() will re-fire Hold every frame. + foreach (Binding hold in holds) + Fired?.Invoke(hold.Action, ActivationType.Press); + _heldHoldChords.Add(chord); + } + } + finally + { + _currentPhysicalChord = null; } } - /// True for Shift/Ctrl/Alt/Win left+right variants — keys - /// that don't complete a capture by themselves. The user holds them - /// to dial in modifier combinations before pressing the trigger key. + /// True for Shift/Ctrl/Alt/Win left+right variants. private static bool IsModifierKey(Key key) => key switch { Key.ShiftLeft or Key.ShiftRight => true, @@ -536,13 +612,24 @@ public sealed class InputDispatcher : IDisposable private void OnKeyUp(Key key, ModifierMask mods) { if (Volatile.Read(ref _active) == 0) return; + if (_captureCallback is not null) + { + if (_captureModifierCandidate == key) + { + Action callback = _captureCallback; + _captureCallback = null; + _captureModifierCandidate = null; + callback(KeyboardChord(key, mods)); + } + return; + } // Release fires regardless of WantCaptureKeyboard so we don't // strand a Hold subscriber in the "held" state if the UI captured // mid-press. - var chord = new KeyChord(key, mods, Device: 0); + var chord = KeyboardChord(key, mods); - var release = FindActive(chord, ActivationType.Release); - if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release); + foreach (Binding release in FindActiveBindings(chord, ActivationType.Release)) + Fired?.Invoke(release.Action, ActivationType.Release); // Any matching Hold binding gets a Release transition. Walk the // tracked set looking for a chord with a matching Key (ignoring @@ -556,8 +643,8 @@ public sealed class InputDispatcher : IDisposable foreach (var held in toRemove) { _heldHoldChords.Remove(held); - var hold = FindActive(held, ActivationType.Hold); - if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release); + foreach (Binding hold in FindActiveBindings(held, ActivationType.Hold)) + Fired?.Invoke(hold.Action, ActivationType.Release); } } @@ -565,16 +652,33 @@ public sealed class InputDispatcher : IDisposable { if (Volatile.Read(ref _active) == 0) return; _mouseClickTravel.Remove(button); + // Retail UIOption_ActionKeyMap captures a QualifiedControl, not merely + // a keyboard scan code. Mouse buttons therefore use the same modal + // capture path and suppress their ordinary action, even while the UI + // owns the pointer for the binding dialog. + if (_captureCallback is not null) + { + var captured = new KeyChord( + MouseButtonToKey(button), + mods, + Device: 1); + Action callback = _captureCallback; + _captureCallback = null; + _captureModifierCandidate = null; + callback(captured); + return; + } if (_mouse.WantCaptureMouse) return; var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); - var press = FindActive(chord, ActivationType.Press); - if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press); + foreach (Binding press in FindActiveBindings(chord, ActivationType.Press)) + Fired?.Invoke(press.Action, ActivationType.Press); - var hold = FindActive(chord, ActivationType.Hold); - if (hold is not null) + IReadOnlyList holds = FindActiveBindings(chord, ActivationType.Hold); + if (holds.Count != 0) { - Fired?.Invoke(hold.Value.Action, ActivationType.Press); + foreach (Binding hold in holds) + Fired?.Invoke(hold.Action, ActivationType.Press); _heldHoldChords.Add(chord); } @@ -589,8 +693,8 @@ public sealed class InputDispatcher : IDisposable if (_lastMouseDownButton == button && nowMs - _lastMouseDownTickMs <= DoubleClickThresholdMs) { - var dbl = FindActive(chord, ActivationType.DoubleClick); - if (dbl is not null) Fired?.Invoke(dbl.Value.Action, ActivationType.DoubleClick); + foreach (Binding dbl in FindActiveBindings(chord, ActivationType.DoubleClick)) + Fired?.Invoke(dbl.Action, ActivationType.DoubleClick); _lastMouseDownButton = null; // consumed; require fresh pair for next } else @@ -606,8 +710,8 @@ public sealed class InputDispatcher : IDisposable var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); bool wasClickCandidate = _mouseClickTravel.Remove(button, out float travel); - var release = FindActive(chord, ActivationType.Release); - if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release); + foreach (Binding release in FindActiveBindings(chord, ActivationType.Release)) + Fired?.Invoke(release.Action, ActivationType.Release); var keyForLookup = MouseButtonToKey(button); var toRemove = new List(); @@ -619,16 +723,17 @@ public sealed class InputDispatcher : IDisposable foreach (var held in toRemove) { _heldHoldChords.Remove(held); - var hold = FindActive(held, ActivationType.Hold); - if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release); + foreach (Binding hold in FindActiveBindings(held, ActivationType.Hold)) + Fired?.Invoke(hold.Action, ActivationType.Release); } if (wasClickCandidate && !_mouse.WantCaptureMouse && travel <= ClickDragThresholdPixels - && FindActive(chord, ActivationType.Click) is { } click) + && FindActiveBindings(chord, ActivationType.Click) is { Count: > 0 } clicks) { - Fired?.Invoke(click.Action, ActivationType.Click); + foreach (Binding click in clicks) + Fired?.Invoke(click.Action, ActivationType.Click); } } @@ -683,6 +788,25 @@ public sealed class InputDispatcher : IDisposable _ => (Key)(-1000 - (int)button), }; + /// + /// Silk includes a modifier key's own bit in the event modifier mask; + /// retail QualifiedControl stores a bare DIK_LSHIFT/LCONTROL/LMENU with + /// metamode zero. Remove only the primary key's self bit so persisted and + /// displayed chords remain byte-faithful while combinations stay exact. + /// + private static KeyChord KeyboardChord(Key key, ModifierMask modifiers) + { + modifiers &= key switch + { + Key.ShiftLeft or Key.ShiftRight => ~ModifierMask.Shift, + Key.ControlLeft or Key.ControlRight => ~ModifierMask.Ctrl, + Key.AltLeft or Key.AltRight => ~ModifierMask.Alt, + Key.SuperLeft or Key.SuperRight => ~ModifierMask.Win, + _ => ~ModifierMask.None, + }; + return new KeyChord(key, modifiers, Device: 0); + } + private List DetachSources() { var failures = new List(); diff --git a/src/AcDream.UI.Abstractions/Input/InputScope.cs b/src/AcDream.UI.Abstractions/Input/InputScope.cs index 857f3816..230312ed 100644 --- a/src/AcDream.UI.Abstractions/Input/InputScope.cs +++ b/src/AcDream.UI.Abstractions/Input/InputScope.cs @@ -8,11 +8,8 @@ namespace AcDream.UI.Abstractions.Input; /// sits at the bottom of the stack and catches global chords like /// Esc / F1 that should fire regardless of focus. /// -/// -/// K.1a defines the enum but only pushes + -/// by default. Combat scopes light up in Phase L -/// when CombatState.CurrentMode tracking lands. -/// +/// Combat scope follows the live retail combat mode; modal/edit/chat +/// scopes are pushed above it as their authored surfaces activate. /// public enum InputScope { @@ -30,13 +27,13 @@ public enum InputScope /// A modal dialog is open and capturing input. Dialog, /// Combat with melee weapon equipped — Insert/PgUp/Delete/End/PgDn - /// remap to power + attack-level. Dormant until Phase L. + /// remap to power + attack-level. MeleeCombat, /// Combat with missile weapon equipped — Insert/PgUp/Delete/End/PgDn - /// remap to accuracy + aim-level. Dormant until Phase L. + /// remap to accuracy + aim-level. MissileCombat, /// Magic mode — 1-9 cast UseSpellSlot; Insert/PgUp etc. - /// page through spell tabs. Dormant until Phase L. + /// page through spell tabs. MagicCombat, /// Camera alternate mode (F2 / Numpad-/) — arrow keys rotate /// the camera instead of the character. diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs index 52a55e5c..c0d79d27 100644 --- a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs +++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs @@ -10,9 +10,9 @@ namespace AcDream.UI.Abstractions.Input; /// /// Mutable collection of s. Owns lookup by chord /// (for the dispatcher) and lookup by action (for the Settings UI). -/// Insertion-order preserved — first-match-wins on lookup, so a user -/// can add a custom binding ahead of a default and have it take effect -/// without removing the default. +/// Insertion order is preserved. Direct +/// queries return the first match; emits every +/// distinct action in the highest-priority active retail input map. /// /// /// K.1c: now returns the full retail-faithful @@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input; /// public sealed class KeyBindings { - private const int CurrentSchemaVersion = 5; + private const int CurrentSchemaVersion = 7; private readonly List _bindings = new(); @@ -162,15 +162,9 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.C, ModifierMask.None), InputAction.MovementStrafeRight)); b.Add(new(new KeyChord(Key.D, ModifierMask.Alt), InputAction.MovementStrafeRight)); b.Add(new(new KeyChord(Key.Right, ModifierMask.Alt), InputAction.MovementStrafeRight)); - // Walk-mode modifier — Hold so a subscriber can latch state on - // press and unlatch on release. K-fix1 (2026-04-26): the chord - // modifier MUST be Shift, not None — when LShift/RShift is the - // primary key the OS keyboard reports CurrentModifiers=Shift - // alongside the key-down. Bind both left + right shift to match. - // This is the same pattern AcdreamCurrentDefaults uses for its - // Shift→RunLock binding (see lines 98-99 above). - b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.Shift), InputAction.MovementWalkMode, ActivationType.Hold)); - b.Add(new(new KeyChord(Key.ShiftRight, ModifierMask.Shift), InputAction.MovementWalkMode, ActivationType.Hold)); + // Retail authors exactly bare DIK_LSHIFT. InputDispatcher normalizes + // Silk's self-reported Shift modifier bit at the physical boundary. + b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.None), InputAction.MovementWalkMode, ActivationType.Hold)); b.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementRunLock)); b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementStop)); b.Add(new(new KeyChord(Key.Y, ModifierMask.None), InputAction.Ready)); @@ -180,7 +174,10 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump)); // ── ItemSelectionCommands ────────────────────────────── - b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.SelectionPickUp)); + // Retail action 0x1000002C: F places the selected object in the + // inventory. The old SelectionPickUp alias had no ActionMap row and + // made Configure Keyboard show the F default on the wrong command. + b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.SelectionPlaceInInventory)); b.Add(new(new KeyChord(Key.T, ModifierMask.None), InputAction.SelectionSplitStack)); b.Add(new(new KeyChord(Key.P, ModifierMask.None), InputAction.SelectionPreviousSelection)); b.Add(new(new KeyChord(Key.Backspace, ModifierMask.None), InputAction.SelectionClosestCompassItem)); @@ -223,20 +220,21 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.Escape, ModifierMask.Shift), InputAction.LOGOUT)); // ── QuickslotCommands ────────────────────────────────── - // Retail gmToolbarUI::ListenToGlobalMessage @ 0x004BE4E0 receives - // distinct action-id ranges: bare 1..9 USE slots 0..8, while - // Ctrl+1..9 SELECT those slots. The keymap repeats the display name - // UseQuickSlot_N for both bindings, so our semantic action layer must - // preserve the differing intent explicitly. + // Retail's MasterInputMap binds both bare N and Ctrl+N to the SAME + // UseQuickSlot_N action ids (0x10000042..4A). The separate Select + // Quickslot action ids (0x1000004E..56) have no default chords. for (int i = 1; i <= 9; i++) { var k = (Key)((int)Key.Number0 + i); // Number1..Number9 var useAction = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1); - var selectAction = (InputAction)((int)InputAction.SelectQuickSlot_1 + i - 1); b.Add(new(new KeyChord(k, ModifierMask.None), useAction)); - b.Add(new(new KeyChord(k, ModifierMask.Ctrl), selectAction)); + b.Add(new(new KeyChord(k, ModifierMask.Ctrl), useAction)); } - // Alt+5..9 → UseQuickSlot_14..18. + // Alt+1..4 → slots 10..13; Alt+5..9 → slots 14..18. + b.Add(new(new KeyChord(Key.Number1, ModifierMask.Alt), InputAction.UseQuickSlot_10)); + b.Add(new(new KeyChord(Key.Number2, ModifierMask.Alt), InputAction.UseQuickSlot_11)); + b.Add(new(new KeyChord(Key.Number3, ModifierMask.Alt), InputAction.UseQuickSlot_12)); + b.Add(new(new KeyChord(Key.Number4, ModifierMask.Alt), InputAction.UseQuickSlot_13)); for (int i = 5; i <= 9; i++) { var k = (Key)((int)Key.Number0 + i); @@ -250,7 +248,7 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.ToggleChatEntry)); b.Add(new(new KeyChord(Key.Enter, ModifierMask.None), InputAction.EnterChatMode)); - // ── Combat (mode-dependent — dormant in K, lights up in Phase L) ── + // ── Combat (mode-dependent retail scopes) ── b.Add(new(new KeyChord(Key.GraveAccent, ModifierMask.None), InputAction.CombatToggleCombat)); // Melee mode (active when MeleeCombat scope pushed). b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower, Scope: InputScope.MeleeCombat)); @@ -261,15 +259,13 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold, InputScope.MeleeCombat)); b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold, InputScope.MeleeCombat)); b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold, InputScope.MeleeCombat)); - // Missile + Magic + Spell-tab — same chords; resolved by scope at - // runtime per InputDispatcher's stack lookup. Add the bindings; - // subscribers arrive in Phase L when CombatState.CurrentMode is - // wired. + // Missile + Magic + Spell-tab — same chords; resolved by the live + // combat scope through InputDispatcher's stack lookup. b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy, Scope: InputScope.MissileCombat)); b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy, Scope: InputScope.MissileCombat)); - b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow, Scope: InputScope.MissileCombat)); - b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium, Scope: InputScope.MissileCombat)); - b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh, Scope: InputScope.MissileCombat)); + b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow, ActivationType.Hold, InputScope.MissileCombat)); + b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium, ActivationType.Hold, InputScope.MissileCombat)); + b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh, ActivationType.Hold, InputScope.MissileCombat)); b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab, Scope: InputScope.MagicCombat)); b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab, Scope: InputScope.MagicCombat)); b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell, Scope: InputScope.MagicCombat)); @@ -294,8 +290,8 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.K, ModifierMask.None), InputAction.PointState)); // ── Camera ───────────────────────────────────────────── - b.Add(new(new KeyChord(Key.KeypadDivide, ModifierMask.None), InputAction.CameraActivateAlternateMode)); - b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.CameraActivateAlternateMode)); + b.Add(new(new KeyChord(Key.KeypadDivide, ModifierMask.None), InputAction.CameraActivateAlternateMode, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.CameraActivateAlternateMode, ActivationType.Hold)); // CameraInstantMouseLook (MMB hold) — encoded as a mouse chord // via the K.1a Device=1 convention. K.2 lights up the actual // camera+yaw drive logic. @@ -304,16 +300,22 @@ public sealed class KeyBindings InputAction.CameraInstantMouseLook, ActivationType.Hold)); // Numpad cluster. - b.Add(new(new KeyChord(Key.Keypad4, ModifierMask.None), InputAction.CameraRotateLeft)); - b.Add(new(new KeyChord(Key.Keypad6, ModifierMask.None), InputAction.CameraRotateRight)); - b.Add(new(new KeyChord(Key.Keypad8, ModifierMask.None), InputAction.CameraRotateUp)); - b.Add(new(new KeyChord(Key.Keypad2, ModifierMask.None), InputAction.CameraRotateDown)); - b.Add(new(new KeyChord(Key.KeypadSubtract, ModifierMask.None), InputAction.CameraMoveToward)); - b.Add(new(new KeyChord(Key.KeypadAdd, ModifierMask.None), InputAction.CameraMoveAway)); + b.Add(new(new KeyChord(Key.Keypad4, ModifierMask.None), InputAction.CameraRotateLeft, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.Keypad6, ModifierMask.None), InputAction.CameraRotateRight, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.Keypad8, ModifierMask.None), InputAction.CameraRotateUp, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.Keypad2, ModifierMask.None), InputAction.CameraRotateDown, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.KeypadSubtract, ModifierMask.None), InputAction.CameraMoveToward, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.KeypadAdd, ModifierMask.None), InputAction.CameraMoveAway, ActivationType.Hold)); b.Add(new(new KeyChord(Key.Keypad0, ModifierMask.None), InputAction.CameraViewDefault)); b.Add(new(new KeyChord(Key.KeypadDecimal, ModifierMask.None), InputAction.CameraViewFirstPerson)); b.Add(new(new KeyChord(Key.Keypad5, ModifierMask.None), InputAction.CameraViewLookDown)); b.Add(new(new KeyChord(Key.KeypadEnter, ModifierMask.None), InputAction.CameraViewMapMode)); + // CameraAlternateControls is a separate retail InputMap. Its arrow + // defaults must not alias the movement/camera-primary row identities. + b.Add(new(new KeyChord(Key.Left, ModifierMask.None), InputAction.CameraAlternateRotateLeft, ActivationType.Hold, InputScope.Camera)); + b.Add(new(new KeyChord(Key.Right, ModifierMask.None), InputAction.CameraAlternateRotateRight, ActivationType.Hold, InputScope.Camera)); + b.Add(new(new KeyChord(Key.Up, ModifierMask.None), InputAction.CameraAlternateRotateUp, ActivationType.Hold, InputScope.Camera)); + b.Add(new(new KeyChord(Key.Down, ModifierMask.None), InputAction.CameraAlternateRotateDown, ActivationType.Hold, InputScope.Camera)); // ── Mouse selection ──────────────────────────────────── // Retail keymap: SelectLeft = LMB, SelectRight = RMB, SelectMid = MMB, @@ -413,6 +415,7 @@ public sealed class KeyBindings var defaults = RetailDefaults(); var loaded = new KeyBindings(); + var explicitlyStoredActions = new HashSet(); if (root.TryGetProperty("actions", out var actionsEl) && actionsEl.ValueKind == JsonValueKind.Object) @@ -422,6 +425,7 @@ public sealed class KeyBindings if (!Enum.TryParse(actionProp.Name, out var action)) continue; // unknown action → skip if (actionProp.Value.ValueKind != JsonValueKind.Array) continue; + explicitlyStoredActions.Add(action); foreach (var bindingEl in actionProp.Value.EnumerateArray()) { if (!bindingEl.TryGetProperty("key", out var keyEl)) continue; @@ -444,7 +448,11 @@ public sealed class KeyBindings device = (byte)dEl.GetInt32(); } var chord = new KeyChord(silkKey, mods, device); - action = MigrateLegacyQuickSlotIntent(version, action, chord, activation); + action = MigrateQuickSlotIntent(version, action, chord, activation); + // A migrated action is still an explicit user entry. + // Without this, the default-merge pass below appends the + // retail default beside the migrated custom chord. + explicitlyStoredActions.Add(action); activation = MigrateCombatAttackActivation(version, action, activation); activation = MigrateSelectRightActivation(version, action, activation); InputScope scope = defaults.ForAction(action) @@ -468,7 +476,7 @@ public sealed class KeyBindings // newly-added actions if the user file is older. foreach (var actionInDefaults in Enum.GetValues()) { - if (!loaded.ForAction(actionInDefaults).Any() + if (!explicitlyStoredActions.Contains(actionInDefaults) && defaults.ForAction(actionInDefaults).Any()) { foreach (var def in defaults.ForAction(actionInDefaults)) @@ -496,6 +504,12 @@ public sealed class KeyBindings if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var actions = new SortedDictionary>(StringComparer.Ordinal); + // An empty array is meaningful: the player explicitly cleared all + // three GUI slots for this retail action. Earlier schemas omitted the + // property entirely, so the next launch mistook "unbound" for "new + // action missing from an old file" and silently restored its default. + foreach (InputAction action in RetailActionIdentityTable.Map.Values) + actions.TryAdd(action.ToString(), new List()); foreach (var binding in _bindings) { if (!actions.TryGetValue(binding.Action.ToString(), out var list)) @@ -528,30 +542,31 @@ public sealed class KeyBindings } /// - /// Schema v1 repeated UseQuickSlot_N for bare and Ctrl chords, - /// losing retail's use-vs-select distinction. Migrate only the exact old - /// default Ctrl+matching-number shape; arbitrary user rebindings remain - /// attached to the action the user chose. + /// Schema v2-v5 incorrectly rewrote retail's Ctrl+1..9 + /// UseQuickSlot_N defaults to SelectQuickSlot_N. The 2013 + /// MasterInputMap and gmToolbarUI::ListenToGlobalMessage both prove + /// Ctrl+N sends the same use action as bare N. Repair only that exact old + /// generated-default shape; arbitrary SelectQuickSlot rebindings remain. /// - private static InputAction MigrateLegacyQuickSlotIntent( + private static InputAction MigrateQuickSlotIntent( int version, InputAction action, KeyChord chord, ActivationType activation) { - if (version >= 2 + if (version >= 6 || activation != ActivationType.Press || chord.Device != 0 || chord.Modifiers != ModifierMask.Ctrl) return action; - int offset = (int)action - (int)InputAction.UseQuickSlot_1; + int offset = (int)action - (int)InputAction.SelectQuickSlot_1; if ((uint)offset >= 9u) return action; var expectedKey = (Key)((int)Key.Number1 + offset); return chord.Key == expectedKey - ? (InputAction)((int)InputAction.SelectQuickSlot_1 + offset) + ? (InputAction)((int)InputAction.UseQuickSlot_1 + offset) : action; } diff --git a/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs b/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs index 1e3b6223..066c9bcd 100644 --- a/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs +++ b/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs @@ -1,72 +1,138 @@ using System.Collections.Generic; +using System.Linq; namespace AcDream.UI.Abstractions.Input; /// /// Campaign OP slice OP8: maps a retail DAT ActionMap row — the /// (InputMap id, Action id) pair AcDream.Core.Input.RetailActionMapRow -/// carries — to acdream's own , when one exists. +/// carries — to acdream's own . /// /// -/// Why this table exists. The DAT ActionMap singleton (empirically dumped -/// 2026-08-11, see AcDream.Core.Input.RetailActionMap's class doc) carries 306 -/// user-bindable rows. — the enum every OTHER acdream input -/// path (live dispatch, KeyBindings, InputDispatcher) already keys on — -/// has roughly half that many members, because it was authored around "what acdream -/// currently implements" (K.1a/K.1c), not "every action the 2013 client's keymap -/// screen can show." Two categories are the biggest gaps: 82 of the DAT's 87 Emote -/// rows have no acdream animation dispatch yet (only 5 are wired: Cry/Laugh/Cheer/ -/// Wave/PointState — exactly the 5 that happen to carry retail default keys), and all -/// 48 CharacterSettings rows 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 yet anywhere in acdream and is out of scope for this slice (see the -/// OP8 register row). +/// The DAT ActionMap singleton carries exactly 306 user-bindable rows. Campaign KB +/// gives every row one distinct live identity. Identity is the full +/// (InputMapId, ActionId) pair: retail legitimately reuses action ids between +/// CameraControls and CameraAlternateControls, and collapsing those rows would make +/// one GUI rebind silently overwrite the other. /// /// /// -/// Every mapping below was verified two ways before being added: (1) the DAT's +/// Every mapping below was verified two ways: (1) the DAT's /// resolved English label/tooltip unambiguously names the SAME action as the /// member's own XML doc, AND (2) where the retail default /// key(s) for that DAT row are non-empty, they match /// 's existing chord(s) for the candidate -/// (byte-verified 2026-08-11 against the installed dats — -/// see RetailActionMapReaderTests.LiveDatTests and this slice's -/// RetailActionIdentityRoundTripTests). A DAT row that could not be verified -/// BOTH ways is left OUT of this table on purpose — it renders on the Configure -/// Keyboard screen as a real, bindable, persisted row (see -/// KeyboardConfigController), it just does not yet reach any live acdream -/// consumer. Silently guessing a wrong mapping would misroute a user's rebind to the -/// WRONG gameplay action, which is worse than an honest "not wired yet." -/// -/// -/// -/// Known gaps deliberately left unmapped (register row, OP8): -/// Spell Slot 10/11/12 (ctx 0x10000005, DAT actions 0x6E/0x6F/0x70 — -/// only defines UseSpellSlot_1..9); Quickslot -/// 10/11/12/13 (ctx 0x1000000C, DAT actions 0x1000004B/4C/4D/10000132 — -/// 's UseQuickSlot_* family jumps from 9 straight to -/// 14, a pre-existing enum gap this slice did not introduce and does not fix); every -/// CharacterSettings row (ctx 0x10000008, all 48); 82 of 87 Emote rows (ctx -/// 0x10000006); all 10 CameraAlternateControls rows (ctx 0x6 — the M2 -/// de-alias carve-out, see the mapping table's own comment); and roughly half of the -/// UI-class rows (ctx 0x10000007/0x10000009 — panels acdream has no -/// toggle for, e.g. Vitae, Link Status, House, Map, Character Info, the -/// positive/negative Magic panels). +/// . The installed-DAT conformance test requires complete, +/// injective 306/306 coverage, so a future DAT drift cannot quietly recreate the +/// former dim/store-only tier. /// /// public static class RetailActionIdentityTable { /// (InputMap id, Action id) → the acdream that - /// owns live dispatch for it. A DAT row whose key is absent has no acdream - /// consumer yet. + /// owns live dispatch for it. public static readonly IReadOnlyDictionary<(uint InputMapId, uint ActionId), InputAction> Map = BuildTable(); public static bool TryResolve(uint inputMapId, uint actionId, out InputAction action) => Map.TryGetValue((inputMapId, actionId), out action); + /// Inverse identity used by family routers and conformance checks. + public static readonly IReadOnlyDictionary ReverseMap = + Map.ToDictionary(static pair => pair.Value, static pair => pair.Key); + + public static bool TryGetRetailIdentity( + InputAction action, + out (uint InputMapId, uint ActionId) identity) => + ReverseMap.TryGetValue(action, out identity); + + /// Live dispatch scope implied by the retail InputMap context. + public static InputScope ScopeForInputMap(uint inputMapId) => inputMapId switch + { + 0x00000006u => InputScope.Camera, + 0x10000003u => InputScope.MeleeCombat, + 0x10000004u => InputScope.MissileCombat, + 0x10000005u => InputScope.MagicCombat, + _ => InputScope.Game, + }; + + /// + /// Retail action delivery shape for a DAT ActionMap row. Continuous + /// movement/camera actions receive start and stop edges; the remaining + /// rows are one-shot presses unless a concrete retail consumer declares + /// otherwise in . + /// + public static ActivationType ActivationFor(uint inputMapId, uint actionId) + { + if (inputMapId == 0x4u && actionId == 0x32u) + return ActivationType.Hold; + + if (inputMapId is 0x5u or 0x6u + && actionId is >= 0x33u and <= 0x38u) + { + return ActivationType.Hold; + } + + if (inputMapId == 0x5u && actionId is 0x3Du or 0x3Eu) + return ActivationType.Hold; + + // ClientCombatSystem::HandleCombatAction @ 0x0056D600 sends both + // melee 0x5D-0x5F and missile 0xF1-0xF3 through Begin/EndAttackRequest. + if (inputMapId == 0x10000003u + && actionId is >= 0x1000005Du and <= 0x1000005Fu) + { + return ActivationType.Hold; + } + + if (inputMapId == 0x10000004u + && actionId is >= 0x100000F1u and <= 0x100000F3u) + { + return ActivationType.Hold; + } + + return ActivationType.Press; + } + + /// + /// Maps a CharacterSettings hotkey identity to retail's linear + /// PlayerOption id. The five 2013 options absent from ActionMap remain + /// configurable through the Character page, but correctly have no row + /// here. + /// + public static bool TryGetCharacterOptionId(InputAction action, out uint optionId) + { + optionId = 0u; + if (!TryGetRetailIdentity(action, out var identity) + || identity.InputMapId != 0x10000008u) + { + return false; + } + + optionId = identity.ActionId switch + { + >= 0x10000071u and <= 0x10000074u => identity.ActionId - 0x10000071u, + >= 0x10000076u and <= 0x10000083u => identity.ActionId - 0x10000071u, + >= 0x10000085u and <= 0x10000093u => identity.ActionId - 0x10000071u, + 0x1000010Eu => 0x23u, + 0x1000010Fu => 0x24u, + 0x10000110u => 0x25u, + 0x10000112u => 0x26u, + 0x1000011Bu => 0x28u, + 0x1000011Du => 0x29u, + 0x1000011Eu => 0x2Au, + 0x1000011Fu => 0x2Bu, + 0x10000120u => 0x2Cu, + 0x10000123u => 0x2Du, + 0x10000125u => 0x2Eu, + 0x1000012Au => 0x2Fu, + 0x1000012Cu => 0x30u, + 0x1000012Fu => 0x32u, + 0x1000013Eu => 0x13u, + _ => uint.MaxValue, + }; + return optionId != uint.MaxValue; + } + private static Dictionary<(uint, uint), InputAction> BuildTable() { var t = new Dictionary<(uint, uint), InputAction>(); @@ -89,24 +155,9 @@ public static class RetailActionIdentityTable M(0x4, 0x10000097, InputAction.Sleeping); // ── CameraControls (ctx 0x5) — 12/12. ────────────────────────── - // M2 REWORK (2026-08-11 review): CameraControls (ctx 0x5, the - // Numpad-default scheme RetailDefaults() actually carries) and - // CameraAlternateControls (ctx 0x6, the arrow-key alternate scheme - // RetailDefaults() never had — see - // RetailActionIdentityRoundTripTests' now-retired camera allowlist - // entries) were both previously mapped to the SAME InputAction. - // KeyBindings/Binding has no "which scheme" tag, and SetForAction is - // whole-action replacement, so the two rows aliased one live target: - // both showed identical (stale) chords, rebinding one silently wiped - // the other, and a row could conflict with its own twin. Building - // real per-scheme dual-binding storage (or ten new InputAction - // members plus the camera-dispatch code to consume them) is a real - // feature, not a one-line fix, and out of scope for this rework. Only - // ctx 0x5 — the scheme that already has a live, verified - // RetailDefaults() presence — maps here; ctx 0x6 falls through to the - // generic unmapped/store-only path below (AP-203), fully renderable, - // bindable and persisted, honestly carrying no live effect, exactly - // like every other unmapped row. + // The primary and alternate maps deliberately use distinct actions. + // Retail reuses the ten low action ids, but they are separate rows and + // separate rebind targets; collapsing them aliases GUI state. M(0x5, 0x33, InputAction.CameraMoveToward); M(0x5, 0x34, InputAction.CameraMoveAway); M(0x5, 0x35, InputAction.CameraRotateLeft); @@ -120,6 +171,18 @@ public static class RetailActionIdentityTable M(0x5, 0x3D, InputAction.CameraInstantMouseLook); M(0x5, 0x3E, InputAction.CameraActivateAlternateMode); + // ── CameraAlternateControls (ctx 0x6) — 10/10. ───────────── + M(0x6, 0x33, InputAction.CameraAlternateMoveToward); + M(0x6, 0x34, InputAction.CameraAlternateMoveAway); + M(0x6, 0x35, InputAction.CameraAlternateRotateLeft); + M(0x6, 0x36, InputAction.CameraAlternateRotateRight); + M(0x6, 0x37, InputAction.CameraAlternateRotateUp); + M(0x6, 0x38, InputAction.CameraAlternateRotateDown); + M(0x6, 0x39, InputAction.CameraAlternateViewDefault); + M(0x6, 0x3A, InputAction.CameraAlternateViewFirstPerson); + M(0x6, 0x3B, InputAction.CameraAlternateViewLookDown); + M(0x6, 0x3C, InputAction.CameraAlternateViewMapMode); + // ── Combat (ctx 0x10000002) — 1/1. ───────────────────────────── M(0x10000002, 0x1000005A, InputAction.CombatToggleCombat); @@ -137,8 +200,7 @@ public static class RetailActionIdentityTable M(0x10000004, 0x100000F2, InputAction.CombatAimMedium); M(0x10000004, 0x100000F3, InputAction.CombatAimHigh); - // ── MagicCombat (ctx 0x10000005) — 18/21 (Spell Slot 10/11/12 have - // no InputAction — register row). ──────────────────────────── + // ── MagicCombat (ctx 0x10000005) — 21/21. ────────────────────── M(0x10000005, 0x10000060, InputAction.CombatCastCurrentSpell); M(0x10000005, 0x10000061, InputAction.CombatPrevSpell); M(0x10000005, 0x10000062, InputAction.CombatNextSpell); @@ -153,21 +215,111 @@ public static class RetailActionIdentityTable M(0x10000005, 0x1000006B, InputAction.UseSpellSlot_7); M(0x10000005, 0x1000006C, InputAction.UseSpellSlot_8); M(0x10000005, 0x1000006D, InputAction.UseSpellSlot_9); - // 0x6E/0x6F/0x70 (Spell Slot 10/11/12) — no InputAction. Unmapped. + M(0x10000005, 0x1000006E, InputAction.UseSpellSlot_10); + M(0x10000005, 0x1000006F, InputAction.UseSpellSlot_11); + M(0x10000005, 0x10000070, InputAction.UseSpellSlot_12); M(0x10000005, 0x10000102, InputAction.CombatFirstSpell); M(0x10000005, 0x10000103, InputAction.CombatLastSpell); M(0x10000005, 0x10000104, InputAction.CombatFirstSpellTab); M(0x10000005, 0x10000105, InputAction.CombatLastSpellTab); - // ── Emotes (ctx 0x10000006) — 5/87 (the only 5 acdream dispatches - // an animation for; also the only 5 with retail default keys). ── - M(0x10000006, 0x100000A2, InputAction.Cheer); - M(0x10000006, 0x100000A7, InputAction.Cry); - M(0x10000006, 0x100000B2, InputAction.Laugh); - M(0x10000006, 0x100000BE, InputAction.PointState); - M(0x10000006, 0x100000E5, InputAction.Wave); + // ── Emotes (ctx 0x10000006) — 87/87. ──────────────────────── + InputAction[] emotes = + { + InputAction.EmoteAfkState, + InputAction.EmoteAkimbo, + InputAction.EmoteAToyotState, + InputAction.EmoteAkimboState, + InputAction.EmoteAtEaseState, + InputAction.EmoteBeckon, + InputAction.EmoteBeSeeingYou, + InputAction.EmoteBlowKiss, + InputAction.EmoteBowDeep, + InputAction.EmoteBowDeepState, + InputAction.Cheer, + InputAction.EmoteClapHands, + InputAction.EmoteClapHandsState, + InputAction.EmoteCringe, + InputAction.EmoteCrossArmsState, + InputAction.Cry, + InputAction.EmoteCurtseyState, + InputAction.EmoteDrudgeDance, + InputAction.EmoteDrudgeDanceState, + InputAction.EmoteHaveASeat, + InputAction.EmoteHaveASeatState, + InputAction.EmoteHeartyLaugh, + InputAction.EmoteHelper, + InputAction.EmoteKneel, + InputAction.EmoteKneelState, + InputAction.EmoteKnock, + InputAction.Laugh, + InputAction.EmoteLeanState, + InputAction.EmoteMeditateState, + InputAction.EmoteMimeDrinking, + InputAction.EmoteMimeEating, + InputAction.EmoteMock, + InputAction.EmoteNod, + InputAction.EmoteNudgeLeft, + InputAction.EmoteNudgeRight, + InputAction.EmotePlead, + InputAction.EmotePleadState, + InputAction.EmotePoint, + InputAction.PointState, + InputAction.EmotePointDown, + InputAction.EmotePointDownState, + InputAction.EmotePointLeft, + InputAction.EmotePointLeftState, + InputAction.EmotePointRight, + InputAction.EmotePointRightState, + InputAction.EmotePossumState, + InputAction.EmotePray, + InputAction.EmotePrayState, + InputAction.EmoteReadState, + InputAction.EmoteSalute, + InputAction.EmoteSaluteState, + InputAction.EmoteScanHorizon, + InputAction.EmoteScratchHead, + InputAction.EmoteScratchHeadState, + InputAction.EmoteShakeFist, + InputAction.EmoteShakeFistState, + InputAction.EmoteShakeHead, + InputAction.EmoteShiver, + InputAction.EmoteShiverState, + InputAction.EmoteShoo, + InputAction.EmoteShrug, + InputAction.EmoteSitState, + InputAction.EmoteSitBackState, + InputAction.EmoteSitCrossleggedState, + InputAction.EmoteSlouch, + InputAction.EmoteSlouchState, + InputAction.EmoteSmackHead, + InputAction.EmoteSnowAngelState, + InputAction.EmoteSpit, + InputAction.EmoteSurrender, + InputAction.EmoteSurrenderState, + InputAction.EmoteTalkToTheHandState, + InputAction.EmoteTapFoot, + InputAction.EmoteTapFootState, + InputAction.EmoteTeapot, + InputAction.EmoteThinkerState, + InputAction.EmoteWarmHands, + InputAction.Wave, + InputAction.EmoteWaveState, + InputAction.EmoteWaveLow, + InputAction.EmoteWaveHigh, + InputAction.EmoteWinded, + InputAction.EmoteWindedState, + InputAction.EmoteWoah, + InputAction.EmoteWoahState, + InputAction.EmoteYawnAndStretch, + InputAction.EmoteYmca, + }; + for (int i = 0; i < emotes.Length; i++) + M(0x10000006, 0x10000098u + (uint)i, emotes[i]); - // ── ItemSelectionCommands (ctx 0x10000007) — 17/26. ──────────── + // ── ItemSelectionCommands (ctx 0x10000007) — 26/26. ──────────── + M(0x10000007, 0x1000002A, InputAction.SelectionSelf); + M(0x10000007, 0x1000002C, InputAction.SelectionPlaceInInventory); M(0x10000007, 0x1000002D, InputAction.SelectionSplitStack); M(0x10000007, 0x1000002E, InputAction.SelectionPreviousSelection); M(0x10000007, 0x1000002F, InputAction.SelectionClosestCompassItem); @@ -185,20 +337,44 @@ public static class RetailActionIdentityTable M(0x10000007, 0x1000003B, InputAction.SelectionNextPlayer); M(0x10000007, 0x1000003C, InputAction.SelectionPreviousFellow); M(0x10000007, 0x1000003D, InputAction.SelectionNextFellow); + M(0x10000007, 0x1000003E, InputAction.SelectionUseClosestUnopenedCorpse); + M(0x10000007, 0x1000003F, InputAction.SelectionUseNextUnopenedCorpse); + M(0x10000007, 0x10000040, InputAction.SelectionGiveToTarget); + M(0x10000007, 0x10000041, InputAction.SelectionDrop); + M(0x10000007, 0x1000011C, InputAction.SelectionPlaceInMainPack); + M(0x10000007, 0x10000121, InputAction.SelectionClosestUnopenedCorpse); + M(0x10000007, 0x10000122, InputAction.SelectionNextUnopenedCorpse); - // ── UICommands (ctx 0x10000009) — 22/42. ─────────────────────── + // ── UICommands (ctx 0x10000009) — 42/42. ─────────────────────── M(0x10000009, 0x55, InputAction.CaptureScreenshot); M(0x10000009, 0x7B, InputAction.ToggleHelp); M(0x10000009, 0x7C, InputAction.TogglePluginManager); + M(0x10000009, 0x10000003, InputAction.ToggleAbuseReportingPanel); + M(0x10000009, 0x10000005, InputAction.ToggleCharacterInfoPanel); + M(0x10000009, 0x10000006, InputAction.TogglePositiveMagicPanel); + M(0x10000009, 0x10000007, InputAction.ToggleNegativeMagicPanel); + M(0x10000009, 0x10000009, InputAction.ToggleLinkStatusPanel); + M(0x10000009, 0x1000000B, InputAction.ToggleUrgentAssistancePanel); + M(0x10000009, 0x1000000C, InputAction.ToggleVitaePanel); + M(0x10000009, 0x1000000D, InputAction.ToggleSocialPanel); M(0x10000009, 0x1000000E, InputAction.ToggleAllegiancePanel); M(0x10000009, 0x1000000F, InputAction.ToggleFellowshipPanel); + M(0x10000009, 0x10000010, InputAction.ToggleSpellManagementPanel); M(0x10000009, 0x10000011, InputAction.ToggleSpellbookPanel); M(0x10000009, 0x10000012, InputAction.ToggleSpellComponentsPanel); + M(0x10000009, 0x10000013, InputAction.ToggleCharacterDetailPanel); M(0x10000009, 0x10000014, InputAction.ToggleAttributesPanel); M(0x10000009, 0x10000015, InputAction.ToggleSkillsPanel); M(0x10000009, 0x10000016, InputAction.ToggleWorldPanel); + M(0x10000009, 0x10000017, InputAction.ToggleMapPage); + M(0x10000009, 0x10000018, InputAction.ToggleHousePage); M(0x10000009, 0x1000001A, InputAction.ToggleOptionsPanel); M(0x10000009, 0x10000019, InputAction.ToggleInventoryPanel); + M(0x10000009, 0x1000001B, InputAction.ToggleGameplayOptionsPage); + M(0x10000009, 0x1000001C, InputAction.ToggleCharacterSettingsPage); + M(0x10000009, 0x1000001D, InputAction.ToggleConfigurationPage); + M(0x10000009, 0x1000001E, InputAction.ToggleCompass); + M(0x10000009, 0x1000001F, InputAction.ToggleKeyboardConfiguration); M(0x10000009, 0x10000114, InputAction.ToggleFloatingChatWindow1); M(0x10000009, 0x10000115, InputAction.ToggleFloatingChatWindow2); M(0x10000009, 0x10000116, InputAction.ToggleFloatingChatWindow3); @@ -206,19 +382,24 @@ public static class RetailActionIdentityTable M(0x10000009, 0x10000025, InputAction.UseSelected); M(0x10000009, 0x10000026, InputAction.LOGOUT); M(0x10000009, 0x1000002B, InputAction.SelectionExamine); - // 0x1000001F ("Show/Hide Keyboard Configuration") deliberately left - // unmapped: it is the retail action that opens THIS screen - // (research doc §4.3/lane A §7 — wired directly by - // KeyboardConfigController's mount, not through InputAction). - - // ── ChatCommands (ctx 0x1000000A) — 1/6. ─────────────────────── + M(0x10000009, 0x10000118, InputAction.ToggleFriendsPage); + M(0x10000009, 0x1000011A, InputAction.ToggleCharacterTitlesPage); + M(0x10000009, 0x10000127, InputAction.ToggleQuestDetailPanel); + M(0x10000009, 0x10000128, InputAction.ToggleQuestJournalPage); + M(0x10000009, 0x10000129, InputAction.ToggleJournalPageList); + M(0x10000009, 0x1000012E, InputAction.ToggleContractsPage); + // ── ChatCommands (ctx 0x1000000A) — 6/6. ─────────────────────── + M(0x1000000A, 0x10000020, InputAction.ChatMonarchReply); + M(0x1000000A, 0x10000021, InputAction.ChatPatronReply); + M(0x1000000A, 0x10000022, InputAction.ChatReply); M(0x1000000A, 0x10000023, InputAction.EnterChatMode); + M(0x1000000A, 0x10000028, InputAction.ChatStartCommand); + M(0x1000000A, 0x10000119, InputAction.ChatTellToSelected); // ── ToggleChatEntry (ctx 0x1000000D) — 1/1. ──────────────────── M(0x1000000D, 0x10000024, InputAction.ToggleChatEntry); - // ── QuickslotCommands (ctx 0x1000000C) — 24/28 (Quickslot - // 10/11/12/13 have no InputAction — pre-existing enum gap). ── + // ── QuickslotCommands (ctx 0x1000000C) — 28/28. ──────────────── M(0x1000000C, 0x10000042, InputAction.UseQuickSlot_1); M(0x1000000C, 0x10000043, InputAction.UseQuickSlot_2); M(0x1000000C, 0x10000044, InputAction.UseQuickSlot_3); @@ -228,6 +409,9 @@ public static class RetailActionIdentityTable M(0x1000000C, 0x10000048, InputAction.UseQuickSlot_7); M(0x1000000C, 0x10000049, InputAction.UseQuickSlot_8); M(0x1000000C, 0x1000004A, InputAction.UseQuickSlot_9); + M(0x1000000C, 0x1000004B, InputAction.UseQuickSlot_10); + M(0x1000000C, 0x1000004C, InputAction.UseQuickSlot_11); + M(0x1000000C, 0x1000004D, InputAction.UseQuickSlot_12); M(0x1000000C, 0x1000004E, InputAction.SelectQuickSlot_1); M(0x1000000C, 0x1000004F, InputAction.SelectQuickSlot_2); M(0x1000000C, 0x10000050, InputAction.SelectQuickSlot_3); @@ -238,16 +422,62 @@ public static class RetailActionIdentityTable M(0x1000000C, 0x10000055, InputAction.SelectQuickSlot_8); M(0x1000000C, 0x10000056, InputAction.SelectQuickSlot_9); M(0x1000000C, 0x1000010D, InputAction.CreateShortcut); - // 0x10000132 ("Quickslot 13") has no InputAction — same pre-existing - // UseQuickSlot_10..13 enum gap as the bare-numeral block above. Unmapped. + M(0x1000000C, 0x10000132, InputAction.UseQuickSlot_13); M(0x1000000C, 0x10000133, InputAction.UseQuickSlot_14); M(0x1000000C, 0x10000134, InputAction.UseQuickSlot_15); M(0x1000000C, 0x10000135, InputAction.UseQuickSlot_16); M(0x1000000C, 0x10000136, InputAction.UseQuickSlot_17); M(0x1000000C, 0x10000137, InputAction.UseQuickSlot_18); - // CharacterSettings (ctx 0x10000008) is intentionally EMPTY here — - // see class doc "Known gaps deliberately left unmapped". + // ── CharacterSettings (ctx 0x10000008) — 48/48. ──────────────── + M(0x10000008, 0x10000071, InputAction.ToggleCharacterOptionAutoRepeatAttack); + M(0x10000008, 0x10000072, InputAction.ToggleCharacterOptionIgnoreAllegianceRequests); + M(0x10000008, 0x10000073, InputAction.ToggleCharacterOptionIgnoreFellowshipRequests); + M(0x10000008, 0x10000074, InputAction.ToggleCharacterOptionIgnoreTradeRequests); + M(0x10000008, 0x10000076, InputAction.ToggleCharacterOptionPersistentAtDay); + M(0x10000008, 0x10000077, InputAction.ToggleCharacterOptionAllowGive); + M(0x10000008, 0x10000078, InputAction.ToggleCharacterOptionViewCombatTarget); + M(0x10000008, 0x10000079, InputAction.ToggleCharacterOptionShowTooltips); + M(0x10000008, 0x1000007A, InputAction.ToggleCharacterOptionUseDeception); + M(0x10000008, 0x1000007B, InputAction.ToggleCharacterOptionToggleRun); + M(0x10000008, 0x1000007C, InputAction.ToggleCharacterOptionStayInChatMode); + M(0x10000008, 0x1000007D, InputAction.ToggleCharacterOptionAdvancedCombatUi); + M(0x10000008, 0x1000007E, InputAction.ToggleCharacterOptionAutoTarget); + M(0x10000008, 0x1000007F, InputAction.ToggleCharacterOptionVividTargetingIndicator); + M(0x10000008, 0x10000080, InputAction.ToggleCharacterOptionFellowshipShareXp); + M(0x10000008, 0x10000081, InputAction.ToggleCharacterOptionAcceptLootPermits); + M(0x10000008, 0x10000082, InputAction.ToggleCharacterOptionFellowshipShareLoot); + M(0x10000008, 0x10000083, InputAction.ToggleCharacterOptionFellowshipAutoAcceptRequests); + M(0x10000008, 0x10000085, InputAction.ToggleCharacterOptionCoordinatesOnRadar); + M(0x10000008, 0x10000086, InputAction.ToggleCharacterOptionSpellDuration); + M(0x10000008, 0x10000087, InputAction.ToggleCharacterOptionDisableHouseRestrictionEffects); + M(0x10000008, 0x10000088, InputAction.ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade); + M(0x10000008, 0x10000089, InputAction.ToggleCharacterOptionDisplayAllegianceLogonNotifications); + M(0x10000008, 0x1000008A, InputAction.ToggleCharacterOptionUseChargeAttack); + M(0x10000008, 0x1000008B, InputAction.ToggleCharacterOptionUseCraftSuccessDialog); + M(0x10000008, 0x1000008C, InputAction.ToggleCharacterOptionListenToAllegianceChat); + M(0x10000008, 0x1000008D, InputAction.ToggleCharacterOptionDisplayDateOfBirth); + M(0x10000008, 0x1000008E, InputAction.ToggleCharacterOptionDisplayAge); + M(0x10000008, 0x1000008F, InputAction.ToggleCharacterOptionDisplayChessRank); + M(0x10000008, 0x10000090, InputAction.ToggleCharacterOptionDisplayFishingSkill); + M(0x10000008, 0x10000091, InputAction.ToggleCharacterOptionDisplayNumberDeaths); + M(0x10000008, 0x10000092, InputAction.ToggleCharacterOptionDisplayTimeStamps); + M(0x10000008, 0x10000093, InputAction.ToggleCharacterOptionSalvageMultiple); + M(0x10000008, 0x1000010E, InputAction.ToggleCharacterOptionListenToGeneralChat); + M(0x10000008, 0x1000010F, InputAction.ToggleCharacterOptionListenToTradeChat); + M(0x10000008, 0x10000110, InputAction.ToggleCharacterOptionListenToLfgChat); + M(0x10000008, 0x10000112, InputAction.ToggleCharacterOptionListenToRoleplayChat); + M(0x10000008, 0x1000011B, InputAction.ToggleCharacterOptionDisplayNumberCharacterTitles); + M(0x10000008, 0x1000011D, InputAction.ToggleCharacterOptionMainPackPreferred); + M(0x10000008, 0x1000011E, InputAction.ToggleCharacterOptionLeadMissileTargets); + M(0x10000008, 0x1000011F, InputAction.ToggleCharacterOptionUseFastMissiles); + M(0x10000008, 0x10000120, InputAction.ToggleCharacterOptionFilterLanguage); + M(0x10000008, 0x10000123, InputAction.ToggleCharacterOptionConfirmVolatileRareUse); + M(0x10000008, 0x10000125, InputAction.ToggleCharacterOptionListenToSocietyChat); + M(0x10000008, 0x1000012A, InputAction.ToggleCharacterOptionShowHelm); + M(0x10000008, 0x1000012C, InputAction.ToggleCharacterOptionDisableDistanceFog); + M(0x10000008, 0x1000012F, InputAction.ToggleCharacterOptionShowCloak); + M(0x10000008, 0x1000013E, InputAction.ToggleCharacterOptionSideBySideVitals); return t; } diff --git a/src/AcDream.UI.Abstractions/Input/RetailScanCodeMap.cs b/src/AcDream.UI.Abstractions/Input/RetailScanCodeMap.cs index caa6188e..ce0ae50e 100644 --- a/src/AcDream.UI.Abstractions/Input/RetailScanCodeMap.cs +++ b/src/AcDream.UI.Abstractions/Input/RetailScanCodeMap.cs @@ -13,17 +13,17 @@ namespace AcDream.UI.Abstractions.Input; /// enum. /// /// -/// The scan-code table covers exactly the 84 distinct DIK codes that appear across -/// the DAT's 306 user-bindable ActionMap rows' default bindings (2026-08-11 probe — -/// see RetailActionMap.cs's class doc), cross-checked against +/// The scan-code table covers the 84 distinct DIK codes that appear across the +/// DAT's 306 user-bindable ActionMap rows' default bindings (2026-08-11 probe), +/// plus the remaining keyboard controls accepted by retail's plain-text keymap +/// interchange. The default set was cross-checked against /// tools/dump-keymap/Program.cs's own Dik(uint) transcription (itself /// verified against acclient_2013_pseudo_c.txt's /// ControlNameMapper::AddKeySemantic calls) and against /// 's existing chords, which already encode /// the same standard US-layout DirectInput scan codes by construction (both were -/// authored from the same retail-default.keymap.txt). Codes outside this set -/// (rare/debug/joystick bindings never seen with a non-empty default in the shipped -/// DAT) intentionally return null rather than guess. +/// authored from the same retail-default.keymap.txt). Unsupported joystick +/// controls intentionally return null rather than guess. /// /// public static class RetailScanCodeMap @@ -94,6 +94,7 @@ public static class RetailScanCodeMap 0x1A => Key.LeftBracket, 0x1B => Key.RightBracket, 0x1C => Key.Enter, + 0x1D => Key.ControlLeft, 0x1E => Key.A, 0x1F => Key.S, 0x20 => Key.D, @@ -120,7 +121,9 @@ public static class RetailScanCodeMap 0x35 => Key.Slash, 0x36 => Key.ShiftRight, 0x37 => Key.KeypadMultiply, + 0x38 => Key.AltLeft, 0x39 => Key.Space, + 0x3A => Key.CapsLock, 0x3B => Key.F1, 0x3C => Key.F2, 0x3D => Key.F3, @@ -148,9 +151,15 @@ public static class RetailScanCodeMap 0x53 => Key.KeypadDecimal, 0x57 => Key.F11, 0x58 => Key.F12, + 0x64 => Key.F13, + 0x65 => Key.F14, + 0x66 => Key.F15, 0x9C => Key.KeypadEnter, 0x9D => Key.ControlRight, 0xB5 => Key.KeypadDivide, + 0xB7 => Key.PrintScreen, + 0xB8 => Key.AltRight, + 0xC5 => Key.Pause, 0xC7 => Key.Home, 0xC8 => Key.Up, 0xC9 => Key.PageUp, @@ -161,7 +170,163 @@ public static class RetailScanCodeMap 0xD1 => Key.PageDown, 0xD2 => Key.Insert, 0xD3 => Key.Delete, + 0xDB => Key.SuperLeft, + 0xDC => Key.SuperRight, + 0xDD => Key.Menu, _ => null, }; } + + /// + /// Retail's plain-text .keymap control semantic to the same device / + /// scan-code pair consumed by . The legacy file + /// uses a few historical aliases (UPARROW, PGUP, + /// NUMPADSTAR, ...), so parsing accepts both those spellings and the + /// canonical DirectInput spellings emitted by . + /// + public static bool TryFromFileControl( + string control, + out uint scan, + out uint device) + { + scan = 0u; + device = 0u; + if (string.IsNullOrWhiteSpace(control)) + return false; + + string token = control.Trim().ToUpperInvariant(); + if (token.StartsWith("DIMOFS_BUTTON", StringComparison.Ordinal) + && int.TryParse(token["DIMOFS_BUTTON".Length..], out int button) + && button is >= 0 and <= 4) + { + scan = (uint)(0x0C + button); + device = 1u; + return true; + } + + if (!token.StartsWith("DIK_", StringComparison.Ordinal)) + return false; + token = token[4..]; + scan = token switch + { + "ESCAPE" => 0x01, + "1" => 0x02, "2" => 0x03, "3" => 0x04, "4" => 0x05, + "5" => 0x06, "6" => 0x07, "7" => 0x08, "8" => 0x09, + "9" => 0x0A, "0" => 0x0B, + "MINUS" => 0x0C, "EQUALS" => 0x0D, "BACK" => 0x0E, + "TAB" => 0x0F, + "Q" => 0x10, "W" => 0x11, "E" => 0x12, "R" => 0x13, + "T" => 0x14, "Y" => 0x15, "U" => 0x16, "I" => 0x17, + "O" => 0x18, "P" => 0x19, + "LBRACKET" => 0x1A, "RBRACKET" => 0x1B, "RETURN" => 0x1C, + "LCONTROL" => 0x1D, + "A" => 0x1E, "S" => 0x1F, "D" => 0x20, "F" => 0x21, + "G" => 0x22, "H" => 0x23, "J" => 0x24, "K" => 0x25, + "L" => 0x26, "SEMICOLON" => 0x27, "APOSTROPHE" => 0x28, + "GRAVE" => 0x29, "LSHIFT" => 0x2A, "BACKSLASH" => 0x2B, + "Z" => 0x2C, "X" => 0x2D, "C" => 0x2E, "V" => 0x2F, + "B" => 0x30, "N" => 0x31, "M" => 0x32, + "COMMA" => 0x33, "PERIOD" => 0x34, "SLASH" => 0x35, + "RSHIFT" => 0x36, "MULTIPLY" or "NUMPADSTAR" => 0x37, + "LMENU" or "LALT" => 0x38, "SPACE" => 0x39, "CAPITAL" => 0x3A, + "F1" => 0x3B, "F2" => 0x3C, "F3" => 0x3D, "F4" => 0x3E, + "F5" => 0x3F, "F6" => 0x40, "F7" => 0x41, "F8" => 0x42, + "F9" => 0x43, "F10" => 0x44, "NUMLOCK" => 0x45, + "SCROLL" => 0x46, "NUMPAD7" => 0x47, "NUMPAD8" => 0x48, + "NUMPAD9" => 0x49, "SUBTRACT" or "NUMPADMINUS" => 0x4A, + "NUMPAD4" => 0x4B, "NUMPAD5" => 0x4C, "NUMPAD6" => 0x4D, + "ADD" or "NUMPADPLUS" => 0x4E, "NUMPAD1" => 0x4F, + "NUMPAD2" => 0x50, "NUMPAD3" => 0x51, "NUMPAD0" => 0x52, + "DECIMAL" or "NUMPADPERIOD" => 0x53, + "F11" => 0x57, "F12" => 0x58, "F13" => 0x64, + "F14" => 0x65, "F15" => 0x66, "NUMPADENTER" => 0x9C, + "RCONTROL" => 0x9D, "DIVIDE" or "NUMPADSLASH" => 0xB5, + "SYSRQ" => 0xB7, "RMENU" or "RALT" => 0xB8, + "PAUSE" => 0xC5, "HOME" => 0xC7, + "UP" or "UPARROW" => 0xC8, "PRIOR" or "PGUP" => 0xC9, + "LEFT" => 0xCB, "RIGHT" or "RIGHTARROW" => 0xCD, + "END" => 0xCF, "DOWN" or "DOWNARROW" => 0xD0, + "NEXT" or "PGDN" => 0xD1, "INSERT" => 0xD2, + "DELETE" => 0xD3, "LWIN" => 0xDB, "RWIN" => 0xDC, + "APPS" => 0xDD, + _ => uint.MaxValue, + }; + return scan != uint.MaxValue; + } + + /// Converts an acdream chord to retail's plain-text control + /// semantic. Returns false for controls the 2013 DirectInput keymap cannot + /// represent (for example joystick axes). + public static bool TryToFileControl(KeyChord chord, out string control) + { + control = string.Empty; + if (chord.Device == 1) + { + int button = (int)chord.Key switch + { + -1001 => 0, + -1002 => 1, + -1003 => 2, + -1004 => 3, + -1005 => 4, + _ => -1, + }; + if (button < 0) return false; + control = $"DIMOFS_BUTTON{button}"; + return true; + } + if (chord.Device != 0) return false; + + for (uint scan = 1; scan <= 0xDD; scan++) + { + if (ToSilkKey(scan, 0) != chord.Key) continue; + control = scan switch + { + 0x37 => "DIK_NUMPADSTAR", + 0x4A => "DIK_NUMPADMINUS", + 0x4E => "DIK_NUMPADPLUS", + 0xB5 => "DIK_NUMPADSLASH", + 0xC8 => "DIK_UPARROW", + 0xC9 => "DIK_PGUP", + 0xCD => "DIK_RIGHTARROW", + 0xD0 => "DIK_DOWNARROW", + 0xD1 => "DIK_PGDN", + _ => FileToken(scan), + }; + return control.Length != 0; + } + return false; + } + + private static string FileToken(uint scan) => scan switch + { + 0x01 => "DIK_ESCAPE", + >= 0x02 and <= 0x0A => $"DIK_{scan - 1}", + 0x0B => "DIK_0", 0x0C => "DIK_MINUS", 0x0D => "DIK_EQUALS", + 0x0E => "DIK_BACK", 0x0F => "DIK_TAB", + >= 0x10 and <= 0x19 => $"DIK_{"QWERTYUIOP"[(int)(scan - 0x10)]}", + 0x1A => "DIK_LBRACKET", 0x1B => "DIK_RBRACKET", + 0x1C => "DIK_RETURN", 0x1D => "DIK_LCONTROL", + >= 0x1E and <= 0x26 => $"DIK_{"ASDFGHJKL"[(int)(scan - 0x1E)]}", + 0x27 => "DIK_SEMICOLON", 0x28 => "DIK_APOSTROPHE", + 0x29 => "DIK_GRAVE", 0x2A => "DIK_LSHIFT", 0x2B => "DIK_BACKSLASH", + >= 0x2C and <= 0x32 => $"DIK_{"ZXCVBNM"[(int)(scan - 0x2C)]}", + 0x33 => "DIK_COMMA", 0x34 => "DIK_PERIOD", 0x35 => "DIK_SLASH", + 0x36 => "DIK_RSHIFT", 0x38 => "DIK_LMENU", 0x39 => "DIK_SPACE", + 0x3A => "DIK_CAPITAL", + >= 0x3B and <= 0x44 => $"DIK_F{scan - 0x3A}", + 0x45 => "DIK_NUMLOCK", 0x46 => "DIK_SCROLL", + 0x47 => "DIK_NUMPAD7", 0x48 => "DIK_NUMPAD8", 0x49 => "DIK_NUMPAD9", + 0x4B => "DIK_NUMPAD4", 0x4C => "DIK_NUMPAD5", 0x4D => "DIK_NUMPAD6", + 0x4F => "DIK_NUMPAD1", 0x50 => "DIK_NUMPAD2", 0x51 => "DIK_NUMPAD3", + 0x52 => "DIK_NUMPAD0", 0x53 => "DIK_DECIMAL", + 0x57 => "DIK_F11", 0x58 => "DIK_F12", 0x64 => "DIK_F13", + 0x65 => "DIK_F14", 0x66 => "DIK_F15", 0x9C => "DIK_NUMPADENTER", + 0x9D => "DIK_RCONTROL", 0xB7 => "DIK_SYSRQ", 0xB8 => "DIK_RALT", + 0xC5 => "DIK_PAUSE", 0xC7 => "DIK_HOME", + 0xCB => "DIK_LEFT", 0xCF => "DIK_END", 0xD2 => "DIK_INSERT", + 0xD3 => "DIK_DELETE", 0xDB => "DIK_LWIN", 0xDC => "DIK_RWIN", + 0xDD => "DIK_APPS", + _ => string.Empty, + }; } diff --git a/src/AcDream.UI.Abstractions/Input/RetailUnmappedKeyBindings.cs b/src/AcDream.UI.Abstractions/Input/RetailUnmappedKeyBindings.cs index 37702866..242783c1 100644 --- a/src/AcDream.UI.Abstractions/Input/RetailUnmappedKeyBindings.cs +++ b/src/AcDream.UI.Abstractions/Input/RetailUnmappedKeyBindings.cs @@ -6,15 +6,13 @@ using System.Text.Json; namespace AcDream.UI.Abstractions.Input; /// -/// Campaign OP slice OP8: persisted bindings for DAT ActionMap rows that -/// RetailActionIdentityTable has no for — -/// mostly Emotes and CharacterSettings hotkeys (see that table's class doc for -/// the full accounting). These rows still render, bind, conflict-check, and -/// persist on the Configure Keyboard screen exactly like a mapped row; they -/// just have no live gameplay consumer to dispatch through yet, so they live in -/// their own small store rather than 's -/// -keyed schema. Sibling file next to -/// keybinds.json (D4 — no .keymap file interchange). +/// Forward-compatible persisted bindings for an ActionMap row introduced by a +/// future DAT revision. Campaign KB maps every one of the 306 rows in the +/// supported Sept-2013 EoR DAT, so this sibling file has no production entries +/// there; it only keeps an unknown future row visible and round-trippable +/// instead of crashing an older client. The compatibility sibling file stays +/// beside keybinds.json; installed-retail rows use the canonical +/// *.keymap profile instead. /// public sealed class RetailUnmappedKeyBindings { diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index ee476db5..0d875761 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -59,6 +59,12 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback public string? LastOutgoingTellTarget => _commandTargets.LastOutgoingTellTarget; + public string? LastMonarchSender => + _commandTargets.LastMonarchSender; + + public string? LastPatronSender => + _commandTargets.LastPatronSender; + /// /// Optional callback exposing the live framerate. Wired by /// GameWindow at construction so the client-side diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index 5494d766..5c6fbbec 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -61,6 +61,33 @@ public sealed class WorldLifecycleAutomationControllerTests } } + [Fact] + public void RetailScreenshotRequest_UsesFirstFreeScreenShotNumber() + { + string directory = NewDirectory(); + Directory.CreateDirectory(directory); + File.WriteAllBytes(Path.Combine(directory, "ScreenShot00000.png"), [0]); + var controller = new FrameScreenshotController( + (_, _) => [255, 255, 255, 255], + directory); + + try + { + Assert.True(controller.TryRequestRetailScreenshot( + out string path, + out string error), error); + Assert.Equal( + Path.Combine(directory, "ScreenShot00001.png"), + path); + Assert.True(controller.CapturePending(1, 1)); + Assert.True(File.Exists(path)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [Fact] public void ScreenshotCapture_ReportsNoWorkAndFailedCapture() { diff --git a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs index 5e8ac21c..bb2bd06f 100644 --- a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.App.Input; using AcDream.App.Rendering; +using AcDream.Core.Rendering; using AcDream.UI.Abstractions.Input; using Silk.NET.Input; @@ -175,31 +176,63 @@ public sealed class CameraPointerInputControllerTests Assert.Equal(flyBefore * 1.2f, fixture.Owner.ActiveSensitivity, 5); } + [Fact] + public void ChaseMouseWheel_RetainsExtendedZoomOutRange() + { + var fixture = Create([new RawSurface()]); + var legacy = new ChaseCamera(); + var retail = new RetailChaseCamera(); + fixture.Mode.IsPlayerMode = true; + fixture.Chase.Legacy = legacy; + fixture.Chase.Retail = retail; + fixture.Camera.EnterChaseMode(legacy, retail); + + float before = CameraDiagnostics.UseRetailChaseCamera + ? retail.Distance + : legacy.Distance; + fixture.Owner.HandleScroll(InputAction.ScrollDown); + float afterOne = CameraDiagnostics.UseRetailChaseCamera + ? retail.Distance + : legacy.Distance; + for (int i = 0; i < 100; i++) + fixture.Owner.HandleScroll(InputAction.ScrollDown); + float afterMany = CameraDiagnostics.UseRetailChaseCamera + ? retail.Distance + : legacy.Distance; + + Assert.Equal(before + 0.8f, afterOne, 5); + Assert.Equal(40f, afterMany, 5); + } + private static Fixture Create(IReadOnlyList surfaces) { var camera = new CameraController(new OrbitCamera(), new FlyCamera()); var capture = new Capture(); var mouse = new Mouse(); var cursor = new Cursor(); + var mode = new LocalPlayerModeState(); + var chase = new ChaseCameraInputState(); var owner = new CameraPointerInputController( surfaces, cursor, new HostQuiescenceGate(), capture, - new LocalPlayerModeState(), + mode, camera, - new ChaseCameraInputState(), + chase, mouse, new PointerPositionState(), new Clock()); - return new Fixture(owner, camera, capture, cursor); + return new Fixture(owner, camera, capture, cursor, mode, chase); } private sealed record Fixture( CameraPointerInputController Owner, CameraController Camera, Capture Capture, - Cursor Cursor); + Cursor Cursor, + LocalPlayerModeState Mode, + ChaseCameraInputState Chase); private sealed class RawSurface : IRawPointerSurface { @@ -282,6 +315,7 @@ public sealed class CameraPointerInputControllerTests { public void Tick() { } public void HandleMovementInput(InputAction action, ActivationType activation) { } + public void AbortAutomaticAttack() { } public bool HandleInputAction(InputAction action, ActivationType activation) => false; } } diff --git a/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs index 0d591f13..7868db80 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs @@ -1,6 +1,7 @@ using AcDream.App.Input; using AcDream.App.Rendering; using AcDream.Core.Combat; +using AcDream.Runtime; using AcDream.UI.Abstractions.Input; namespace AcDream.App.Tests.Input; @@ -11,9 +12,10 @@ public sealed class GameplayInputActionRouterTests [InlineData("pointer", "pointer")] [InlineData("combat", "pointer,combat")] [InlineData("retained", "pointer,combat,retained")] - [InlineData("selection", "pointer,combat,retained,selection")] - [InlineData("movement", "pointer,combat,retained,selection,movement")] - [InlineData("command", "pointer,combat,retained,selection,movement,command")] + [InlineData("character-option", "pointer,combat,retained,character-option")] + [InlineData("selection", "pointer,combat,retained,character-option,selection")] + [InlineData("movement", "pointer,combat,retained,character-option,selection,movement")] + [InlineData("command", "pointer,combat,retained,character-option,selection,movement,command")] public void Press_PreservesFrozenPriorityAndStopsAtConsumer( string consumeAt, string expectedCsv) @@ -66,7 +68,7 @@ public sealed class GameplayInputActionRouterTests ActivationType.DoubleClick); Assert.Equal( - ["pointer", "combat", "retained", "selection", "movement", "command"], + ["pointer", "combat", "retained", "character-option", "selection", "movement", "command"], harness.Targets.Calls); } @@ -81,7 +83,7 @@ public sealed class GameplayInputActionRouterTests ActivationType.Click); Assert.Equal( - ["pointer", "combat", "retained", "selection"], + ["pointer", "combat", "retained", "character-option", "selection"], harness.Targets.Calls); } @@ -100,6 +102,60 @@ public sealed class GameplayInputActionRouterTests harness.Actions.Scopes); } + [Theory] + [InlineData(InputAction.Ready, RuntimeMovementCommand.Ready)] + [InlineData(InputAction.Sitting, RuntimeMovementCommand.Sit)] + [InlineData(InputAction.Crouch, RuntimeMovementCommand.Crouch)] + [InlineData(InputAction.Sleeping, RuntimeMovementCommand.Sleep)] + public void RetailPostureKeys_MapToCanonicalRuntimeCommands( + InputAction action, + RuntimeMovementCommand expected) + { + Assert.Equal( + expected, + RuntimeGameplayInputPriorityTargets.ResolvePressedMovementCommand(action)); + } + + [Fact] + public void EscapeMovementRung_PreservesRetailPriority() + { + var charging = new AcDream.Runtime.Gameplay.JumpChargeSnapshot( + IsCharging: true, + Power: 0.5f); + var repeat = new RuntimeCombatAttackSnapshot( + 0, + AttackHeight.Medium, + 0f, + 0f, + false, + false, + 0f, + RepeatAttackInProgress: true); + + Assert.Equal( + RuntimeMovementCommand.FinishJump, + RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand( + InputAction.EscapeKey, + isStandingStill: false, + charging, + repeat)); + + Assert.Equal( + RuntimeMovementCommand.StopCompletely, + RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand( + InputAction.EscapeKey, + isStandingStill: false, + jumpCharge: default, + repeat)); + + Assert.Null( + RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand( + InputAction.EscapeKey, + isStandingStill: true, + jumpCharge: default, + repeat with { RepeatAttackInProgress = false })); + } + [Theory] [InlineData(0, "remove-actions")] [InlineData(1, "remove-combat,remove-actions")] @@ -277,6 +333,9 @@ public sealed class GameplayInputActionRouterTests public void SetCombatScope(InputScope? scope) => Scopes.Add(scope); + public void SetCameraAlternateScope(bool active) => + calls.Add($"camera-scope:{active}"); + public void Raise(InputAction action, ActivationType activation) => Callback?.Invoke(action, activation); } @@ -326,6 +385,9 @@ public sealed class GameplayInputActionRouterTests public bool HandleRetainedUiAction(InputAction action) => Record("retained"); + public bool HandleCharacterOptionAction(InputAction action) => + Record("character-option"); + public bool HandleSelectionAction(InputAction action) => Record("selection"); diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs index c0cfce5e..2ace75a5 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs @@ -23,6 +23,9 @@ public sealed class GameplayInputCommandControllerTests [InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")] [InlineData(InputAction.ToggleFloatingChatWindow3, "chat-window-3")] [InlineData(InputAction.ToggleFloatingChatWindow4, "chat-window-4")] + [InlineData(InputAction.ToggleChatEntry, "focus-chat")] + [InlineData(InputAction.EnterChatMode, "focus-chat")] + [InlineData(InputAction.LOGOUT, "logout")] public void RecognizedCommand_RoutesToTypedOwner( InputAction action, string expected) @@ -35,21 +38,12 @@ public sealed class GameplayInputCommandControllerTests Assert.Equal([expected], harness.Calls); } - // OP9: AcdreamToggleDebugPanel/ToggleChatEntry retired the - // IDevToolsGameplayCommands seam they used to forward to — both - // targets (the ImGui-era DebugPanel/ChatPanel) were already gone - // (Campaign V slice V11), so the seam's own body was an unconditional - // no-op. The action is still consumed (handled == true, matching the - // prior no-op's contract) but claims no typed-owner call. - [Theory] - [InlineData(InputAction.AcdreamToggleDebugPanel)] - [InlineData(InputAction.ToggleChatEntry)] - public void RetiredDevToolsCommand_IsConsumedWithoutClaimingATypedOwner( - InputAction action) + [Fact] + public void RetiredDebugPanelCommand_IsConsumedWithoutClaimingATypedOwner() { var harness = new Harness(); - bool handled = harness.Controller.Handle(action); + bool handled = harness.Controller.Handle(InputAction.AcdreamToggleDebugPanel); Assert.True(handled); Assert.Empty(harness.Calls); @@ -80,8 +74,9 @@ public sealed class GameplayInputCommandControllerTests } /// - /// Escape's priority chain: cancel a target mode, else leave player mode, - /// else close a window. + /// Escape's command-tier priority: cancel a target mode, otherwise toggle + /// retail's Gameplay Options page. It must never expose the developer/fly + /// camera or close the game window. /// /// /// The free-fly rung was REMOVED (2026-08-21, user direction: free-fly @@ -90,21 +85,15 @@ public sealed class GameplayInputCommandControllerTests /// rather than silently exiting a camera the player has no way to enter. /// [Theory] - [InlineData(true, true, true, "cancel-target")] - [InlineData(false, true, true, "exit-player")] - [InlineData(false, false, true, "exit-player")] - [InlineData(false, false, false, "close")] - public void Escape_PreservesTargetPlayerWindowPriority( + [InlineData(true, "cancel-target")] + [InlineData(false, "gameplay-options")] + public void Escape_PreservesRetailTargetThenGameplayOptionsPriority( bool targetMode, - bool flyMode, - bool playerMode, string expected) { var harness = new Harness { TargetMode = { IsActive = targetMode }, - Camera = { IsFly = flyMode }, - Player = { IsPlayer = playerMode }, }; bool handled = harness.Controller.Handle(InputAction.EscapeKey); @@ -121,19 +110,15 @@ public sealed class GameplayInputCommandControllerTests Diagnostics = new FakeDiagnostics(Calls); Player = new FakePlayerMode(Calls); TargetMode = new FakeTargetMode(Calls); - Camera = new FakeCamera(Calls); Combat = new FakeCombat(Calls); Runtime = new FakeRuntimeView(); - Window = new FakeWindow(Calls); Controller = new GameplayInputCommandController( Retained, Diagnostics, Player, TargetMode, - Camera, Runtime, - Combat, - Window); + Combat); } public List Calls { get; } = []; @@ -141,10 +126,8 @@ public sealed class GameplayInputCommandControllerTests public FakeDiagnostics Diagnostics { get; } public FakePlayerMode Player { get; } public FakeTargetMode TargetMode { get; } - public FakeCamera Camera { get; } public FakeCombat Combat { get; } public FakeRuntimeView Runtime { get; } - public FakeWindow Window { get; } public GameplayInputCommandController Controller { get; } } @@ -157,6 +140,12 @@ public sealed class GameplayInputCommandControllerTests calls.Add($"chat-window-{windowId}"); public void ToggleOptionsPanel() => calls.Add("options"); + + public void ToggleGameplayOptionsPage() => calls.Add("gameplay-options"); + + public void FocusChatEntry() => calls.Add("focus-chat"); + + public void LogOutCharacter() => calls.Add("logout"); } private sealed class FakeDiagnostics(List calls) @@ -180,11 +169,8 @@ public sealed class GameplayInputCommandControllerTests private sealed class FakePlayerMode(List calls) : IPlayerModeGameplayCommands { - public bool IsPlayer { get; set; } - public bool IsPlayerMode => IsPlayer; public void ToggleFlyOrChase() => calls.Add("fly-or-chase"); public void TogglePlayerMode() => calls.Add("player-mode"); - public void ExitPlayerMode() => calls.Add("exit-player"); } private sealed class FakeTargetMode(List calls) @@ -195,14 +181,6 @@ public sealed class GameplayInputCommandControllerTests public void CancelTargetMode() => calls.Add("cancel-target"); } - private sealed class FakeCamera(List calls) - : IGameplayCameraModeCommands - { - public bool IsFly { get; set; } - public bool IsFlyMode => IsFly; - public void ExitFlyMode() => calls.Add("exit-fly"); - } - private sealed class FakeCombat(List calls) : IRuntimeCombatCommands { public RuntimeCommandResult Execute( @@ -248,8 +226,4 @@ public sealed class GameplayInputCommandControllerTests throw new NotSupportedException(); } - private sealed class FakeWindow(List calls) : IGameplayWindowCommands - { - public void Close() => calls.Add("close"); - } } diff --git a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs index 0389db2b..e71cb26f 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs @@ -127,6 +127,7 @@ public sealed class GameplayInputFrameControllerTests public void Tick() => _calls.Add("combat"); public void HandleMovementInput(InputAction action, ActivationType activation) => _calls.Add("combat-movement"); + public void AbortAutomaticAttack() => _calls.Add("combat-abort"); public bool HandleInputAction(InputAction action, ActivationType activation) { _calls.Add("combat-action"); diff --git a/tests/AcDream.App.Tests/Input/RetailEmoteMotionTableTests.cs b/tests/AcDream.App.Tests/Input/RetailEmoteMotionTableTests.cs new file mode 100644 index 00000000..3c04baf7 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/RetailEmoteMotionTableTests.cs @@ -0,0 +1,45 @@ +using AcDream.App.Input; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class RetailEmoteMotionTableTests +{ + [Fact] + public void EveryRetailEmoteActionHasExactMotionConsumer() + { + InputAction[] emotes = RetailActionIdentityTable.Map + .Where(entry => entry.Key.InputMapId == 0x10000006u) + .OrderBy(entry => entry.Key.ActionId) + .Select(entry => entry.Value) + .ToArray(); + + Assert.Equal(87, RetailEmoteMotionTable.Count); + Assert.Equal(87, emotes.Length); + foreach (InputAction emote in emotes) + Assert.True(RetailEmoteMotionTable.TryGetMotion(emote, out _)); + } + + [Theory] + [InlineData(InputAction.EmoteAfkState, 0x43000118u)] + [InlineData(InputAction.Cheer, 0x1300004Cu)] + [InlineData(InputAction.Cry, 0x1300007Fu)] + [InlineData(InputAction.Laugh, 0x13000080u)] + [InlineData(InputAction.PointState, 0x430000F0u)] + [InlineData(InputAction.Wave, 0x13000087u)] + [InlineData(InputAction.EmoteYmca, 0x1200009Bu)] + public void RepresentativeActionsMatchNamedRetailGlobals( + InputAction action, + uint expectedMotion) + { + Assert.True(RetailEmoteMotionTable.TryGetMotion(action, out uint motion)); + Assert.Equal(expectedMotion, motion); + } + + [Theory] + [InlineData(InputAction.Ready)] + [InlineData(InputAction.ToggleOptionsPanel)] + [InlineData(InputAction.UseQuickSlot_1)] + public void NonEmoteActionsAreRejected(InputAction action) => + Assert.False(RetailEmoteMotionTable.TryGetMotion(action, out _)); +} diff --git a/tests/AcDream.App.Tests/Input/RetailKeymapFileTests.cs b/tests/AcDream.App.Tests/Input/RetailKeymapFileTests.cs new file mode 100644 index 00000000..39b29b88 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/RetailKeymapFileTests.cs @@ -0,0 +1,113 @@ +using AcDream.App.Input; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class RetailKeymapFileTests +{ + [Fact] + public void Parse_CommittedRetailFile_ReproducesEveryUserBindableDefault() + { + string text = File.ReadAllText(Path.Combine( + FindRepoRoot(), "docs", "research", "named-retail", + "retail-default.keymap.txt")); + KeyBindings expected = KeyBindings.RetailDefaults(); + KeyBindings actual = RetailKeymapFile.Parse(text, expected); + + Assert.Equal( + expected.ForAction(InputAction.MovementForward).ToArray(), + actual.ForAction(InputAction.MovementForward).ToArray()); + Assert.Equal( + expected.ForAction(InputAction.MovementWalkMode).ToArray(), + actual.ForAction(InputAction.MovementWalkMode).ToArray()); + Assert.Equal( + expected.ForAction(InputAction.ToggleInventoryPanel).ToArray(), + actual.ForAction(InputAction.ToggleInventoryPanel).ToArray()); + Assert.Equal( + expected.ForAction(InputAction.CameraAlternateRotateLeft).ToArray(), + actual.ForAction(InputAction.CameraAlternateRotateLeft).ToArray()); + // The user's captured retail file omits slots 10-13; omission means + // unbound rather than "inherit a compiled default". + Assert.Empty(actual.ForAction(InputAction.UseQuickSlot_10)); + + // Host-only actions are outside retail's fourteen editable maps and survive. + Assert.Equal( + expected.ForAction(InputAction.AcdreamToggleAudioMute).ToArray(), + actual.ForAction(InputAction.AcdreamToggleAudioMute).ToArray()); + } + + [Fact] + public void WriteThenParse_RoundTripsAll306RetailActionIdentities() + { + var source = new KeyBindings(); + foreach (((uint inputMapId, uint actionId), InputAction action) in + RetailActionIdentityTable.Map) + { + source.Add(new Binding( + new KeyChord( + Key.SuperRight, + ModifierMask.Shift | ModifierMask.Ctrl | ModifierMask.Win), + action, + RetailActionIdentityTable.ActivationFor(inputMapId, actionId), + RetailActionIdentityTable.ScopeForInputMap(inputMapId))); + } + + string text = RetailKeymapFile.Write(source); + KeyBindings loaded = RetailKeymapFile.Parse(text, new KeyBindings()); + + Assert.Equal(306, loaded.All.Count); + foreach (Binding expected in source.All) + Assert.Contains(expected, loaded.All); + Assert.Contains("CharacterOptionCommands", text, StringComparison.Ordinal); + Assert.Contains("AutoRepeatAttacks", text, StringComparison.Ordinal); + Assert.Contains("AFKState", text, StringComparison.Ordinal); + Assert.Contains("DIK_RWIN", text, StringComparison.Ordinal); + Assert.Contains("EscapeKey [ \"\" [ 0 DIK_ESCAPE ] ]", text, StringComparison.Ordinal); + Assert.Contains("TargetedUsage", text, StringComparison.Ordinal); + } + + [Fact] + public void ProfileStore_SaveAsSelectsProfile_AndLoadReplacesRetailRows() + { + string root = Path.Combine(Path.GetTempPath(), "acdream-keymap-" + Guid.NewGuid().ToString("N")); + string config = Path.Combine(root, "config"); + string documents = Path.Combine(root, "documents", "Asheron's Call"); + string json = Path.Combine(config, "keybinds.json"); + try + { + var source = KeyBindings.RetailDefaults(); + var store = new RetailKeymapProfileStore(json, documents); + RetailKeymapSaveResult saved = store.Save("friends", source, overwrite: false); + + Assert.Equal(RetailKeymapSaveStatus.Saved, saved.Status); + Assert.Equal("friends.keymap", store.CurrentFileName); + Assert.Equal(new[] { "friends.keymap" }, store.ListFiles()); + Assert.True(store.TryLoad( + "friends.keymap", new KeyBindings(), out KeyBindings loaded, out string? error), + error); + Assert.Equal( + source.ForAction(InputAction.MovementForward).ToArray(), + loaded.ForAction(InputAction.MovementForward).ToArray()); + Assert.Equal( + RetailKeymapSaveStatus.Exists, + store.Save("friends", source, overwrite: false).Status); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + private static string FindRepoRoot() + { + string? directory = AppContext.BaseDirectory; + while (directory is not null) + { + if (File.Exists(Path.Combine(directory, "AcDream.slnx"))) + return directory; + directory = Directory.GetParent(directory)?.FullName; + } + throw new DirectoryNotFoundException("Could not locate acdream.sln."); + } +} diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 0e28b3a9..0e30d2d6 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -234,6 +234,25 @@ public sealed class SelectionInteractionControllerTests } } + [Fact] + public void Escape_ClearsCurrentSelectionBeforeTheOptionsFallback() + { + var h = new Harness(); + h.Selection.Select(Target, SelectionChangeSource.World); + + Assert.True(h.Controller.HandleInputAction(InputAction.EscapeKey)); + + Assert.Null(h.Selection.SelectedObjectId); + } + + [Fact] + public void Escape_WithNoTargetModeOrSelection_FallsThrough() + { + var h = new Harness(); + + Assert.False(h.Controller.HandleInputAction(InputAction.EscapeKey)); + } + [Fact] public void TargetModeClickPulsesBeforeItIsConsumedAndIncludesSelf() { @@ -323,6 +342,25 @@ public sealed class SelectionInteractionControllerTests Assert.Contains(h.Toasts, text => text.Contains("Target 70000001")); } + [Fact] + public void EveryRetailItemSelectionRowHasALiveControllerConsumer() + { + InputAction[] actions = RetailActionIdentityTable.Map + .Where(entry => entry.Key.InputMapId == 0x10000007u) + .OrderBy(entry => entry.Key.ActionId) + .Select(entry => entry.Value) + .ToArray(); + + Assert.Equal(26, actions.Length); + foreach (InputAction action in actions) + { + var harness = new Harness(); + Assert.True( + harness.Controller.HandleInputAction(action), + $"No selection consumer for {action}"); + } + } + [Fact] public void CloseUseSendsImmediatelyWithoutSpeculativeMovement() { diff --git a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs index d56e046d..c731caa2 100644 --- a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs +++ b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs @@ -11,6 +11,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Selection; +using AcDream.Core.Ui; using AcDream.Core.World; using DatReaderWriter.DBObjs; @@ -45,7 +46,10 @@ public sealed class WorldSelectionQueryTests public readonly LiveEntityRuntime Runtime; public readonly RetailSelectionScene Scene; public readonly WorldSelectionQuery Query; + public readonly ExternalContainerState ExternalContainers = new(); + public readonly HashSet Fellows = []; public PlayerInteractionPose? PlayerPose = new(0x0101_0001u, Vector3.Zero); + public CombatMode CurrentCombatMode = CombatMode.NonCombat; /// /// Stands in for EntityEffectPoseRegistry: the composed equipped-child @@ -74,7 +78,10 @@ public sealed class WorldSelectionQueryTests _ => (new Vector3(1f, 0f, 0f), 2f), localEntityId => ChildRoots.TryGetValue(localEntityId, out Matrix4x4 root) ? root - : null); + : null, + ExternalContainers.HasCorpseBeenOpened, + () => CurrentCombatMode, + Fellows.Contains); Add(Player, Vector3.Zero, ItemType.Creature, SelectedObjectHealthPolicy.BfPlayer); } @@ -89,7 +96,8 @@ public sealed class WorldSelectionQueryTests ushort instance = 1, float scale = 1f, Quaternion? rotation = null, - float? useRadius = null) + float? useRadius = null, + byte? radarBehavior = null) { WorldSession.EntitySpawn spawn = Spawn(guid, instance) with { @@ -109,6 +117,7 @@ public sealed class WorldSelectionQueryTests Name = $"Object {guid:X8}", Type = type, PublicWeenieBitfield = publicFlags, + RadarBehavior = radarBehavior, }); return entity; } @@ -301,6 +310,187 @@ public sealed class WorldSelectionQueryTests Assert.Equal(64f, closest?.DistanceSquared); } + [Fact] + public void RetailItemSelectionUsesRadarAndSpecialObjectRules() + { + var h = new Harness(); + const uint radarItem = 0x7000_0020u; + const uint ordinaryItem = 0x7000_0021u; + const uint portal = 0x7000_0022u; + h.Add( + radarItem, + new Vector3(1f, 0f, 0f), + ItemType.Misc, + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Add(ordinaryItem, new Vector3(2f, 0f, 0f), ItemType.Misc); + h.Add( + portal, + new Vector3(3f, 0f, 0f), + ItemType.Misc, + publicFlags: (uint)PublicWeenieFlags.Portal, + radarBehavior: (byte)RadarBehavior.ShowAlways); + + Assert.Equal( + ordinaryItem, + h.Query.FindSelectionTarget( + RetailSelectionKind.Item, + RetailSelectionDirection.Closest, + anchor: null)); + + // A radar-authored item is not in the Item cycle unless it carries + // one of retail's three explicit exceptions (lifestone/portal/ + // bindstone). + h.Objects.Get(ordinaryItem)!.ContainerId = Player; + Assert.Equal( + portal, + h.Query.FindSelectionTarget( + RetailSelectionKind.Item, + RetailSelectionDirection.Closest, + anchor: null)); + } + + [Fact] + public void RetailCompassSelectionChangesPredicateInPhysicalCombat() + { + var h = new Harness(); + const uint peaceful = 0x7000_0030u; + const uint fellow = 0x7000_0031u; + const uint vendor = 0x7000_0032u; + const uint environment = 0x7000_0033u; + const uint hostile = 0x7000_0034u; + h.Add( + peaceful, + new Vector3(1f, 0f, 0f), + ItemType.Misc, + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Add( + fellow, + new Vector3(2f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)PublicWeenieFlags.Attackable, + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Fellows.Add(fellow); + h.Add( + vendor, + new Vector3(3f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)(PublicWeenieFlags.Attackable | PublicWeenieFlags.Vendor), + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Add( + environment, + new Vector3(4f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)PublicWeenieFlags.Attackable, + radarBehavior: (byte)RadarBehavior.ShowAlways); + Assert.True(h.Runtime.TryApplyState( + new SetState.Parsed( + environment, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.ReportAsEnvironment), + InstanceSequence: 1, + StateSequence: 2), + out _)); + h.Add( + hostile, + new Vector3(5f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)PublicWeenieFlags.Attackable, + radarBehavior: (byte)RadarBehavior.ShowAlways); + + Assert.Equal( + peaceful, + h.Query.FindSelectionTarget( + RetailSelectionKind.CompassItem, + RetailSelectionDirection.Closest, + anchor: null)); + + h.CurrentCombatMode = CombatMode.Melee; + Assert.Equal( + hostile, + h.Query.FindSelectionTarget( + RetailSelectionKind.CompassItem, + RetailSelectionDirection.Closest, + anchor: null)); + + // Retail's special combat-only compass restriction does not apply + // in magic mode. + h.CurrentCombatMode = CombatMode.Magic; + Assert.Equal( + peaceful, + h.Query.FindSelectionTarget( + RetailSelectionKind.CompassItem, + RetailSelectionDirection.Closest, + anchor: null)); + } + + [Fact] + public void RetailMonsterSelectionUsesObjectIsAttackableAndRejectsFellowsAndVendors() + { + var h = new Harness(); + const uint fellow = 0x7000_0040u; + const uint vendor = 0x7000_0041u; + const uint hostile = 0x7000_0042u; + h.Add( + fellow, + new Vector3(1f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)PublicWeenieFlags.Attackable, + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Fellows.Add(fellow); + h.Add( + vendor, + new Vector3(2f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)(PublicWeenieFlags.Attackable | PublicWeenieFlags.Vendor), + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Add( + hostile, + new Vector3(3f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)PublicWeenieFlags.Attackable, + radarBehavior: (byte)RadarBehavior.ShowAlways); + + Assert.Equal( + hostile, + h.Query.FindSelectionTarget( + RetailSelectionKind.Monster, + RetailSelectionDirection.Closest, + anchor: null)); + } + + [Fact] + public void RetailUnopenedCorpseSelectionRemembersOpenUntilDelete() + { + var h = new Harness(); + const uint corpse = 0x7000_0050u; + h.Add( + corpse, + new Vector3(1f, 0f, 0f), + ItemType.Container, + publicFlags: (uint)PublicWeenieFlags.Corpse); + + Assert.Equal( + corpse, + h.Query.FindSelectionTarget( + RetailSelectionKind.UnopenedCorpse, + RetailSelectionDirection.Closest, + anchor: null)); + + Assert.True(h.ExternalContainers.RequestOpen(corpse, isCorpse: true)); + Assert.Null(h.Query.FindSelectionTarget( + RetailSelectionKind.UnopenedCorpse, + RetailSelectionDirection.Closest, + anchor: null)); + + Assert.True(h.ExternalContainers.SetCorpseDeleted(corpse)); + Assert.Equal( + corpse, + h.Query.FindSelectionTarget( + RetailSelectionKind.UnopenedCorpse, + RetailSelectionDirection.Closest, + anchor: null)); + } + /// /// #298 follow-up: retail ClientCombatSystem::UpdateTargetTracking /// @ 0x0056A950 (pc:375691-375696) gates CameraSet::TrackTarget diff --git a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs index 7228a136..41f47c47 100644 --- a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs @@ -9,17 +9,18 @@ namespace AcDream.App.Tests.Rendering; public sealed class PaperdollFramePresenterTests { [Fact] - public void HiddenView_DoesNotBuildOrRender() + public void HiddenView_BuildsAndPrewarmsWithoutRendering() { var renderer = new RecordingRenderer(); var view = new RecordingView { Visible = false }; - var factory = new RecordingFactory(); + var factory = new RecordingFactory { Doll = CreateDoll() }; var presenter = new PaperdollFramePresenter(renderer, view, factory); presenter.Render(); - Assert.True(presenter.IsDirty); - Assert.Equal(0, factory.BuildCount); + Assert.False(presenter.IsDirty); + Assert.Equal(1, factory.BuildCount); + Assert.Equal(1, renderer.PrepareCount); Assert.Equal(0, renderer.RenderCount); Assert.Empty(view.TextureHandles); } @@ -255,9 +256,12 @@ public sealed class PaperdollFramePresenterTests public List Dolls { get; } = []; public List<(int Width, int Height)> RenderSizes { get; } = []; public int RenderCount => RenderSizes.Count; + public int PrepareCount { get; private set; } public void SetDoll(WorldEntity? doll) => Dolls.Add(doll); + public void Prepare() => PrepareCount++; + public uint Render(int width, int height) { RenderSizes.Add((width, height)); diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index 653a5182..84283765 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -209,6 +209,25 @@ public class RetailChaseCameraTests Assert.Equal(Vector3.Normalize(pivot - eye), forward); } + [Fact] + public void MapMode_TargetDirectionTransformsViewerOffsetIntoOverheadPose() + { + var pivot = new Vector3(10f, 20f, 1.5f); + float distance = MathF.Sqrt(450f * 450f + 0.75f * 0.75f); + float pitch = MathF.Atan2(0.75f, 450f); + + var (eye, forward) = RetailChaseCamera.ComputeTargetDirectionPose( + pivot, + Vector3.UnitX, + distance, + pitch, + new Vector3(0f, 0.5f, -1.8f)); + + Assert.True(eye.Z > 430f, $"expected retail overhead eye, got Z={eye.Z}"); + Assert.InRange(Vector2.Distance(new Vector2(eye.X, eye.Y), new Vector2(pivot.X, pivot.Y)), 119f, 122f); + Assert.True(forward.Z < -0.95f, $"expected steep downward view, got {forward}"); + } + [Fact] public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded() { @@ -528,6 +547,41 @@ public class RetailChaseCameraTests Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance); } + [Fact] + public void SetRetailFirstPersonView_PlacesEyeAheadAndLooksForward() + { + var cam = new RetailChaseCamera(); + cam.SetRetailFirstPersonView(); + + cam.Update( + playerPosition: Vector3.Zero, + playerYaw: 0f, + playerVelocity: Vector3.Zero, + isOnGround: true, + contactPlaneNormal: Vector3.UnitZ, + dt: 1f / 60f); + + Assert.True(cam.IsInHead); + Assert.Equal(new Vector3(0.18f, 0f, 1.5f), cam.Position); + Assert.Equal(1f, cam.PlayerTranslucency, 5); + var (_, forward) = RetailChaseCamera.ComputeInHeadPose( + new Vector3(0f, 0f, 1.5f), + Vector3.UnitX); + Assert.Equal(Vector3.UnitX, forward); + } + + [Fact] + public void AdjustingZoomExitsRetailFirstPersonView() + { + var cam = new RetailChaseCamera(); + cam.SetRetailFirstPersonView(); + + cam.AdjustDistance(1f); + + Assert.False(cam.IsInHead); + Assert.Equal(RetailChaseCamera.DistanceMin + 1f, cam.Distance); + } + [Fact] public void AdjustPitch_ClampsToRange() { diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index d35b5bf3..087a29a2 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -338,6 +338,11 @@ public sealed class CurrentGameRuntimeAdapterTests Assert.True(graphicalInput.HandleInputAction( InputAction.CombatLowAttack, ActivationType.Press)); + Assert.True(graphical.Actions.View.Snapshot.CombatAttack.RequestInProgress); + Assert.True(graphicalInput.HandleInputAction( + InputAction.CombatLowAttack, + ActivationType.Hold)); + Assert.True(graphical.Actions.View.Snapshot.CombatAttack.RequestInProgress); Assert.True(graphicalInput.HandleInputAction( InputAction.CombatLowAttack, ActivationType.Release)); @@ -1280,6 +1285,10 @@ public sealed class CurrentGameRuntimeAdapterTests { } + public void AbortAutomaticAttack() + { + } + public bool HandleInputAction( InputAction action, ActivationType activation) => false; diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index f176db45..276e2610 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -1400,6 +1400,7 @@ public sealed class LocalPlayerTeleportControllerTests public bool IsRecenterPending => RecenterPending; public bool RecenterPending; public bool ResetResult = true; + public int ResetCalls; public bool LastResetWasSessionEnding; public readonly List<(int X, int Y, bool Sealed)> Recenters = new(); public readonly List<(long Generation, uint Cell, int Radius)> @@ -1414,6 +1415,7 @@ public sealed class LocalPlayerTeleportControllerTests public bool ResetRecenter(bool sessionEnding) { + ResetCalls++; LastResetWasSessionEnding = sessionEnding; return ResetResult; } @@ -1954,6 +1956,7 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.ResetGenerationPresentation(); harness.Controller.Tick(0.016f); Assert.Equal(1, harness.Logout.CompleteCalls); + Assert.Equal(1, harness.Streaming.ResetCalls); Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage); // The transaction's world reset retired the presentation. Assert.Contains("presentation-reset", order); @@ -1966,6 +1969,34 @@ public sealed class LocalPlayerTeleportControllerTests Assert.Equal(1, harness.Logout.CompleteCalls); } + [Fact] + public void LogoutConfirmation_WaitsForOldStreamingWindowBeforeFreshGeneration() + { + var harness = new Harness(worldReady: true); + Assert.True(harness.Controller.TryRequestLogout()); + harness.Logout.IsCharacterLogOffConfirmed = true; + harness.Streaming.ResetResult = false; + harness.Logout.OnComplete = () => + harness.Controller.ResetGenerationPresentation(); + + harness.Controller.Tick(0.016f); + + Assert.Equal(RuntimeLogoutStage.Confirmed, harness.Transit.LogoutStage); + Assert.Equal(0, harness.Logout.CompleteCalls); + Assert.Equal(1, harness.Streaming.ResetCalls); + + // StreamingController.Tick advances the retained retirement between + // these controller ticks. Once converged, the logout transaction may + // expose the next generation. Its reset callback consumes the same + // retirement instead of beginning a second one. + harness.Streaming.ResetResult = true; + harness.Controller.Tick(0.016f); + + Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage); + Assert.Equal(1, harness.Logout.CompleteCalls); + Assert.Equal(2, harness.Streaming.ResetCalls); + } + [Fact] public void LogoutConfirmationBeforeHoldEnd_SkipsTheWormholeEntirely() { diff --git a/tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs b/tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs index 4233a3bb..8d4ab29e 100644 --- a/tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs +++ b/tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs @@ -35,8 +35,7 @@ public sealed class AutoWieldGenerationTests objects, () => Player, sendWield: null, - sendPutItemInContainer: (_, _, _) => { }, - toast: null); + sendPutItemInContainer: (_, _, _) => { }); Assert.True(controller.TryWield(requested)); Assert.True(controller.IsBusy); diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs index 7cf9e128..8b5534cb 100644 --- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -182,6 +182,38 @@ public class DragDropSpineTests Assert.Equal((0x99u, 32, 32), root.DragGhostForTest); } + [Fact] + public void RootOwnedDrag_survivesProceduralSourceCellReplacement_untilRelease() + { + var (root, list, cell) = RootWithBoundSlot(0x5001u); + object? released = null; + root.DragReleasedOutsideUi += (payload, _, _) => released = payload; + + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // BeginDrag → root owns ghost + object payload = Assert.IsType(root.DragPayload); + + // InventoryController.Populate/ExternalContainerController.Populate use + // UiItemList.Flush when an unrelated authoritative item update arrives. + // The old source cell is replaced while the physical button is still down. + list.Flush(); + + Assert.Null(cell.Parent); + Assert.Same(cell, root.DragSource); + Assert.Same(payload, root.DragPayload); + Assert.Equal((0x99u, 32, 32), root.DragGhostForTest); + Assert.Same(root, root.Captured); // retail drag element owns capture + + root.OnMouseMove(600, 500); + root.OnMouseUp(UiMouseButton.Left, 600, 500); + + Assert.Same(payload, released); + Assert.Null(root.DragSource); + Assert.Null(root.DragPayload); + Assert.Null(root.DragGhostForTest); + Assert.Null(root.Captured); + } + [Fact] public void FinishDrag_overNothing_deliversNoDrop_butLiftStands() { diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 2276902b..f03c7123 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -23,6 +23,7 @@ public sealed class ItemInteractionControllerTests public readonly List<(uint Item, uint Mask)> Wields = new(); public readonly List<(uint Item, uint Container, int Placement)> Puts = new(); public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new(); + public readonly List<(uint Source, uint Target, uint Amount)> Merges = new(); public readonly List ExternalRequests = new(); public readonly List<(uint Item, uint Container, int Placement)> BackpackPlacements = new(); public readonly List Drops = new(); @@ -130,7 +131,9 @@ public sealed class ItemInteractionControllerTests Sells.Add((vendorGuid, items)); return true; }, - interfaceText: (text, type) => InterfaceTexts.Add((text, type))); + interfaceText: (text, type) => InterfaceTexts.Add((text, type)), + sendStackableMerge: (source, target, amount) => + Merges.Add((source, target, amount))); } public ItemInteractionController Controller { get; } @@ -589,7 +592,7 @@ public sealed class ItemInteractionControllerTests } [Fact] - public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically() + public void EquippableItemWithFreeSlot_sendsGetAndWieldAndWaitsForServer() { var h = new Harness(); h.AddContained(0x50000A05u, item => @@ -602,12 +605,12 @@ public sealed class ItemInteractionControllerTests Assert.Equal(new[] { (0x50000A05u, (uint)EquipMask.HeadWear) }, h.Wields); var equipped = h.Objects.Get(0x50000A05u)!; - Assert.Equal(Player, equipped.ContainerId); - Assert.Equal(EquipMask.HeadWear, equipped.CurrentlyEquippedLocation); + Assert.Equal(Pack, equipped.ContainerId); + Assert.Equal(EquipMask.None, equipped.CurrentlyEquippedLocation); } [Fact] - public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically() + public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndWaitsForServer() { var h = new Harness(); const EquipMask coatMask = @@ -624,8 +627,8 @@ public sealed class ItemInteractionControllerTests Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields); var equipped = h.Objects.Get(0x50000A15u)!; - Assert.Equal(Player, equipped.ContainerId); - Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation); + Assert.Equal(Pack, equipped.ContainerId); + Assert.Equal(EquipMask.None, equipped.CurrentlyEquippedLocation); } [Fact] @@ -654,7 +657,7 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Controller.ActivateItem(0x50000A16u)); Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields); - Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation); + Assert.Equal(EquipMask.None, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation); } [Fact] @@ -692,12 +695,13 @@ public sealed class ItemInteractionControllerTests } [Fact] - public void EquippableItemWithNoFreeSlot_sendsNothing() + public void EquippableItemWithNoFreeSlot_movesBlockerThenWieldsAfterServerConfirm() { var h = new Harness(); h.Objects.AddOrUpdate(new ClientObject { ObjectId = 0x50000AF0u, + Name = "Old Shield", Type = ItemType.Armor, CurrentlyEquippedLocation = EquipMask.Shield, }); @@ -712,9 +716,17 @@ public sealed class ItemInteractionControllerTests bool activated = h.Controller.ActivateItem(0x50000A06u); + Assert.True(activated); Assert.Empty(h.Wields); - Assert.False(activated); + Assert.Equal(new[] { (0x50000AF0u, Player, 0) }, h.Puts); + Assert.Equal(new[] { "Moving Old Shield to your backpack" }, h.SystemMessages); Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId); + + Assert.True(h.Objects.ApplyConfirmedServerMove(0x50000AF0u, Player, 0u, 0)); + + Assert.Equal( + new[] { (0x50000A06u, (uint)EquipMask.Shield) }, + h.Wields); } [Theory] @@ -752,14 +764,18 @@ public sealed class ItemInteractionControllerTests Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId); // Authoritative 0x0022: only now does retail retry AutoWield. - h.Objects.MoveItem(sword, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0)); Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); Assert.Equal(EquipMask.None, h.Objects.Get(sword)!.CurrentlyEquippedLocation); + Assert.Equal(EquipMask.None, + h.Objects.Get(bow)!.CurrentlyEquippedLocation); + Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId); + Assert.True(h.Objects.ApplyConfirmedServerWield( + bow, Player, EquipMask.MissileWeapon)); Assert.Equal(EquipMask.MissileWeapon, h.Objects.Get(bow)!.CurrentlyEquippedLocation); - Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId); } [Theory] @@ -797,7 +813,7 @@ public sealed class ItemInteractionControllerTests // wand away. The transaction retains the initial active-combat intent // rather than consulting this intermediate state on its second pass. h.Combat.SetCombatMode(CombatMode.Melee); - h.Objects.MoveItem(wand, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0)); Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); Assert.Empty(h.CombatModeRequests); @@ -860,7 +876,7 @@ public sealed class ItemInteractionControllerTests // replacement wield. h.Combat.SetCombatMode(CombatMode.Melee); h.Combat.SetCombatMode(CombatMode.NonCombat); - h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0)); Assert.Equal(new[] { (wand, (uint)EquipMask.Held) }, h.Wields); Assert.True(h.Objects.ApplyConfirmedServerWield( @@ -899,7 +915,7 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Controller.ActivateItem(wand)); h.Combat.SetCombatMode(CombatMode.NonCombat); - h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0)); Assert.True(h.Objects.ApplyConfirmedServerWield( wand, Player, @@ -935,7 +951,7 @@ public sealed class ItemInteractionControllerTests }); Assert.True(h.Controller.ActivateItem(bow)); - h.Objects.MoveItem(wand, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0)); Assert.True(h.Objects.ApplyConfirmedServerWield( bow, Player, EquipMask.MissileWeapon)); @@ -1027,7 +1043,7 @@ public sealed class ItemInteractionControllerTests }); Assert.True(h.Controller.ActivateItem(bow)); - h.Objects.MoveItem(sword, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0)); Assert.Single(h.Wields); // Bow is optimistic but ACE has not sent WieldObject yet. Retail's @@ -1121,13 +1137,13 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Controller.ActivateItem(bow)); Assert.Equal(new[] { (sword, Player, 0) }, h.Puts); - h.Objects.MoveItem(sword, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0)); Assert.Equal( new[] { (sword, Player, 0), (shield, Player, 0) }, h.Puts); Assert.Empty(h.Wields); - h.Objects.MoveItem(shield, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(shield, Player, 0u, 0)); Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); } @@ -1164,12 +1180,12 @@ public sealed class ItemInteractionControllerTests }); Assert.True(h.Controller.ActivateItem(bow)); - h.Objects.MoveItem(sword, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0)); Assert.Equal( new[] { (sword, Player, 0), (arrows, Player, 0) }, h.Puts); - h.Objects.MoveItem(arrows, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(arrows, Player, 0u, 0)); Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); } @@ -1213,7 +1229,7 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.Wields); Assert.Equal(new[] { "Moving Shortbow to your backpack" }, h.SystemMessages); - h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0)); Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields); Assert.Equal(EquipMask.MissileAmmo, @@ -1247,7 +1263,7 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.Wields); Assert.Equal(new[] { "Moving Wand to your backpack" }, h.SystemMessages); - h.Objects.MoveItem(wand, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0)); Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields); } @@ -1292,14 +1308,14 @@ public sealed class ItemInteractionControllerTests crossbow, EquipMask.MissileWeapon)); Assert.Equal(new[] { (bow, Player, 0) }, h.Puts); - h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0)); Assert.Equal( new[] { (bow, Player, 0), (arrows, Player, 0) }, h.Puts); Assert.Empty(h.Wields); - h.Objects.MoveItem(arrows, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(arrows, Player, 0u, 0)); Assert.Equal( new[] { (crossbow, (uint)EquipMask.MissileWeapon) }, @@ -1333,7 +1349,11 @@ public sealed class ItemInteractionControllerTests new[] { (ring, (uint)EquipMask.FingerWearRight) }, h.Wields); Assert.Equal( - EquipMask.FingerWearRight, + EquipMask.FingerWearLeft, + h.Objects.Get(ring)!.CurrentlyEquippedLocation); + Assert.True(h.Objects.ApplyConfirmedServerWield( + ring, Player, EquipMask.FingerWearRight)); + Assert.Equal(EquipMask.FingerWearRight, h.Objects.Get(ring)!.CurrentlyEquippedLocation); } @@ -1366,14 +1386,61 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.Wields); Assert.Equal(new[] { "Moving Right Ring to your backpack" }, h.SystemMessages); - h.Objects.MoveItem(rightRing, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(rightRing, Player, 0u, 0)); Assert.Equal( new[] { (leftRing, (uint)EquipMask.FingerWearRight) }, h.Wields); Assert.Equal( - EquipMask.FingerWearRight, + EquipMask.FingerWearLeft, h.Objects.Get(leftRing)!.CurrentlyEquippedLocation); + Assert.True(h.Objects.ApplyConfirmedServerWield( + leftRing, Player, EquipMask.FingerWearRight)); + Assert.Equal(EquipMask.FingerWearRight, + h.Objects.Get(leftRing)!.CurrentlyEquippedLocation); + } + + [Fact] + public void ActivateItem_whenEveryCompatibleSlotIsOccupied_movesRetailPreferredBlockerThenWields() + { + var h = new Harness(); + const uint leftRing = 0x50000B74u; + const uint rightRing = 0x50000B75u; + const uint requestedRing = 0x50000B76u; + EquipMask valid = EquipMask.FingerWearLeft | EquipMask.FingerWearRight; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = leftRing, + Name = "Left Ring", + Type = ItemType.Jewelry, + ValidLocations = valid, + }); + h.Objects.MoveItem(leftRing, Player, -1, EquipMask.FingerWearLeft); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = rightRing, + Name = "Right Ring", + Type = ItemType.Jewelry, + ValidLocations = valid, + }); + h.Objects.MoveItem(rightRing, Player, -1, EquipMask.FingerWearRight); + h.AddContained(requestedRing, item => + { + item.Name = "New Ring"; + item.Type = ItemType.Jewelry; + item.ValidLocations = valid; + }); + + Assert.True(h.Controller.ActivateItem(requestedRing)); + Assert.Equal(new[] { (leftRing, Player, 0) }, h.Puts); + Assert.Empty(h.Wields); + Assert.Equal(new[] { "Moving Left Ring to your backpack" }, h.SystemMessages); + + Assert.True(h.Objects.ApplyConfirmedServerMove(leftRing, Player, 0u, 0)); + + Assert.Equal( + new[] { (requestedRing, (uint)EquipMask.FingerWearLeft) }, + h.Wields); } [Fact] @@ -1400,7 +1467,7 @@ public sealed class ItemInteractionControllerTests } [Fact] - public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically() + public void InventoryDragOutsideUi_sendsDropAndWaitsForServerPlacement() { var h = new Harness(); h.AddContained(0x50000A07u); @@ -1413,7 +1480,7 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Controller.DropToWorld(payload)); Assert.Equal(new[] { 0x50000A07u }, h.Drops); - Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId); + Assert.Equal(Pack, h.Objects.Get(0x50000A07u)!.ContainerId); } /// @@ -1517,7 +1584,7 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.Gives); Assert.Equal(new[] { item }, h.Drops); - Assert.Equal(0u, h.Objects.Get(item)!.ContainerId); + Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId); } [Theory] @@ -1898,6 +1965,126 @@ public sealed class ItemInteractionControllerTests Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); } + [Fact] + public void KeyboardPickup_AutoMergesWholeStackBeforeContainerPlacement() + { + var h = new Harness(); + const uint source = 0x70000B01u; + const uint target = 0x50000B02u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = source, + WeenieClassId = 77u, + Name = "World stack", + StackSize = 3, + StackSizeMax = 10, + }); + h.AddContained(target, item => + { + item.WeenieClassId = 77u; + item.StackSize = 5; + item.StackSizeMax = 10; + }); + var attempts = new List<(uint Source, uint Target)>(); + h.Controller.MergeAttempted += (from, into) => attempts.Add((from, into)); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(source)); + + Assert.Equal(new[] { (source, target, 3u) }, h.Merges); + Assert.Equal(new[] { (source, target) }, attempts); + Assert.Empty(h.BackpackPlacements); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(InventoryRequestKind.Merge, pending.Kind); + Assert.True(pending.Dispatched); + } + + [Fact] + public void KeyboardPickup_UsesSelectedSplitQuantityAndSearchesNestedPacks() + { + var h = new Harness(); + const uint nestedPack = 0x50000B10u; + const uint source = 0x70000B11u; + const uint target = 0x50000B12u; + h.AddContained(nestedPack, item => item.Type = ItemType.Container); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = source, + WeenieClassId = 88u, + StackSize = 10, + StackSizeMax = 10, + }); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = target, + WeenieClassId = 88u, + StackSize = 8, + StackSizeMax = 10, + }); + h.Objects.MoveItem(target, nestedPack, 0); + h.SelectedObject = source; + h.SplitQuantity.Reset(10u, 2u); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(source)); + + Assert.Equal(new[] { (source, target, 2u) }, h.Merges); + Assert.Empty(h.BackpackPlacements); + } + + [Fact] + public void KeyboardPickup_SkipsPartialMergeTargetAndFallsBackToPlacement() + { + var h = new Harness(); + const uint source = 0x70000B20u; + const uint partialTarget = 0x50000B21u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = source, + WeenieClassId = 99u, + StackSize = 3, + StackSizeMax = 10, + }); + h.AddContained(partialTarget, item => + { + item.WeenieClassId = 99u; + item.StackSize = 9; + item.StackSizeMax = 10; + }); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(source)); + + Assert.Empty(h.Merges); + Assert.Equal(new[] { (source, Player, 0) }, h.BackpackPlacements); + } + + [Fact] + public void TrySplitToContainerDispatchesTheExactSelectedQuantity() + { + var h = new Harness(); + const uint source = 0x50000A30u; + h.AddContained(source, item => item.StackSize = 10); + + Assert.True(h.Controller.TrySplitToContainer(source, Pack, 3u, 2u)); + + Assert.Equal(new[] { (source, Pack, 3u, 2u) }, h.SplitPuts); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(InventoryRequestKind.SplitToContainer, pending.Kind); + Assert.Equal(source, pending.ItemId); + } + + [Fact] + public void TrySplitToContainerRejectsZeroAndWholeStackAmounts() + { + var h = new Harness(); + const uint source = 0x50000A31u; + h.AddContained(source, item => item.StackSize = 10); + + Assert.False(h.Controller.TrySplitToContainer(source, Pack, 0u, 0u)); + Assert.False(h.Controller.TrySplitToContainer(source, Pack, 0u, 10u)); + + Assert.Empty(h.SplitPuts); + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + } + [Fact] public void MatchingInventoryFailureReleasesGlobalRequest() { diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index e80f46f3..e91d56a3 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -1156,6 +1156,28 @@ public class CharacterStatControllerTests Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom(child).ActiveRetailStateId)); } + [Fact] + public void ProgrammaticShowTab_UsesTheSameAuthoredStateAsAKeyboardAction() + { + ImportedLayout layout = FixtureLoader.LoadCharacter(); + var attributes = Assert.IsType( + layout.FindElement(CharacterStatController.TabAttribId)); + var skills = Assert.IsType( + layout.FindElement(CharacterStatController.TabSkillsId)); + CharacterStatController.Binding binding = CharacterStatController.Bind( + layout, + SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + binding.ShowTab(CharacterStatController.CharacterStatTab.Skills); + + Assert.Equal( + CharacterStatController.CharacterStatTab.Skills, + binding.CurrentTab()); + Assert.Equal(RetailUiStateIds.Closed, attributes.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.Open, skills.ActiveRetailStateId); + } + /// /// CT3 (2026-08-24): unlike Attributes/Skills (which share ONE mounted /// page and only rebind its content), the Titles page (0x10000539) is a @@ -1267,7 +1289,7 @@ public class CharacterStatControllerTests CharacterSheet sheet = SampleData.SampleCharacter(); Action refresh = CharacterStatController.Bind(layout, () => sheet, - spriteResolve: id => (id, 16, 16)); + spriteResolve: id => (id, 16, 16)).Refresh; ClickTab(layout, left: 92f); var untrained = sheet.Skills.First( diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs index 8e705d6d..64f118de 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs @@ -225,6 +225,47 @@ public sealed class ChatTranscriptRunsTests Assert.Equal(detailed[^1].Text, lines[^1].Text); } + [Fact] + public void OversizedMultilineServerMessageKeepsItsNewestCompleteLines() + { + string response = string.Join( + '\n', + Enumerable.Range(0, 1_500).Select(i => $"@command-{i:D4}")); + var detailed = new List { Plain(response) }; + + List lines = ChatTranscriptRenderer.BuildLines( + detailed, + maxW: 100_000f, + Measure, + accept: null, + defaultColor: LineColor); + + Assert.NotEmpty(lines); + Assert.Equal("@command-1499", lines[^1].Text); + Assert.DoesNotContain(lines, line => line.Text == "@command-0000"); + Assert.All(lines, line => Assert.False(string.IsNullOrWhiteSpace(line.Text))); + } + + [Fact] + public void MultilineServerMessageWithinBudgetRendersEveryAuthoredLine() + { + var detailed = new List + { + Plain("@acecommands\n@help\n@teleport"), + }; + + List lines = ChatTranscriptRenderer.BuildLines( + detailed, + maxW: 100_000f, + Measure, + accept: null, + defaultColor: LineColor); + + Assert.Equal( + new[] { "@acecommands", "@help", "@teleport" }, + lines.Select(line => line.Text)); + } + [Fact] public void TheBudgetIsRetailsOwnNumber() => Assert.Equal(0x2710, ChatTranscriptRenderer.MaxTranscriptCharacters); diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 418b54df..0f640969 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -494,6 +494,28 @@ public class InventoryControllerTests Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item } + [Fact] + public void DoubleClickOwnedBag_opensOnceOnFirstPress_andNeverRunsGenericUse() + { + var (layout, _, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedBag(objects, 0xCu, slot: 0); + var uses = new List(); + Bind(layout, objects, uses: uses); + + UiItemSlot bag = containers.GetItem(0)!; + bag.OnEvent(new UiEvent(0u, bag, UiEventType.MouseDown)); + bag.OnEvent(new UiEvent(0u, bag, UiEventType.Click)); + bag.OnEvent(new UiEvent(0u, bag, UiEventType.MouseDown)); + bag.OnEvent(new UiEvent(0u, bag, UiEventType.Click)); + bag.OnEvent(new UiEvent(0u, bag, UiEventType.DoubleClick)); + + Assert.Equal(new[] { 0xCu }, uses); + Assert.Null(bag.DoubleClicked); + Assert.True(containers.GetItem(0)!.IsOpenContainer); + Assert.Equal(0xCu, objects.Get(0xCu)!.ObjectId); + } + [Fact] public void MouseDownGridItem_movesSquareImmediately_noWire_keepsOpenContainer() { @@ -684,7 +706,7 @@ public class InventoryControllerTests Workmanship: null); [Fact] - public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally() + public void Drop_onOccupiedGridCell_insertsBefore_andWaitsForServer() { var (layout, grid, _, _, _, _, _, _) = BuildLayout(); var objects = new ClientObjectTable(); @@ -699,7 +721,7 @@ public class InventoryControllerTests ((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xFFFFu)); Assert.Contains((0xFFFFu, Player, 1), puts); // insert-before slot 1, into the open container - Assert.Equal(Player, objects.Get(0xFFFFu)!.ContainerId); // moved locally (instant) + Assert.Equal(0u, objects.Get(0xFFFFu)!.ContainerId); } [Fact] @@ -1316,11 +1338,12 @@ public class InventoryControllerTests StackSizeMax = 100, }); objects.MoveItem(0xAu, Player, 0); + SeedBag(objects, 0xCu, slot: 1); var selection = new SelectionState(); selection.Select(0xAu, SelectionChangeSource.Inventory); var splitQuantity = new StackSplitQuantityState(); splitQuantity.Reset(10u); - splitQuantity.SetValue(1u); + splitQuantity.SetValue(2u); var splits = new List<(uint item, uint container, uint placement, uint amount)>(); var puts = new List<(uint item, uint container, int placement)>(); var ctrl = Bind(layout, objects, puts: puts, splits: splits, @@ -1328,7 +1351,9 @@ public class InventoryControllerTests ctrl.HandleDropRelease(grid, grid.GetItem(5)!, Payload(0xAu)); - Assert.Equal(new[] { (0xAu, Player, 1u, 1u) }, splits); + // Placement counts the main pack's visible loose-item list only; + // side bags occupy the separate selector list and must not shift it. + Assert.Equal(new[] { (0xAu, Player, 1u, 2u) }, splits); Assert.Empty(puts); Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); Assert.Equal(0, objects.Get(0xAu)!.ContainerSlot); @@ -1464,7 +1489,7 @@ public class InventoryControllerTests ((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xFFFFu)); Assert.Contains((0xFFFFu, 0xCu, 0), puts); // into the bag, append (placement 0) - Assert.Equal(0xCu, objects.Get(0xFFFFu)!.ContainerId); + Assert.Equal(0u, objects.Get(0xFFFFu)!.ContainerId); } [Fact] @@ -1483,6 +1508,34 @@ public class InventoryControllerTests ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green } + [Fact] + public void MainPackFullness_countsLooseItems_notSideBags_afterAFreeSlotAppears() + { + var (layout, _, _, top, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Type = ItemType.Creature, + ItemsCapacity = 2, + }); + SeedContained(objects, 0xA0u, Player, slot: 0); + SeedContained(objects, 0xA1u, Player, slot: 1); + SeedBag(objects, 0xC0u, slot: 0); + SeedContained(objects, 0xB0u, 0xC0u, slot: 0); + var controller = (IItemListDragHandler)Bind(layout, objects); + UiItemSlot mainPack = top.GetItem(0)!; + + Assert.Equal(ItemDragAcceptance.Reject, + controller.OnDragOver(top, mainPack, Payload(0xB0u))); + + Assert.True(objects.Remove(0xA1u)); + + Assert.Equal(ItemDragAcceptance.Accept, + controller.OnDragOver(top, top.GetItem(0)!, Payload(0xB0u))); + Assert.Equal(0.5f, top.GetItem(0)!.CapacityFill); + } + [Fact] public void GroundPack_rejectsContentsGrid_butEmptyPackSlotAcceptsAndPicksUpAtThatSlot() { @@ -1617,7 +1670,7 @@ public class InventoryControllerTests } [Fact] - public void Drop_thenServerRollback_revertsTheMove() // optimistic + InventoryServerSaveFailed snap-back + public void Drop_thenServerReject_keepsCanonicalPlacement() { var (layout, _, containers, _, _, _, _, _) = BuildLayout(); var objects = new ClientObjectTable(); @@ -1626,11 +1679,10 @@ public class InventoryControllerTests var ctrl = Bind(layout, objects); ((IItemListDragHandler)ctrl).HandleDropRelease(containers, containers.GetItem(0)!, Payload(0xAu)); - Assert.Equal(0xCu, objects.Get(0xAu)!.ContainerId); // moved into the bag optimistically (instant) - - objects.RollbackMove(0xAu); // server rejected (InventoryServerSaveFailed) - Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // snapped back to the main pack - Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot); // and the original slot + Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); + Assert.False(objects.RejectMove(0xAu, 0x426u)); + Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); + Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot); } // Reads the text of the UiText caption child attached by the controller. diff --git a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs index f7c50a22..31924816 100644 --- a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs @@ -20,9 +20,8 @@ namespace AcDream.App.Tests.UI.Layout; /// /// /// Reworked at the 2026-08-11 combined review (M1/M2/M3/S1/S4): the seam now -/// carries (chord + activation + scope), the M2 fix means -/// only ONE camera InputMap context maps live, and conflicts open a real confirm -/// dialog instead of auto-reassigning silently. +/// carries (chord + activation + scope). Campaign KB gives +/// both camera contexts distinct live identities and removes the store-only tier. /// /// public sealed class KeyboardConfigControllerTests @@ -55,7 +54,16 @@ public sealed class KeyboardConfigControllerTests uint inputMapId, uint actionId, RetailActionClass cls, uint labelHash = 0, uint tooltipHash = 0, params RetailKeyChord[] defaults) => - new(inputMapId, actionId, cls, labelHash, tooltipHash, defaults); + new( + inputMapId, + actionId, + cls, + labelHash == 0 ? 0xDE000000u | (actionId & 0x00FFFFFFu) : labelHash, + tooltipHash, + defaults); + + private static string? ResolveSyntheticString(uint _, uint hash) => + (hash & 0xFF000000u) == 0xDE000000u ? $"Action {hash & 0x00FFFFFFu:X}" : null; private sealed class FakeBindings { @@ -72,6 +80,9 @@ public sealed class KeyboardConfigControllerTests public List InstructionCloses { get; } = new(); public uint NextInstructionContext { get; set; } = 7u; public bool WireInstructions { get; set; } + public string CurrentKeymapFilename { get; set; } = "acdream.keymap"; + public Action? PendingLoadCompleted { get; private set; } + public Action? PendingSaveCompleted { get; private set; } public void Capture(KeyChord? chord) { @@ -103,8 +114,26 @@ public sealed class KeyboardConfigControllerTests BeginCapture: cb => PendingCapture = cb, Save: () => SaveCalls++, Toggle: () => ToggleCalls++, - DisplaySystemMessage: msg => Messages.Add(msg), - NonBindableRefusalText: "cannot overwrite", + ResolveTemplate: (key, variables) => key switch + { + "ID_ActionKeyMap_ButtonLabel" => variables[DatStringResolver.ComputeHash("LABEL")], + "ID_ActionKeyMap_TT_ExistingBinding" => + $"({variables[DatStringResolver.ComputeHash("VALUE")]}) existing binding", + "ID_ActionKeyMap_TT_NewBinding" => "new binding", + "ID_ActionKeyMap_NonUserBindableBinding" => + $"cannot overwrite {variables[DatStringResolver.ComputeHash("KEY")]}", + "ID_ActionKeyMap_OverwriteExistingBinding" => + $"overwrite {variables[DatStringResolver.ComputeHash("KEY")]} " + + variables[DatStringResolver.ComputeHash("ACTION")], + "ID_ActionKeyMap_Binding" => + $"{variables[DatStringResolver.ComputeHash("ACTION")]} " + + $"({variables[DatStringResolver.ComputeHash("KEY")]})", + "ID_ActionKeyMap_OverwriteExistingBindings" => + $"overwrite {variables[DatStringResolver.ComputeHash("KEY")]}\n" + + variables[DatStringResolver.ComputeHash("BINDINGS")], + _ => null, + }, + ShowMessage: msg => Messages.Add(msg), ConfirmOverwrite: (message, onResult) => PendingConfirm = (message, onResult), OpenCaptureInstructions: WireInstructions ? label => @@ -114,11 +143,23 @@ public sealed class KeyboardConfigControllerTests } : null, CloseCaptureInstructions: context => InstructionCloses.Add(context)); + + public KeyboardConfigController.Bindings ToProfileBindings() => + ToBindings() with + { + CurrentKeymapFilename = () => this.CurrentKeymapFilename, + OpenLoadKeymap = completed => PendingLoadCompleted = completed, + OpenSaveKeymap = completed => PendingSaveCompleted = completed, + }; } private static readonly KeyChord ChordW = new(Silk.NET.Input.Key.W, ModifierMask.None); private static readonly KeyChord ChordUp = new(Silk.NET.Input.Key.Up, ModifierMask.None); private static readonly KeyChord ChordA = new(Silk.NET.Input.Key.A, ModifierMask.None); + private static readonly KeyChord LeftMouse = new( + InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Left), + ModifierMask.None, + Device: 1); [Fact] public void Bind_Succeeds_AndBuildsOneRowPerSnapshotRow() @@ -133,7 +174,7 @@ public sealed class KeyboardConfigControllerTests ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); var fake = new FakeBindings(); KeyboardConfigController? controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings()); + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings()); Assert.NotNull(controller); Assert.Equal(3, controller!.Rows.Count); @@ -158,7 +199,7 @@ public sealed class KeyboardConfigControllerTests ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); var fake = new FakeBindings(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; Assert.NotNull(controller); UiTabPanel tabHost = Assert.IsType(layout.FindElement(0x1000049Bu)); @@ -188,28 +229,28 @@ public sealed class KeyboardConfigControllerTests } [Fact] - public void Bind_MapsKnownActionsAndLeavesUnknownOnesUnmapped() + public void Bind_MapsEveryRetailActionRow() { var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward - Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> no InputAction + Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // -> EmoteBowDeep }); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); var fake = new FakeBindings(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); Assert.Equal(InputAction.MovementForward, forward.MappedAction); KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u); - Assert.Null(bowDeep.MappedAction); + Assert.Equal(InputAction.EmoteBowDeep, bowDeep.MappedAction); } [Fact] - public void Bind_SeedsRowFromLiveBindings_MappedAndUnmapped() + public void Bind_SeedsEveryRowFromLiveBindings() { var snapshot = new RetailActionMapSnapshot(new[] { @@ -223,11 +264,14 @@ public sealed class KeyboardConfigControllerTests new(ChordW, InputAction.MovementForward), new(ChordUp, InputAction.MovementForward), }; - fake.Unmapped[(0x10000006u, 0x100000A0u)] = new List { ChordA }; + fake.Mapped[InputAction.EmoteBowDeep] = new List + { + new(ChordA, InputAction.EmoteBowDeep), + }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); Assert.Equal(new[] { ChordW, ChordUp }, forward.Model.Current); @@ -243,7 +287,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); Assert.NotEmpty(row.KeyButtons); @@ -257,8 +301,8 @@ public sealed class KeyboardConfigControllerTests Assert.Equal(InputAction.MovementForward, written.Action); Binding onlyBinding = Assert.Single(written.Value); Assert.Equal(ChordW, onlyBinding.Chord); - // No live binding existed at build time — falls back to the Binding - // record's own defaults (Press/Game), same as before M1. + // No live binding existed at build time — falls back to the retail + // action identity's activation/scope metadata. Assert.Equal(ActivationType.Press, onlyBinding.Activation); Assert.Equal(InputScope.Game, onlyBinding.Scope); Assert.Equal("W", row.KeyButtons[0].Label); @@ -272,7 +316,7 @@ public sealed class KeyboardConfigControllerTests fake.Mapped[InputAction.MovementForward] = new List { new(ChordW, InputAction.MovementForward) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -293,7 +337,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 42u }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -314,7 +358,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 9u }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -331,7 +375,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 0u }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -354,7 +398,7 @@ public sealed class KeyboardConfigControllerTests ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); var requests = new List<(uint LayoutId, uint ElementId)>(); KeyboardConfigController? controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings(), + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings(), resolveTemplateFont: (layoutId, elementId) => { requests.Add((layoutId, elementId)); @@ -382,7 +426,7 @@ public sealed class KeyboardConfigControllerTests }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); Assert.Equal(2, row.Model.Current.Count); @@ -400,7 +444,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); Assert.Empty(row.Model.Current); @@ -411,17 +455,17 @@ public sealed class KeyboardConfigControllerTests Assert.Empty(fake.MappedSets); } - /// S4 (2026-08-11 review): clicking "Mapping 3" (slot index 2) on a - /// row with NO existing bindings must land the captured chord on display - /// index 2, not collapse it onto index 0. + /// Retail SetBinding clamps a requested slot past the dense + /// current-list tail to Count. Mapping 3 on an empty row therefore appends + /// at Mapping 1. [Fact] - public void KeyButtonClick_OnSparseRow_ThirdSlotLandsOnThirdButton() + public void KeyButtonClick_PastDenseTail_AppendsAtFirstAvailableButton() { var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var fake = new FakeBindings(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); Assert.Equal(3, row.KeyButtons.Count); @@ -430,12 +474,10 @@ public sealed class KeyboardConfigControllerTests row.KeyButtons[2].OnClick!.Invoke(); // "Mapping 3" fake.Capture(ChordW); - Assert.Null(row.KeyButtons[0].Label); + Assert.Equal("W", row.KeyButtons[0].Label); Assert.Null(row.KeyButtons[1].Label); - Assert.Equal("W", row.KeyButtons[2].Label); + Assert.Null(row.KeyButtons[2].Label); - // The write to the live seam only ever carries the REAL chord — no - // default(KeyChord) padding leaks into the persisted Binding list. (InputAction Action, IReadOnlyList Value) written = Assert.Single(fake.MappedSets); Binding onlyBinding = Assert.Single(written.Value); Assert.Equal(ChordW, onlyBinding.Chord); @@ -453,7 +495,7 @@ public sealed class KeyboardConfigControllerTests fake.Mapped[InputAction.MovementBackup] = new List { new(ChordA, InputAction.MovementBackup) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au); @@ -463,6 +505,7 @@ public sealed class KeyboardConfigControllerTests // M3: nothing is applied yet — a confirm dialog is pending. Assert.NotNull(fake.PendingConfirm); + Assert.Equal("overwrite A Action 2A", fake.PendingConfirm?.Message); Assert.DoesNotContain(ChordA, forward.Model.Current); Assert.Contains(ChordA, backup.Model.Current); Assert.Empty(fake.Messages); @@ -473,6 +516,153 @@ public sealed class KeyboardConfigControllerTests Assert.DoesNotContain(ChordA, backup.Model.Current); } + [Fact] + public void Capture_BareShiftConflictWithRetailWalkMode_AlwaysPrompts() + { + var shift = new KeyChord( + Silk.NET.Input.Key.ShiftLeft, + ModifierMask.None); + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), // Move forward + Row(0x4, 0x32, RetailActionClass.Movement), // Toggle walk/run + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementWalkMode] = + [new Binding( + shift, + InputAction.MovementWalkMode, + ActivationType.Hold, + InputScope.Game)]; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, + snapshot, + MakeTemplateResolver(), + ResolveSyntheticString, + fake.ToBindings())!; + + KeyboardConfigController.RowView forward = controller.Rows.Single( + row => row.ActionId == 0x29u); + forward.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(shift); + + Assert.NotNull(fake.PendingConfirm); + Assert.DoesNotContain(shift, forward.Model.Current); + Assert.Equal( + [shift], + controller.Rows.Single(row => row.ActionId == 0x32u).Model.Current); + } + + [Fact] + public void Capture_ConflictWithMultipleRows_UsesRetailPluralBindingList() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + Row(0x4, 0x2A, RetailActionClass.Movement), + Row(0x4, 0x2B, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementBackup] = + new List { new(ChordA, InputAction.MovementBackup) }; + fake.Mapped[InputAction.MovementStop] = + new List { new(ChordA, InputAction.MovementStop) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + controller.Rows.Single(row => row.ActionId == 0x29u).KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordA); + + Assert.Equal( + "overwrite A\nAction 2A (A)\nAction 2B (A)", + fake.PendingConfirm?.Message); + } + + [Fact] + public void Refresh_UsesRetailExistingAndNewBindingTooltipTemplates() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementForward] = + new List { new(ChordW, InputAction.MovementForward) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + KeyboardConfigController.RowView row = Assert.Single(controller.Rows); + Assert.Equal("(W) existing binding", row.KeyButtons[0].TooltipText); + Assert.Equal("new binding", row.KeyButtons[1].TooltipText); + Assert.Equal("new binding", row.KeyButtons[2].TooltipText); + + row.KeyButtons[0].OnRightClick!.Invoke(); + Assert.Equal("new binding", row.KeyButtons[0].TooltipText); + } + + [Fact] + public void Capture_ChordAlreadyInAnotherSlotOfSameRow_IsRetailNoOp() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementForward] = new List + { + new(ChordW, InputAction.MovementForward), + new(ChordUp, InputAction.MovementForward), + }; + // Prove retail's same-row early return happens before the + // non-user-bindable conflict check too. + fake.Mapped[InputAction.AcdreamToggleAudioMute] = new List + { + new(ChordW, InputAction.AcdreamToggleAudioMute), + }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + KeyboardConfigController.RowView row = Assert.Single(controller.Rows); + row.KeyButtons[2].OnClick!.Invoke(); + fake.Capture(ChordW); + + Assert.Equal(new[] { ChordW, ChordUp }, row.Model.Current); + Assert.Null(fake.PendingConfirm); + Assert.Empty(fake.Messages); + Assert.Empty(fake.MappedSets); + } + + [Fact] + public void Capture_LeftOrRightMouseButton_RemainsArmedUntilSupportedInput() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings { WireInstructions = true }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + KeyboardConfigController.RowView row = Assert.Single(controller.Rows); + row.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(LeftMouse); + + Assert.NotNull(fake.PendingCapture); + Assert.Empty(fake.InstructionCloses); + Assert.Empty(row.Model.Current); + + fake.Capture(ChordW); + + Assert.Null(fake.PendingCapture); + Assert.Equal(new[] { ChordW }, row.Model.Current); + Assert.Equal(new[] { 7u }, fake.InstructionCloses); + } + [Fact] public void Capture_ConflictWithAnotherRow_DeclineLeavesBothRowsUnchanged() { @@ -486,7 +676,7 @@ public sealed class KeyboardConfigControllerTests fake.Mapped[InputAction.MovementBackup] = new List { new(ChordA, InputAction.MovementBackup) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au); @@ -500,7 +690,71 @@ public sealed class KeyboardConfigControllerTests } [Fact] - public void Capture_ConflictWithNonBindableAcdreamAction_RefusesWithoutADialog() + public void Capture_SharedChordAcrossNonConflictingCombatContexts_KeepsBothBindings() + { + const uint meleeMap = 0x10000003u; + const uint missileMap = 0x10000004u; + var conflicts = new Dictionary> + { + [meleeMap] = new HashSet { meleeMap }, + [missileMap] = new HashSet { missileMap }, + }; + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(meleeMap, 0x1000005Du, RetailActionClass.Combat), + Row(missileMap, 0x100000F1u, RetailActionClass.Combat), + }, conflicts); + var fake = new FakeBindings(); + fake.Mapped[InputAction.CombatAimLow] = + new List { new(ChordA, InputAction.CombatAimLow, Scope: InputScope.MissileCombat) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + KeyboardConfigController.RowView melee = controller.Rows.Single( + row => row.InputMapId == meleeMap); + KeyboardConfigController.RowView missile = controller.Rows.Single( + row => row.InputMapId == missileMap); + melee.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordA); + + Assert.Null(fake.PendingConfirm); + Assert.Contains(ChordA, melee.Model.Current); + Assert.Contains(ChordA, missile.Model.Current); + } + + [Fact] + public void Capture_SharedChordAcrossDatConflictingContexts_StillPrompts() + { + const uint movementMap = 0x4u; + const uint uiMap = 0x10000009u; + var conflicts = new Dictionary> + { + [movementMap] = new HashSet { movementMap, uiMap }, + }; + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(movementMap, 0x29u, RetailActionClass.Movement), + Row(uiMap, 0x10000019u, RetailActionClass.Ui), + }, conflicts); + var fake = new FakeBindings(); + fake.Mapped[InputAction.ToggleInventoryPanel] = + new List { new(ChordA, InputAction.ToggleInventoryPanel) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + KeyboardConfigController.RowView movement = controller.Rows.Single( + row => row.InputMapId == movementMap); + movement.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordA); + + Assert.NotNull(fake.PendingConfirm); + Assert.DoesNotContain(ChordA, movement.Model.Current); + } + + [Fact] + public void Capture_ConflictWithNonBindableAcdreamAction_ShowsRetailMessageDialog() { var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var fake = new FakeBindings(); @@ -510,16 +764,17 @@ public sealed class KeyboardConfigControllerTests new List { new(muteChord, InputAction.AcdreamToggleAudioMute) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(); forward.KeyButtons[0].OnClick!.Invoke(); fake.Capture(muteChord); - // S1: refused outright, no confirm dialog offered. + // S1: refused outright, with the distinct retail message dialog and no + // overwrite-confirmation dialog. Assert.Null(fake.PendingConfirm); Assert.DoesNotContain(muteChord, forward.Model.Current); - Assert.Contains("cannot overwrite", fake.Messages); + Assert.Contains(fake.Messages, message => message.Contains("cannot overwrite")); Assert.Equal(muteChord, Assert.Single(fake.Mapped[InputAction.AcdreamToggleAudioMute]).Chord); } @@ -540,14 +795,14 @@ public sealed class KeyboardConfigControllerTests new List { new(sharedChord, InputAction.AcdreamToggleAudioMute) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); forward.KeyButtons[0].OnClick!.Invoke(); fake.Capture(sharedChord); Assert.Null(fake.PendingConfirm); - Assert.Contains("cannot overwrite", fake.Messages); + Assert.Contains(fake.Messages, message => message.Contains("cannot overwrite")); Assert.DoesNotContain(sharedChord, forward.Model.Current); } @@ -558,7 +813,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -573,6 +828,102 @@ public sealed class KeyboardConfigControllerTests Assert.Equal(1, fake.ToggleCalls); } + [Fact] + public void LoadFile_ReplacesRowsAndRevertBaseline_AndRefreshesFilename() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementForward] = + [new Binding(ChordW, InputAction.MovementForward)]; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, + fake.ToProfileBindings())!; + + ((UiButton)layout.FindElement(0x10000027u)!).OnClick!.Invoke(); + Assert.NotNull(fake.PendingLoadCompleted); + + fake.Mapped[InputAction.MovementForward] = + [new Binding(ChordUp, InputAction.MovementForward)]; + fake.CurrentKeymapFilename = "friends.keymap"; + fake.PendingLoadCompleted!(); + + ActionKeyMapOptionRow row = controller.Rows.Single().Model; + Assert.Equal(new[] { ChordUp }, row.Current); + Assert.Equal(new[] { ChordUp }, row.Saved); + Assert.False(row.Changed); + UiText filename = (UiText)layout.FindElement(0x10000028u)!; + Assert.Equal("friends.keymap", Assert.Single(filename.LinesProvider()).Text); + } + + [Fact] + public void SaveAs_RefreshesActiveFilenameOnlyAfterSuccessfulCallback() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + _ = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, + fake.ToProfileBindings()); + UiText filename = (UiText)layout.FindElement(0x10000028u)!; + + ((UiButton)layout.FindElement(0x10000029u)!).OnClick!.Invoke(); + Assert.NotNull(fake.PendingSaveCompleted); + Assert.Equal("acdream.keymap", Assert.Single(filename.LinesProvider()).Text); + + fake.CurrentKeymapFilename = "alternate.keymap"; + fake.PendingSaveCompleted!(); + Assert.Equal("alternate.keymap", Assert.Single(filename.LinesProvider()).Text); + } + + [Fact] + public void RevertButton_IsEnabledExactlyWhileWorkingMapDiffersFromSavedMap() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementForward] = + new List { new(ChordW, InputAction.MovementForward) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + UiButton revert = (UiButton)layout.FindElement(0x1000002Bu)!; + + // gmKeyboardUI::OnOptionChanged @ 0x004DA890: Ghosted while clean. + Assert.False(revert.Enabled); + + KeyboardConfigController.RowView row = controller.Rows.Single(); + row.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordUp); + Assert.True(controller.Page.Changed); + Assert.True(revert.Enabled); + + revert.OnClick!.Invoke(); + Assert.Equal(new[] { ChordW }, row.Model.Current); + Assert.False(controller.Page.Changed); + Assert.False(revert.Enabled); + + // Defaults is also a live uncommitted edit when the DAT default does + // not equal the saved user map, and therefore re-enables Revert. + UiButton defaults = (UiButton)layout.FindElement(0x1000002Au)!; + defaults.OnClick!.Invoke(); + Assert.True(controller.Page.Changed); + Assert.True(revert.Enabled); + + UiButton ok = (UiButton)layout.FindElement(0x1000002Cu)!; + ok.OnClick!.Invoke(); + Assert.False(controller.Page.Changed); + Assert.False(revert.Enabled); + } + [Fact] public void CancelButton_RevertsUncommittedEditAndToggles() { @@ -581,7 +932,7 @@ public sealed class KeyboardConfigControllerTests fake.Mapped[InputAction.MovementForward] = new List { new(ChordW, InputAction.MovementForward) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -607,7 +958,7 @@ public sealed class KeyboardConfigControllerTests fake.Mapped[InputAction.MovementForward] = new List { new(ChordUp, InputAction.MovementForward) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); Assert.Equal(new[] { ChordUp }, row.Model.Current); @@ -639,7 +990,7 @@ public sealed class KeyboardConfigControllerTests }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; UiButton defaultsButton = (UiButton)layout.FindElement(0x1000002Au)!; defaultsButton.OnClick!.Invoke(); @@ -665,7 +1016,7 @@ public sealed class KeyboardConfigControllerTests }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -695,20 +1046,24 @@ public sealed class KeyboardConfigControllerTests Row(0x6, 0x35, RetailActionClass.Camera, defaults: new[] { new RetailKeyChord(0xCB, 0, 0, 3) }), }); var fake = new FakeBindings(); + fake.Mapped[InputAction.CameraRotateLeft] = + [new Binding(ChordA, InputAction.CameraRotateLeft)]; + fake.Mapped[InputAction.CameraAlternateRotateLeft] = + [new Binding( + ChordUp, + InputAction.CameraAlternateRotateLeft, + Scope: InputScope.Camera)]; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView ctx5 = controller.Rows.Single(r => r.InputMapId == 0x5u); KeyboardConfigController.RowView ctx6 = controller.Rows.Single(r => r.InputMapId == 0x6u); Assert.Equal(InputAction.CameraRotateLeft, ctx5.MappedAction); - Assert.Null(ctx6.MappedAction); // unmapped — no live dual-binding infrastructure (M2) + Assert.Equal(InputAction.CameraAlternateRotateLeft, ctx6.MappedAction); - // Round-2 SHOULD-FIX: an unmapped row with no persisted chords now - // DISPLAYS its DAT defaults (retail shows the arrow keys; blank read - // as "unbound"). Display-only — storage stays untouched until the - // user edits THIS row. + // Both rows display their independent live bindings. Assert.NotEmpty(ctx6.Model.Current); var ctx6InitialDisplay = ctx6.Model.Current.ToArray(); @@ -717,13 +1072,15 @@ public sealed class KeyboardConfigControllerTests fake.Capture(ChordW); Assert.Contains(ChordW, ctx5.Model.Current); Assert.Equal(ctx6InitialDisplay, ctx6.Model.Current); // unchanged by ctx5's edit - Assert.Empty(fake.Unmapped); // ctx6's STORE untouched — display seeding writes nothing + Assert.Empty(fake.Unmapped); ctx6.KeyButtons[0].OnClick!.Invoke(); fake.Capture(ChordA); Assert.Contains(ChordA, ctx6.Model.Current); Assert.Contains(ChordW, ctx5.Model.Current); // ctx5 unaffected by ctx6's edit - Assert.True(fake.Unmapped.ContainsKey((0x6u, 0x35u))); + Assert.Contains(fake.MappedSets, write => + write.Action == InputAction.CameraAlternateRotateLeft + && write.Value.Any(binding => binding.Chord == ChordA)); } [Fact] @@ -756,30 +1113,26 @@ public sealed class KeyboardConfigControllerTests + $"and (0x{mapId:X}, 0x{actionId:X}) — aliasing reintroduces the M2 twin-row clobber."); seen[action] = (mapId, actionId); } - Assert.True(seen.Count > 100, $"sanity: only {seen.Count} mapped actions seen"); + Assert.Equal(306, seen.Count); } // ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ─────────── [Fact] - public void UnmappedRows_DimTheirCaption_MappedRowsStayWhite() + public void EveryRetailRowCaptionIsEnabled() { - // AP-203's store-only set: a row whose RetailActionIdentityTable - // lookup fails (MappedAction null -- mostly Emotes/CharacterSettings) - // never reaches the InputDispatcher, so its caption dims. Wiring a - // future mapping for "Bow Deep" (or any other unmapped row) means - // this assertion flips from StoreOnlyCaptionColor to Vector4.One -- - // a conscious edit, not a silent pass. + // Campaign KB removes AP-203's store-only set. Every DAT row now has + // a live dispatcher identity and uses the enabled retail caption color. var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward (mapped) - Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> unmapped + Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // -> EmoteBowDeep }); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); var fake = new FakeBindings(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); Assert.NotNull(forward.MappedAction); @@ -787,9 +1140,9 @@ public sealed class KeyboardConfigControllerTests Assert.Equal(Vector4.One, forwardCaption.DefaultColor); KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u); - Assert.Null(bowDeep.MappedAction); + Assert.Equal(InputAction.EmoteBowDeep, bowDeep.MappedAction); UiText bowDeepCaption = RowCaption(bowDeep); - Assert.Equal(UiRenderContext.StoreOnlyCaptionColor, bowDeepCaption.DefaultColor); + Assert.Equal(Vector4.One, bowDeepCaption.DefaultColor); } /// The row's synthesized caption (composed beside the authored key diff --git a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigInstalledDatConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigInstalledDatConformanceTests.cs new file mode 100644 index 00000000..e267538b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigInstalledDatConformanceTests.cs @@ -0,0 +1,184 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Content; +using AcDream.Core.Input; +using AcDream.Content; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Production-shaped installed-DAT gate for #446. The Core conformance lane +/// proves the 306 identities/defaults; this gate proves the actual retained +/// screen can import its authored layout and row template and expose every one +/// of those identities as a live three-slot row. +/// +[Trait("Lane", "InstalledDat")] +public sealed class KeyboardConfigInstalledDatConformanceTests +{ + [Fact] + public void InstalledEorLayout_MountsEveryBindableActionAsALiveRow() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); + + using var dats = new AcDream.App.Tests.BoundedTestDatCollection(datDir); + var strings = new DatStringResolver(dats); + ElementInfo? info = LayoutImporter.ImportInfos( + dats, + KeyboardConfigController.LayoutId); + Assert.NotNull(info); + + ImportedLayout layout = LayoutImporter.Build( + info!, + _ => (0u, 0, 0), + datFont: null, + fontResolve: null, + strings.Resolve); + RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read( + (IDatObjectSource)dats); + Assert.NotNull(snapshot); + + KeyBindings live = KeyBindings.RetailDefaults(); + KeyboardConfigController? controller = KeyboardConfigController.Bind( + layout, + snapshot!, + templateResolver: (templateLayoutId, templateElementId) => + { + ElementInfo? template = LayoutImporter.ImportInfos( + dats, + templateLayoutId, + templateElementId); + return template is null + ? null + : LayoutImporter.Build( + template, + _ => (0u, 0, 0), + datFont: null, + fontResolve: null, + strings.Resolve, + templateLayoutId).Root; + }, + resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId), + new KeyboardConfigController.Bindings( + CurrentForAction: action => live.ForAction(action).ToArray(), + SetForAction: (_, _) => { }, + CurrentForUnmapped: _ => Array.Empty(), + SetForUnmapped: (_, _) => { }, + BeginCapture: _ => { }, + Save: () => { }, + Toggle: () => { }, + ResolveTemplate: (key, variables) => + strings.ResolveTemplate(0x23000004u, key, variables), + ShowMessage: _ => { }, + ConfirmOverwrite: (_, _) => { }, + CurrentKeymapFilename: () => "acdream.keymap", + OpenLoadKeymap: completed => completed(), + OpenSaveKeymap: completed => completed())); + + Assert.NotNull(controller); + Assert.Equal(306, snapshot!.Rows.Count); + Assert.Equal(snapshot.Rows.Count, controller!.Rows.Count); + Assert.Equal(306, controller.Page.Rows.Count); + Assert.All(controller.Rows, row => + { + Assert.NotNull(row.MappedAction); + Assert.False(string.IsNullOrWhiteSpace(row.Label)); + Assert.Equal(3, row.KeyButtons.Count); + Assert.All(row.KeyButtons, button => + { + Assert.NotNull(button.OnClick); + Assert.NotNull(button.OnRightClick); + Assert.False(string.IsNullOrWhiteSpace(button.TooltipText)); + }); + }); + Assert.Equal( + snapshot.Rows.Select(static row => (row.InputMapId, row.ActionId)).ToHashSet(), + controller.Rows.Select(static row => (row.InputMapId, row.ActionId)).ToHashSet()); + + uint key = DatStringResolver.ComputeHash("KEY"); + uint action = DatStringResolver.ComputeHash("ACTION"); + uint bindings = DatStringResolver.ComputeHash("BINDINGS"); + Assert.Equal( + "'Ctrl+M' is currently bound to a non user-bindable action. Please select a different binding.", + strings.ResolveTemplate( + 0x23000004u, + "ID_ActionKeyMap_NonUserBindableBinding", + new Dictionary { [key] = "Ctrl+M" })); + Assert.Equal( + "'A' is currently bound to 'Move Backward'. Do you wish to erase that binding?", + strings.ResolveTemplate( + 0x23000004u, + "ID_ActionKeyMap_OverwriteExistingBinding", + new Dictionary + { + [key] = "A", + [action] = "Move Backward", + })); + Assert.Equal( + "'A' conflicts with the following bindings:\n'Move Backward' ('A')\n'Turn Right' ('A')\nDo you wish to erase those bindings?", + strings.ResolveTemplate( + 0x23000004u, + "ID_ActionKeyMap_OverwriteExistingBindings", + new Dictionary + { + [key] = "A", + [bindings] = "'Move Backward' ('A')\n'Turn Right' ('A')", + })); + + foreach (uint buttonId in new[] + { + 0x10000027u, // Load File + 0x10000029u, // Save As + 0x1000002Au, // Defaults + 0x1000002Bu, // Revert + 0x1000002Cu, // OK + 0x1000002Du, // Cancel + }) + { + UiButton button = Assert.IsType(layout.FindElement(buttonId)); + Assert.NotNull(button.OnClick); + } + + UiText filename = Assert.IsType(layout.FindElement(0x10000028u)); + Assert.Equal("acdream.keymap", Assert.Single(filename.LinesProvider()).Text); + + // The same installed catalog must contain the type-7 presenter retail's + // Load File button opens: root 0x1F, menu 0x21, accept/reject 0x22/0x23. + uint dialogLayoutId = RetailDataIdResolver.Resolve(dats, 2u, 5u); + Assert.NotEqual(0u, dialogLayoutId); + ElementInfo? menuInfo = LayoutImporter.ImportInfos( + dats, + dialogLayoutId, + RetailConfirmationMenuDialogView.RootElementId); + Assert.NotNull(menuInfo); + ImportedLayout menuLayout = LayoutImporter.Build( + menuInfo!, _ => (0u, 0, 0), null, null, strings.Resolve); + Assert.IsType(menuLayout.Root); + UiMenu catalogMenu = Assert.IsType(menuLayout.FindElement( + RetailConfirmationMenuDialogView.MenuElementId)); + Assert.NotEqual(0u, catalogMenu.NormalSprite); + Assert.NotEqual(0u, catalogMenu.PressedSprite); + Assert.NotEqual(0u, catalogMenu.PopupBgSprite); + Assert.NotEqual(0u, catalogMenu.ItemNormalSprite); + Assert.IsType(menuLayout.FindElement( + RetailConfirmationMenuDialogView.AcceptButtonId)); + Assert.IsType(menuLayout.FindElement( + RetailConfirmationMenuDialogView.RejectButtonId)); + } + + private static string? ResolveDatDir() + { + string? fromEnvironment = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnvironment) && Directory.Exists(fromEnvironment)) + return fromEnvironment; + + string installed = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + return Directory.Exists(installed) ? installed : null; + } + +} diff --git a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs index 430e2114..e37b83ee 100644 --- a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs @@ -221,6 +221,13 @@ public sealed class KeyboardConfigLiveMountProbeTests string[] keys = { "ID_ActionKeyMap_MapInstructions", + "ID_ActionKeyMap_Binding", + "ID_ActionKeyMap_ButtonLabel", + "ID_ActionKeyMap_NonUserBindableBinding", + "ID_ActionKeyMap_OverwriteExistingBinding", + "ID_ActionKeyMap_OverwriteExistingBindings", + "ID_ActionKeyMap_TT_ExistingBinding", + "ID_ActionKeyMap_TT_NewBinding", "ID_KeyDescDelimiter", "ID_KeyNameWithSubControl", "ID_KeyMapCantOverwriteReadOnlyKeymap_Label", @@ -251,7 +258,11 @@ public sealed class KeyboardConfigLiveMountProbeTests } // Candidate variable-name hashes for the MapInstructions template slot. - foreach (string candidate in new[] { "ACTION", "NAME", "KEY", "SUBCONTROL", "PLAYER", "COMMAND" }) + foreach (string candidate in new[] + { + "ACTION", "BINDINGS", "KEY", "LABEL", "VALUE", + "NAME", "SUBCONTROL", "PLAYER", "COMMAND", + }) Console.WriteLine( $"[kbstr] hash('{candidate}') = 0x{DatStringResolver.ComputeHash(candidate):X8}"); diff --git a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs index f68475fe..f96352b6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs @@ -214,6 +214,22 @@ public sealed class MapHousePanelControllerTests Assert.Contains("house-shown", calls); } + [Fact] + public void ShowMap_UsesTheSameAuthoredTabStateAsAClick() + { + ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); + ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); + MapHousePanelController controller = MapHousePanelController.Bind( + rootInfo, layout, MakeCallbacks())!; + controller.ActivateTabs(); + controller.TabPanel.SwitchTo(0x100001F7u); // House + + controller.ShowMap(); + + Assert.True(controller.IsShowingMap); + Assert.False(controller.IsShowingHouse); + } + [Fact] public void CloseButton_InvokesToggle() { diff --git a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs index d0cad449..8efdc9fb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs @@ -162,6 +162,22 @@ public sealed class OptionsPanelControllerTests Assert.Equal(["flush"], flushes); } + [Fact] + public void ShowGameplay_UsesTheSameAuthoredTabStateAsAClick() + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + var calls = new List(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, MakeCallbacks(calls))!; + controller.ActivateTabs(); + controller.TabPanel.SwitchTo(0x10000211u); // Character + + controller.ShowGameplay(); + + Assert.True(controller.IsShowingGameplay); + Assert.Equal(0x10000212u, controller.TabPanel.ActivePageElementId); + } + [Fact] public void WholeWindowHide_RevertsCurrentlyActivePage() { diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index ff74b09f..851c8241 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -254,7 +254,7 @@ public class PaperdollControllerTests } [Fact] - public void HandleDropRelease_wields_optimistically_and_sends_wire() + public void HandleDropRelease_sendsWieldAndWaitsForServerPlacement() { var (layout, lists) = BuildLayout(); var objects = new ClientObjectTable(); @@ -263,8 +263,8 @@ public class PaperdollControllerTests var ctrl = Bind(layout, objects, wields); var payload = new ItemDragPayload(0xD01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell); ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload); - Assert.Equal(EquipMask.HeadWear, objects.Get(0xD01u)!.CurrentlyEquippedLocation); // equipped instantly - Assert.Equal(Player, objects.Get(0xD01u)!.ContainerId); // contained-by-wielder (the optimistic wield is ContainerId-based; it does NOT write WielderId) + Assert.Equal(EquipMask.None, objects.Get(0xD01u)!.CurrentlyEquippedLocation); + Assert.Equal(Pack, objects.Get(0xD01u)!.ContainerId); Assert.Single(wields); Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire } @@ -334,10 +334,10 @@ public class PaperdollControllerTests Assert.Equal(EquipMask.None, objects.Get(sword)!.CurrentlyEquippedLocation); Assert.Equal(new[] { "Moving Shortbow to your backpack" }, messages); - objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.True(objects.ApplyConfirmedServerMove(bow, Player, 0u, 0)); Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, wields); - Assert.Equal(EquipMask.MeleeWeapon, + Assert.Equal(EquipMask.None, objects.Get(sword)!.CurrentlyEquippedLocation); } @@ -371,7 +371,7 @@ public class PaperdollControllerTests ctrl.HandleDropRelease(lists[ChestSlot], lists[ChestSlot].Cell, payload); Assert.Equal((uint)coatMask, wields[0].mask); - Assert.Equal(coatMask, objects.Get(0xE02u)!.CurrentlyEquippedLocation); + Assert.Equal(EquipMask.None, objects.Get(0xE02u)!.CurrentlyEquippedLocation); } [Fact] @@ -392,7 +392,7 @@ public class PaperdollControllerTests ctrl.HandleDropRelease(lists[ChestArmorSlot], lists[ChestArmorSlot].Cell, payload); Assert.Equal((uint)hauberkMask, wields[0].mask); - Assert.Equal(hauberkMask, objects.Get(0xE03u)!.CurrentlyEquippedLocation); + Assert.Equal(EquipMask.None, objects.Get(0xE03u)!.CurrentlyEquippedLocation); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index 17ac70da..6bea5ec7 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -55,6 +55,42 @@ public sealed class RetailDialogFactoryTests Assert.False(factory.IsOpen); } + [Fact] + public void ConfirmationMenu_ReturnsSelectedIndex_AndRejectReturnsMinusOne() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = new RetailDialogFactory(root, type => + { + ImportedLayout layout = BuildDialogLayout(type); + layouts.Add(layout); + return layout; + }); + int? result = null; + + factory.MakeConfirmationMenu( + new[] { "acdream.keymap", "friends.keymap" }, + selectedIndex: 1, + data => result = data.GetInt32(RetailDialogProperty.MenuSelection)); + + ImportedLayout first = Assert.Single(layouts); + UiMenu menu = Assert.IsType( + first.FindElement(RetailConfirmationMenuDialogView.MenuElementId)); + Assert.Equal(1, menu.Selected); + menu.Selected = 0; + Button(first, RetailConfirmationMenuDialogView.AcceptButtonId).OnClick!(); + Assert.Equal(0, result); + + result = null; + factory.MakeConfirmationMenu( + new[] { "acdream.keymap" }, + selectedIndex: 0, + data => result = data.GetInt32(RetailDialogProperty.MenuSelection)); + ImportedLayout second = layouts[^1]; + Button(second, RetailConfirmationMenuDialogView.RejectButtonId).OnClick!(); + Assert.Equal(-1, result); + } + [Fact] public void SameQueuePresentsFifoUsingFreshLiveRoots() { @@ -691,6 +727,7 @@ public sealed class RetailDialogFactoryTests { RetailDialogType.Message => 0x17u, RetailDialogType.ConfirmationTextInput => 0x15u, + RetailDialogType.ConfirmationMenu => 0x14u, RetailDialogType.Wait => 0x19u, _ => 0x13u, }; @@ -708,15 +745,18 @@ public sealed class RetailDialogFactoryTests Width = 400f, Height = type == RetailDialogType.ConfirmationTextInput ? 125f : 95f, }; - popup.Children.Add(new ElementInfo + if (type != RetailDialogType.ConfirmationMenu) { - Id = 0x3Eu, - Type = 12u, - X = 15f, - Y = 15f, - Width = 370f, - Height = 18f, - }); + popup.Children.Add(new ElementInfo + { + Id = 0x3Eu, + Type = 12u, + X = 15f, + Y = 15f, + Width = 370f, + Height = 18f, + }); + } if (type == RetailDialogType.Message) { popup.Children.Add(new ElementInfo @@ -793,6 +833,36 @@ public sealed class RetailDialogFactoryTests Height = 32f, }); } + else if (type == RetailDialogType.ConfirmationMenu) + { + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationMenuDialogView.MenuElementId, + Type = 6u, + X = 80f, + Y = 15f, + Width = 240f, + Height = 24f, + }); + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationMenuDialogView.AcceptButtonId, + Type = 1u, + X = 80f, + Y = 48f, + Width = 80f, + Height = 32f, + }); + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationMenuDialogView.RejectButtonId, + Type = 1u, + X = 240f, + Y = 48f, + Width = 80f, + Height = 32f, + }); + } root.Children.Add(popup); return LayoutImporter.Build(root, _ => (0u, 0, 0), null); } diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs index af85104f..fc36a0f0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs @@ -54,9 +54,8 @@ public sealed class RetailKeyNamesTests [Fact] public void SelfModifier_ShowsOnlyTheKeyName_NeverShiftPlusShiftLeft() { - // acdream's wire-side chord for retail's bare DIK_LSHIFT walk-mode row - // carries the self-modifier bit; retail's QualifiedControl has - // meta-mode 0 and displays just the key. + // Also accept a legacy self-modifier bit while migrated JSON is read; + // retail's exact row has meta-mode 0 and displays just the key. var names = new RetailKeyNames( NoStrings, osKeyName: (scan, _) => scan == 0x2A ? "SKIFT" : null); @@ -130,15 +129,37 @@ public sealed class RetailKeyNamesTests } [Fact] - public void ControlsOutsideTheDikTable_KeepTheEnumSpelling() + public void KeymapInterchangeControls_UseTheirRetailDikNames() { var names = new RetailKeyNames(NoStrings, osKeyName: (_, _) => null); - // Key.F13 never appears in the DAT's 84 observed scan codes. Assert.Equal("F13", names.Describe(new KeyChord(Key.F13, ModifierMask.None))); Assert.Equal( - "Shift+F13", + "LSHIFT+F13", names.Describe(new KeyChord(Key.F13, ModifierMask.Shift))); + Assert.Equal( + "LWIN+F13", + names.Describe(new KeyChord(Key.F13, ModifierMask.Win))); + } + + [Fact] + public void MouseButtonUsesRetailSemanticTableThenReadableFallback() + { + var authored = new RetailKeyNames( + Table((RetailKeyNames.KeyNameTableId, "DIMOFS_BUTTON0", "Primary Mouse")), + osKeyName: (_, _) => null); + var fallback = new RetailKeyNames(NoStrings, osKeyName: (_, _) => null); + var left = new KeyChord( + InputDispatcher.MouseButtonToKey(MouseButton.Left), + ModifierMask.None, + Device: 1); + var rightWithCtrl = new KeyChord( + InputDispatcher.MouseButtonToKey(MouseButton.Right), + ModifierMask.Ctrl, + Device: 1); + + Assert.Equal("Primary Mouse", authored.Describe(left)); + Assert.Equal("LCONTROL+Mouse Button 2", fallback.Describe(rightWithCtrl)); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs index 20f8e9ee..46069cf0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs @@ -94,6 +94,8 @@ public class SelectedObjectControllerTests public readonly Dictionary HasHealthMap = new(); public readonly Dictionary ManaMap = new(); public readonly Dictionary StackMap = new(); + public readonly Dictionary CoinstackMap = new(); + public int CoinTotal; // Slice 6.2: vendor-owned split-exempt predicate — see // SelectedObjectController.Bind's isVendorSplitExempt parameter. public readonly Dictionary VendorSplitExemptMap = new(); @@ -139,7 +141,9 @@ public class SelectedObjectControllerTests { if (ObjectUpdatedHandler == h) ObjectUpdatedHandler = null; }, - isVendorSplitExempt: g => VendorSplitExemptMap.TryGetValue(g, out var v) && v); + isVendorSplitExempt: g => VendorSplitExemptMap.TryGetValue(g, out var v) && v, + isCoinstack: g => CoinstackMap.TryGetValue(g, out var v) && v, + coinTotal: () => CoinTotal); } // ── B1: Bind initialisation ────────────────────────────────────────────── @@ -165,6 +169,25 @@ public class SelectedObjectControllerTests Assert.True(nameEl.ZOrder > 1000, "name element must be floated above the overlay/meter z-order"); } + [Fact] + public void OwnedCoinstack_usesRetailsExactStackNameAndTotalFormat() + { + var (layout, nameEl, _, _) = FakeLayout(); + var h = new Harness { CoinTotal = 12_345 }; + const uint coins = 0x50000111u; + h.NameMap[coins] = "Pyreals"; + h.StackMap[coins] = 2_345u; + h.OwnedMap[coins] = true; + h.CoinstackMap[coins] = true; + h.Bind(layout); + + h.FireSelection(coins); + + Assert.Equal( + "2345 Pyreals (of 12345)", + nameEl.Children.OfType().First().LinesProvider().Single().Text); + } + [Fact] public void Bind_nameLinesProvider_yieldsEmpty_whenNothingSelected() { diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingShortcutInputTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingShortcutInputTests.cs new file mode 100644 index 00000000..7027e772 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingShortcutInputTests.cs @@ -0,0 +1,47 @@ +using AcDream.App.UI.Layout; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class SpellcastingShortcutInputTests +{ + [Fact] + public void EveryRetailFavoriteSpellSlotHasALiveConsumer() + { + InputAction[] slots = RetailActionIdentityTable.Map + .Where(entry => entry.Key.InputMapId == 0x10000005u) + .Select(entry => entry.Value) + .Where(action => action.ToString().StartsWith( + "UseSpellSlot_", + StringComparison.Ordinal)) + .ToArray(); + + Assert.Equal(12, slots.Length); + Assert.Equal( + Enumerable.Range(0, 12), + slots.Select(action => + { + Assert.True( + SpellcastingUiController.TryMapSpellShortcut( + action, + out int index), + $"No favorite-spell consumer for {action}"); + return index; + }).Order()); + } + + [Theory] + [InlineData(InputAction.UseSpellSlot_1, 0)] + [InlineData(InputAction.UseSpellSlot_9, 8)] + [InlineData(InputAction.UseSpellSlot_10, 9)] + [InlineData(InputAction.UseSpellSlot_11, 10)] + [InlineData(InputAction.UseSpellSlot_12, 11)] + public void AllRetailSpellSlotsMapToFavoriteIndex( + InputAction action, + int expectedIndex) + { + Assert.True( + SpellcastingUiController.TryMapSpellShortcut(action, out int index)); + Assert.Equal(expectedIndex, index); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarInputControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarInputControllerTests.cs index 17ad3a13..8223438b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarInputControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarInputControllerTests.cs @@ -5,11 +5,41 @@ namespace AcDream.App.Tests.UI.Layout; public sealed class ToolbarInputControllerTests { + [Fact] + public void EveryRetailQuickslotRowHasALiveToolbarConsumer() + { + InputAction[] actions = RetailActionIdentityTable.Map + .Where(entry => entry.Key.InputMapId == 0x1000000Cu) + .OrderBy(entry => entry.Key.ActionId) + .Select(entry => entry.Value) + .ToArray(); + + Assert.Equal(28, actions.Length); + Assert.Equal(InputAction.CreateShortcut, actions[21]); + foreach (InputAction action in actions) + { + if (action == InputAction.CreateShortcut) + continue; + + Assert.True( + ToolbarInputController.TryMapShortcut( + action, + out int slot, + out _), + $"No toolbar consumer for {action}"); + Assert.InRange(slot, 0, 17); + } + } + [Theory] [InlineData(InputAction.UseQuickSlot_1, 0, true)] [InlineData(InputAction.UseQuickSlot_9, 8, true)] [InlineData(InputAction.SelectQuickSlot_1, 0, false)] [InlineData(InputAction.SelectQuickSlot_9, 8, false)] + [InlineData(InputAction.UseQuickSlot_10, 9, true)] + [InlineData(InputAction.UseQuickSlot_11, 10, true)] + [InlineData(InputAction.UseQuickSlot_12, 11, true)] + [InlineData(InputAction.UseQuickSlot_13, 12, true)] [InlineData(InputAction.UseQuickSlot_14, 13, true)] [InlineData(InputAction.UseQuickSlot_18, 17, true)] public void ShortcutActions_mapRetailSlotAndIntent(InputAction action, int slot, bool use) diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index 389fafc2..c9082885 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -206,6 +206,7 @@ public sealed class VendorUiControllerTests public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new(); public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new(); public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new(); + public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new(); public readonly List SystemMessages = new(); public readonly ItemInteractionController ItemInteraction; public readonly RetailDialogFactory Dialogs; @@ -365,6 +366,9 @@ public sealed class VendorUiControllerTests sendWield: null, sendDrop: null, sendExamine: Examines.Add, + systemMessage: SystemMessages.Add, + sendSplitToContainer: (item, container, placement, amount) => + SplitPuts.Add((item, container, placement, amount)), sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) => { Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId)); @@ -655,6 +659,47 @@ public sealed class VendorUiControllerTests GetText(h.ItemCostText)); } + [Fact] + public void AlternateCurrencyPurchaseUpdatesImmediatelyThenReconcilesToInventory() + { + var h = new Harness(); + const uint currencyWcid = 0x12345678u; + const uint currencyGuid = 0x60000A01u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = currencyGuid, + WeenieClassId = currencyWcid, + Name = "Colosseum Coin", + Type = ItemType.Misc, + StackSize = 10, + }); + h.Objects.MoveItem(currencyGuid, Harness.PlayerGuid, 0); + h.State.Apply( + VendorGuid, + Profile(sellRate: 1.0f, altCurrency: currencyWcid, altName: "Colosseum Coins", altAmount: 10u), + new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 2), + }); + + h.BuyButton.OnClick!.Invoke(); + + Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText)); + Assert.Equal("You have 8 Colosseum Coins.", GetText(h.BuyPurseText)); + + // An unrelated appraisal/property refresh on the currency object is + // not an authoritative count response and must not erase m_last_sale. + Assert.True(h.Objects.UpdateIntProperty(currencyGuid, 0x7FFFu, 1)); + Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText)); + + // The server's stack update replaces the optimistic m_last_sale + // subtraction with the canonical inventory count without bouncing + // the displayed purse back to the stale vendor snapshot. + Assert.True(h.Objects.UpdateStackSize(currencyGuid, 8, value: 0)); + Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText)); + Assert.Equal("You have 8 Colosseum Coins.", GetText(h.BuyPurseText)); + } + [Fact] public void NoSelection_DisablesBuyButton_SelectionEnablesIt() { @@ -1850,6 +1895,29 @@ public sealed class VendorUiControllerTests Assert.Empty(h.Buys); } + [Fact] + public void DoubleClickStagedBuyingRow_RemovesOneUnitAndReportsRetailsNotice() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem( + StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000, + DescStackSize: 100), + }); + h.SplitQuantity.Reset(100u, initialValue: 3u); + h.AddButton.OnClick!.Invoke(); + + h.BuyingList.GetItem(0)!.DoubleClicked!.Invoke(); + + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + Assert.Equal(StackedItemGuid, h.Selection.SelectedObjectId); + Assert.Equal(new[] { "Removing Arrows from shopping list" }, h.SystemMessages); + h.BuyAllButton.OnClick!.Invoke(); + (_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls); + Assert.Equal(new[] { (2, StackedItemGuid) }, items); + } + /// /// F9 (Slice 6b/6c review): clicking a staged Buying-tab row must /// visibly move the highlight — a prior version of this port only @@ -1887,12 +1955,19 @@ public sealed class VendorUiControllerTests // Slice 6c — Selling tab drag-to-sell staging // ══════════════════════════════════════════════════════════════════════ - private static void MakePlayerOwned(Harness h, uint guid, ItemType type, int value, int stackSize = 1) + private static void MakePlayerOwned( + Harness h, + uint guid, + ItemType type, + int value, + int stackSize = 1, + uint weenieClassId = 0u) { h.Objects.AddOrUpdate(new ClientObject { ObjectId = guid, Name = $"Item {guid:X8}", + WeenieClassId = weenieClassId, Type = type, Value = value, StackSize = stackSize, @@ -2041,29 +2116,79 @@ public sealed class VendorUiControllerTests } /// - /// F6 (Slice 6b/6c review, byte-verified): sell staging ALWAYS records - /// the item's FULL stack — retail's AddItemToSell stages via a - /// LITERAL -1 "full stack" argument - /// (gmVendorUI::AddItem(..., -1, ...), pc:203595), never a - /// slider read. A prior version of this port read the LIVE split - /// slider here instead — this proves a PARTIAL slider selection at - /// drop time does not leak into the staged (or sent) quantity. + /// Retail's AcceptDragObject splits the selected amount first, retains + /// the source as a temporary staging row, then substitutes the newly + /// created split stack before Sell All is sent. /// [Fact] - public void HandleDropRelease_StackableItem_StagesTheFullStackIgnoringTheLiveSlider() + public void HandleDropRelease_PartialStack_SplitsThenStagesTheNewExactStack() { var h = new Harness(); h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty()); - MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20); + const uint wcid = 0x2345u; + const uint splitGuid = 0x60000222u; + MakePlayerOwned( + h, + PlayerOwnedWeaponGuid, + ItemType.MissileWeapon, + 100, + stackSize: 20, + weenieClassId: wcid); h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor); - h.SplitQuantity.Reset(20u, initialValue: 5u); // partial -- must be ignored + h.SplitQuantity.Reset(20u, initialValue: 5u); h.Controller.HandleDropRelease( h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid)); + + Assert.Equal( + new[] { (PlayerOwnedWeaponGuid, Harness.PlayerGuid, 0u, 5u) }, + h.SplitPuts); + Assert.Equal(PlayerOwnedWeaponGuid, h.SellingList.GetItem(0)!.ItemId); + Assert.Equal( + new[] { "Splitting the Item 60000202 before selling them" }, + h.SystemMessages); + + // SetStackSize completes the split request. CreateObject + placement + // identify the server-assigned split guid and replace the placeholder. + Assert.True(h.Objects.UpdateStackSize(PlayerOwnedWeaponGuid, 15, value: 75)); + MakePlayerOwned( + h, + splitGuid, + ItemType.MissileWeapon, + 25, + stackSize: 5, + weenieClassId: wcid); + Assert.Equal(splitGuid, h.SellingList.GetItem(0)!.ItemId); + h.SellAllButton.OnClick!.Invoke(); (_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells); - Assert.Equal(new (int Amount, uint ItemGuid)[] { (20, PlayerOwnedWeaponGuid) }, items); + Assert.Equal(new (int Amount, uint ItemGuid)[] { (5, splitGuid) }, items); + } + + [Fact] + public void HandleDropRelease_PartialStackFailure_RemovesTheTemporarySellRow() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty()); + MakePlayerOwned( + h, + PlayerOwnedWeaponGuid, + ItemType.MissileWeapon, + 100, + stackSize: 10, + weenieClassId: 0x2345u); + h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor); + h.SplitQuantity.Reset(10u, initialValue: 2u); + + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid)); + Assert.Equal(1, h.SellingList.GetNumUIItems()); + + h.Objects.RejectMove(PlayerOwnedWeaponGuid, weenieError: 0x29u); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Empty(h.Sells); } [Fact] @@ -2309,6 +2434,84 @@ public sealed class VendorUiControllerTests Assert.Empty(h.Sells); } + [Fact] + public void DoubleClickStagedSellingRow_RemovesTheEntryAndReportsRetailsNotice() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + h.SellingList.GetItem(0)!.DoubleClicked!.Invoke(); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId); + Assert.Equal( + new[] { "Removing Item 60000201 from shopping list" }, + h.SystemMessages); + Assert.Empty(h.Sells); + } + + [Fact] + public void DragStagedSellingRow_RemovesItAndPartialSelectionPrintsExactRefusalThenResets() + { + var h = new Harness(); + h.State.Apply( + VendorGuid, + SellProfile((uint)ItemType.MissileWeapon), + Array.Empty()); + MakePlayerOwned( + h, + PlayerOwnedWeaponGuid, + ItemType.MissileWeapon, + 100, + stackSize: 10); + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid)); + h.SplitQuantity.Reset(10u, initialValue: 2u); + UiItemSlot staged = h.SellingList.GetItem(0)!; + + h.Controller.OnDragLift( + h.SellingList, + staged, + new ItemDragPayload( + PlayerOwnedWeaponGuid, + ItemDragSource.Inventory, + staged.SlotIndex, + staged)); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Equal( + new[] { "You cannot split items from this panel" }, + h.SystemMessages); + Assert.Equal(10u, h.SplitQuantity.Value); + Assert.Equal(10u, h.SplitQuantity.Maximum); + } + + [Fact] + public void RightClickStagedBuyingAndSellingRows_SelectsAndExaminesBoth() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), new[] + { + new VendorShopItem( + ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + UiItemSlot buying = h.BuyingList.GetItem(0)!; + buying.OnEvent(new UiEvent(0u, buying, UiEventType.RightClick)); + UiItemSlot selling = h.SellingList.GetItem(0)!; + selling.OnEvent(new UiEvent(0u, selling, UiEventType.RightClick)); + + Assert.Equal(new[] { ArmorItemGuid, PlayerOwnedArmorGuid }, h.Examines); + Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId); + } + // ══════════════════════════════════════════════════════════════════════ // F10 (Slice 6b/6c review) — unstage on removal/dispossession. // ══════════════════════════════════════════════════════════════════════ diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs index 44b0144c..f120843e 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs @@ -297,10 +297,19 @@ public sealed class RetailUiInteractionFlowTests Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory)); Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields); + var pending = probe.AssertItem( + Hauberk, + equippedLocation: EquipMask.None, + containerId: Player); + Assert.True(pending.Success, pending.Message); + + Assert.True(h.Objects.ApplyConfirmedServerWield( + Hauberk, Player, HauberkMask)); + var state = probe.AssertItem( Hauberk, equippedLocation: HauberkMask, - containerId: Player); + containerId: 0u); Assert.True(state.Success, state.Message); } @@ -337,9 +346,15 @@ public sealed class RetailUiInteractionFlowTests Assert.Equal(EquipMask.MeleeWeapon, h.Objects.Get(Sword)!.CurrentlyEquippedLocation); - h.Objects.MoveItem(Sword, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(Sword, Player, 0u, 0)); Assert.Equal(new[] { (Bow, (uint)EquipMask.MissileWeapon) }, h.Wields); + Assert.Equal(EquipMask.None, + h.Objects.Get(Bow)!.CurrentlyEquippedLocation); + + Assert.True(h.Objects.ApplyConfirmedServerWield( + Bow, Player, EquipMask.MissileWeapon)); + Assert.Equal(EquipMask.MissileWeapon, h.Objects.Get(Bow)!.CurrentlyEquippedLocation); } @@ -453,6 +468,11 @@ public sealed class RetailUiInteractionFlowTests Assert.True(probe.DragItemOutside(Hauberk, 700, 500, ItemDragSource.Inventory)); Assert.Equal(new[] { Hauberk }, h.Drops); + var pending = probe.AssertItem(Hauberk, containerId: Player, slot: 0); + Assert.True(pending.Success, pending.Message); + + Assert.True(h.Objects.ApplyConfirmedServerMove(Hauberk, 0u, 0u, -1)); + var state = probe.AssertItem(Hauberk, containerId: 0u, slot: -1); Assert.True(state.Success, state.Message); } @@ -470,10 +490,19 @@ public sealed class RetailUiInteractionFlowTests Assert.True(probe.DragItemToElement(Hauberk, ChestArmorSlotId, ItemDragSource.Inventory)); Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields); + var pending = probe.AssertItem( + Hauberk, + equippedLocation: EquipMask.None, + containerId: Player); + Assert.True(pending.Success, pending.Message); + + Assert.True(h.Objects.ApplyConfirmedServerWield( + Hauberk, Player, HauberkMask)); + var state = probe.AssertItem( Hauberk, equippedLocation: HauberkMask, - containerId: Player); + containerId: 0u); Assert.True(state.Success, state.Message); } } diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index effd8015..41d7f19d 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -6,6 +6,44 @@ namespace AcDream.App.Tests.UI; public class UiRootInputTests { + [Fact] + public void KeypadEnter_DoesNotUseTheRawChatActivationFallback() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var field = new UiField { Width = 100, Height = 20 }; + root.AddChild(field); + root.DefaultTextInput = field; + + root.OnKeyDown((int)Silk.NET.Input.Key.KeypadEnter); + + Assert.Null(root.KeyboardFocus); + } + + [Fact] + public void SemanticChatActivation_SuppressesTheSameNativeEnterTail() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var field = new UiField { Width = 100, Height = 20 }; + int submissions = 0; + field.SetText("hello"); + field.OnSubmit = _ => submissions++; + root.AddChild(field); + root.DefaultTextInput = field; + root.SetKeyboardFocus(field); + root.SuppressPhysicalKeyUntilRelease(Silk.NET.Input.Key.Enter); + + root.OnKeyDown((int)Silk.NET.Input.Key.Enter); + root.OnChar('x'); + + Assert.Equal(0, submissions); + Assert.Equal("hello", field.Text); + Assert.Same(field, root.KeyboardFocus); + + root.OnKeyUp((int)Silk.NET.Input.Key.Enter); + root.OnChar('x'); + Assert.Equal("hellox", field.Text); + } + [Fact] public void UiNineSlicePanel_IsNotAnchorManaged_SoUserMoveResizeSticks() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs index 6142171a..fdfd6d2a 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs @@ -67,6 +67,7 @@ public sealed class ClientCommandRequestsTests { { ClientCommandRequests.BuildSetAfkMessage, ClientCommandRequests.SetAfkMessageOpcode }, { ClientCommandRequests.BuildEmote, ClientCommandRequests.EmoteOpcode }, + { ClientCommandRequests.BuildSoulEmote, ClientCommandRequests.SoulEmoteOpcode }, { ClientCommandRequests.BuildAddFriend, ClientCommandRequests.AddFriendOpcode }, { ClientCommandRequests.BuildRemoveConsent, ClientCommandRequests.RemoveConsentOpcode }, }; diff --git a/tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs index c3c05bf9..866755c6 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs @@ -27,6 +27,22 @@ public sealed class ServerMessageTests Assert.Equal(5u, parsed.Value.ChatType); } + [Fact] + public void TryParse_PreservesEmbeddedCommandResponseNewlines() + { + const string text = "@acecommands\n@help\n@teleport"; + byte[] msg = PackString16L(text); + byte[] body = new byte[4 + msg.Length + 4]; + BinaryPrimitives.WriteUInt32LittleEndian(body, ServerMessage.Opcode); + Array.Copy(msg, 0, body, 4, msg.Length); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4 + msg.Length), 0u); + + ServerMessage.Parsed parsed = Assert.IsType( + ServerMessage.TryParse(body)); + + Assert.Equal(text, parsed.Message); + } + [Fact] public void TryParse_WrongOpcode_ReturnsNull() { diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs index c90c06c2..029fc7a0 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs @@ -77,6 +77,18 @@ public sealed class WorldSessionChatTests Assert.Throws(() => session.SendTalk(null!)); } + [Fact] + public void SendSoulEmote_EmitsRetailGameAction() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendSoulEmote("waves."); + + Assert.Equal(ClientCommandRequests.BuildSoulEmote(1u, "waves."), captured); + } + [Fact] public void SendTeleportToLifestone_EmitsRetailGameAction() { diff --git a/tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs b/tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs index c8ec32e4..82121666 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs @@ -17,6 +17,22 @@ public sealed class ChatCommandTargetStateTests Assert.Equal("Caith", targets.LastOutgoingTellTarget); } + [Fact] + public void TracksIndependentMonarchAndPatronReplyTargetsFromLegacyBroadcasts() + { + var chat = new ChatLog(); + using var targets = new ChatCommandTargetState(chat); + + // 0x0147 does not carry a sender GUID; the incoming sender name is + // nevertheless authoritative for retail's monarch/patron reply keys. + chat.OnChannelBroadcast(0x4000u, "Monarch", "orders"); + chat.OnChannelBroadcast(0x2000u, "Patron", "hello"); + chat.OnChannelBroadcast(0x4000u, "New Monarch", "new orders"); + + Assert.Equal("New Monarch", targets.LastMonarchSender); + Assert.Equal("Patron", targets.LastPatronSender); + } + [Fact] public void ResetSessionForgetsTargetsButPreservesTranscript() { @@ -29,6 +45,8 @@ public sealed class ChatCommandTargetStateTests Assert.Null(targets.LastIncomingTellSender); Assert.Null(targets.LastOutgoingTellTarget); + Assert.Null(targets.LastMonarchSender); + Assert.Null(targets.LastPatronSender); Assert.Equal(2, chat.Count); } diff --git a/tests/AcDream.Core.Tests/Chat/InventoryFailureMessagesTests.cs b/tests/AcDream.Core.Tests/Chat/InventoryFailureMessagesTests.cs index 4a24b9be..46742e82 100644 --- a/tests/AcDream.Core.Tests/Chat/InventoryFailureMessagesTests.cs +++ b/tests/AcDream.Core.Tests/Chat/InventoryFailureMessagesTests.cs @@ -32,6 +32,12 @@ public sealed class InventoryFailureMessagesTests [InlineData( InventoryRequestKind.SplitToContainer, "Arrows", 0x36u, "The Arrows can't be split - action cancelled")] + [InlineData( + InventoryRequestKind.Move, "Sword", 0u, + "The Sword can't be moved")] + [InlineData( + InventoryRequestKind.Wield, "Sword", 0x1Du, + "The Sword can't be wielded - you're too busy")] public void ComposeMatchesServerSaysAttemptFailed( InventoryRequestKind kind, string name, diff --git a/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs b/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs index 8ae3fe69..c82ca498 100644 --- a/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs +++ b/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using AcDream.Content; using AcDream.Core.Input; +using AcDream.Runtime.Gameplay; using AcDream.UI.Abstractions.Input; using DatReaderWriter; using Xunit; @@ -9,84 +10,13 @@ using Xunit; namespace AcDream.Core.Tests.Input; /// -/// Campaign OP slice OP8: pins 's agreement -/// with — for every -/// this slice's table resolves, the UNION of DAT default bindings across every DAT -/// row mapped to that action must equal KeyBindings.RetailDefaults()'s chord -/// set for it. Per the slice contract: "investigate + report any disagreement rather -/// than silently preferring one." Skips cleanly when the installed dats are -/// unavailable (CI), matching every other live-DAT conformance test in this project. -/// -/// -/// Two real, byte-verified disagreements survive after the mechanism fixes -/// (2026-08-11 investigation, updated at the M2 rework — none are bugs in this -/// slice's table; both are PRE-EXISTING -/// gaps/design choices this slice does not touch, listed in -/// with citations). A THIRD -/// disagreement — ten CameraAlternateControls (InputMap 0x6) actions — was RETIRED -/// at the M2 rework: no longer maps InputMap -/// 0x6 to any at all (the aliasing that produced two -/// independent rows fighting over one live target — M2, 2026-08-11 review), so this -/// test never sees a ctx-0x6 row and the ctx-0x5-only union now matches -/// RetailDefaults() exactly for all twelve Camera actions with no allowlist -/// entry needed: -/// -/// -/// MovementWalkMode. The DAT's raw QualifiedControl.Modifier -/// for the Shift-key binding is 0 (the key itself IS Shift — there is no separate -/// "modifier" to report when the primary key and the modifier are the same physical -/// key). RetailDefaults() deliberately encodes Modifiers=Shift anyway — -/// its own comment (K-fix1, 2026-04-26) explains the OS echoes -/// CurrentModifiers=Shift alongside a Shift key-DOWN event, so the chord must -/// carry the flag to match at dispatch time. Not a disagreement to fix; a raw-DAT -/// artifact this slice's reader faithfully reproduces. -/// Quickslot 1-9's Ctrl+N chord (and its SelectQuickSlot_1-9 -/// counterpart). The DAT's own default -/// master map binds Ctrl+1..9 to the SAME action id as bare 1..9 ("Quickslot N" — -/// UseQuickSlot_N), NOT to the separate "Select Quickslot N" action id -/// (SelectQuickSlot_N, DAT action ids 0x1000004E-56) — those carry NO -/// default binding at all in the shipped DAT. RetailDefaults()'s own comment -/// (citing gmToolbarUI::ListenToGlobalMessage @0x004BE4E0) asserts retail's -/// CLIENT reinterprets Ctrl+N contextually as Select — a runtime behavior this raw -/// keymap-default probe cannot see (it reads bound ACTIONS, not the dispatch -/// function's own modifier branching). Both readings are independently retail- -/// sourced; reconciling them needs the decompiled dispatch function, out of scope -/// here. Reported, not silently resolved either way. -/// +/// Campaign KB's installed-DAT contract: all 306 user-bindable ActionMap rows +/// have distinct live identities and their default chord sets match the two +/// retail MasterInputMaps exactly. Missing, aliased, or guessed rows fail here. /// [Trait("Lane", "InstalledDat")] public sealed class RetailActionIdentityRoundTripTests { - /// Actions with a citation-backed, pre-existing reason their DAT-union - /// default set legitimately differs from - /// — see class doc. Every other mapped action must match exactly. - private static readonly HashSet KnownRetailDefaultsDisagreements = new() - { - InputAction.MovementWalkMode, - InputAction.UseQuickSlot_1, - InputAction.UseQuickSlot_2, - InputAction.UseQuickSlot_3, - InputAction.UseQuickSlot_4, - InputAction.UseQuickSlot_5, - InputAction.UseQuickSlot_6, - InputAction.UseQuickSlot_7, - InputAction.UseQuickSlot_8, - InputAction.UseQuickSlot_9, - // Same Use-vs-Select ambiguity as the bare-numeral block above: the DAT's - // own "Select Quickslot N" action ids carry NO default binding at all — - // RetailDefaults()'s Ctrl+N->Select mapping rests on the decompiled - // dispatch function's runtime modifier check, not the raw keymap default. - InputAction.SelectQuickSlot_1, - InputAction.SelectQuickSlot_2, - InputAction.SelectQuickSlot_3, - InputAction.SelectQuickSlot_4, - InputAction.SelectQuickSlot_5, - InputAction.SelectQuickSlot_6, - InputAction.SelectQuickSlot_7, - InputAction.SelectQuickSlot_8, - InputAction.SelectQuickSlot_9, - }; - [Fact] public void MappedActions_DatUnionDefaultBindings_MatchRetailDefaults() { @@ -98,13 +28,44 @@ public sealed class RetailActionIdentityRoundTripTests RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(source); Assert.NotNull(snapshot); + Assert.Equal(306, snapshot!.Rows.Count); + var unresolvedRows = snapshot.Rows + .Where(row => !RetailActionIdentityTable.TryResolve( + row.InputMapId, + row.ActionId, + out _)) + .Select(row => $"0x{row.InputMapId:X8}/0x{row.ActionId:X8}") + .ToArray(); + Assert.Empty(unresolvedRows); + Assert.Equal(306, RetailActionIdentityTable.Map.Count); + Assert.Equal(306, RetailActionIdentityTable.Map.Values.Distinct().Count()); + Assert.Equal(306, RetailActionIdentityTable.ReverseMap.Count); + + var optionIds = new HashSet(); + foreach (RetailActionMapRow row in snapshot.Rows.Where( + static row => row.InputMapId == 0x10000008u)) + { + Assert.True(RetailActionIdentityTable.TryResolve( + row.InputMapId, + row.ActionId, + out InputAction action)); + Assert.True( + RetailActionIdentityTable.TryGetCharacterOptionId( + action, + out uint optionId), + $"CharacterSettings row 0x{row.ActionId:X8} has no PlayerOption id"); + Assert.True(CharacterOptionTable.TryGet(optionId, out _)); + Assert.True(optionIds.Add(optionId), $"duplicate PlayerOption id 0x{optionId:X2}"); + } + Assert.Equal(48, optionIds.Count); + KeyBindings retailDefaults = KeyBindings.RetailDefaults(); // Aggregate DAT default chords by resolved InputAction — a single action can // be reached by more than one DAT row (e.g. the Camera/CameraAlternate pair). var datChordsByAction = new Dictionary>(); var unresolvedScanCodes = new List(); - foreach (RetailActionMapRow row in snapshot!.Rows) + foreach (RetailActionMapRow row in snapshot.Rows) { if (!RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action)) continue; @@ -125,15 +86,12 @@ public sealed class RetailActionIdentityRoundTripTests } } - Assert.True(datChordsByAction.Count > 100, - $"expected >100 mapped actions, got {datChordsByAction.Count}"); + Assert.Equal(306, datChordsByAction.Count); Assert.Empty(unresolvedScanCodes); var mismatches = new List(); foreach ((InputAction action, HashSet datChords) in datChordsByAction) { - if (KnownRetailDefaultsDisagreements.Contains(action)) continue; - var acdreamChords = retailDefaults.ForAction(action).Select(b => b.Chord).ToHashSet(); if (!datChords.SetEquals(acdreamChords)) { @@ -144,8 +102,7 @@ public sealed class RetailActionIdentityRoundTripTests } Assert.True(mismatches.Count == 0, - $"{mismatches.Count} unexpected DAT-vs-RetailDefaults() disagreements " - + "(not in the documented KnownRetailDefaultsDisagreements allowlist):\n" + $"{mismatches.Count} DAT-vs-RetailDefaults() disagreements:\n" + string.Join("\n", mismatches)); } } diff --git a/tests/AcDream.Core.Tests/Input/RetailActionMapReaderTests.cs b/tests/AcDream.Core.Tests/Input/RetailActionMapReaderTests.cs index 11832831..b04a66d6 100644 --- a/tests/AcDream.Core.Tests/Input/RetailActionMapReaderTests.cs +++ b/tests/AcDream.Core.Tests/Input/RetailActionMapReaderTests.cs @@ -169,6 +169,38 @@ public sealed class RetailActionMapReaderTests Assert.Empty(row.DefaultBindings); } + [Fact] + public void Read_PreservesRetailInputMapConflictPolicy() + { + var actionMap = new ActionMap + { + InputMaps = new Dictionary>(), + ConflictingMaps = new Dictionary + { + [0x10000003u] = new InputsConflictsValue + { + InputMap = 0x10000003u, + ConflictingInputMaps = new List + { + 0x10000003u, + 0x10000002u, + }, + }, + }, + }; + var dats = new FakeDatObjectSource(); + dats.Add(RetailActionMapIds.ActionMapId, actionMap); + + RetailActionMapSnapshot snapshot = Assert.IsType( + RetailActionMapReader.Read(dats)); + + Assert.True(snapshot.InputMapsConflict(0x10000003u, 0x10000003u)); + Assert.True(snapshot.InputMapsConflict(0x10000003u, 0x10000002u)); + Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000004u)); + Assert.True(snapshot.InputMapsConflict(0xDEADBEEFu, 0xDEADBEEFu)); + Assert.False(snapshot.InputMapsConflict(0xDEADBEEFu, 0x10000003u)); + } + [Fact] public void RetailInputMapHeaders_HasAllNineteenByteVerifiedEntries() { @@ -245,5 +277,12 @@ public sealed class RetailActionMapReader_LiveDatTests Assert.Equal(2, moveForward.DefaultBindings.Count); Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0x11u); // DIK_W Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0xC8u); // DIK_UPARROW + + // The authored combat modes intentionally share the five attack/aim + // keys. Retail's conflict table keeps those mode-local contexts apart; + // Configure Keyboard must not erase one mode while editing another. + Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000004u)); + Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000005u)); + Assert.False(snapshot.InputMapsConflict(0x10000004u, 0x10000005u)); } } diff --git a/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs index b3bc83e6..e571448a 100644 --- a/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs +++ b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs @@ -94,6 +94,40 @@ public sealed class ExternalContainerStateTests Assert.Equal(2, delivered); } + [Fact] + public void OpenedCorpseHistoryMatchesRetailSetAndDeleteLifetime() + { + var state = new ExternalContainerState(); + const uint corpse = 0x70000010u; + + Assert.True(state.RequestOpen(corpse, isCorpse: true)); + Assert.True(state.HasCorpseBeenOpened(corpse)); + Assert.Equal(1, state.OpenedCorpseCount); + + state.ApplyViewContents(corpse); + state.ApplyClose(corpse); + Assert.True(state.HasCorpseBeenOpened(corpse)); + Assert.True(state.SetCorpseDeleted(corpse)); + Assert.False(state.HasCorpseBeenOpened(corpse)); + + state.RequestOpen(corpse, isCorpse: true); + Assert.True(state.Reset()); + Assert.False(state.HasCorpseBeenOpened(corpse)); + Assert.Equal(0, state.OpenedCorpseCount); + } + + [Fact] + public void RepeatedGroundObjectRequestStillRecordsCorpseIdentity() + { + var state = new ExternalContainerState(); + const uint corpse = 0x70000011u; + + Assert.True(state.RequestOpen(corpse)); + Assert.False(state.RequestOpen(corpse, isCorpse: true)); + + Assert.True(state.HasCorpseBeenOpened(corpse)); + } + private static ExternalContainerState Open(uint id) { var state = new ExternalContainerState(); diff --git a/tests/AcDream.Core.Tests/Items/InventoryContainerPlacementPolicyTests.cs b/tests/AcDream.Core.Tests/Items/InventoryContainerPlacementPolicyTests.cs new file mode 100644 index 00000000..d0298f3a --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/InventoryContainerPlacementPolicyTests.cs @@ -0,0 +1,76 @@ +using AcDream.Core.Items; + +namespace AcDream.Core.Tests.Items; + +public sealed class InventoryContainerPlacementPolicyTests +{ + private const uint Player = 0x50000001u; + + [Fact] + public void FullItemCapacityRejectsNewItemButAllowsReorder() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Name = "Player", + ItemsCapacity = 1, + ContainersCapacity = 7, + }); + objects.AddOrUpdate(new ClientObject { ObjectId = 2u }); + objects.MoveItem(2u, Player, 0); + objects.AddOrUpdate(new ClientObject { ObjectId = 3u }); + + Assert.Equal( + InventoryContainerPlacementRejection.ItemCapacityFull, + InventoryContainerPlacementPolicy.Evaluate(objects, 3u, Player, Player)); + Assert.Equal( + InventoryContainerPlacementRejection.None, + InventoryContainerPlacementPolicy.Evaluate(objects, 2u, Player, Player)); + } + + [Fact] + public void ContainerCycleAndTradeAreRejected() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = 10u, Type = ItemType.Container, ItemsCapacity = 24, + }); + objects.AddOrUpdate(new ClientObject + { + ObjectId = 11u, Type = ItemType.Container, ItemsCapacity = 24, + }); + objects.MoveItem(11u, 10u, 0); + + Assert.Equal( + InventoryContainerPlacementRejection.RecursiveContainment, + InventoryContainerPlacementPolicy.Evaluate(objects, 10u, 11u, Player)); + + objects.Get(10u)!.TradeState = 1; + Assert.Equal( + InventoryContainerPlacementRejection.SourceBeingTraded, + InventoryContainerPlacementPolicy.Evaluate(objects, 10u, Player, Player)); + } + + [Fact] + public void FullMessageMatchesRetailContainerTypeBranches() + { + var player = new ClientObject { ObjectId = Player, Name = "Backpack" }; + var bag = new ClientObject { ObjectId = 2u, Name = "Pack" }; + Assert.Equal( + "Backpack is completely full!", + InventoryContainerPlacementPolicy.ComposeClientLocal( + InventoryContainerPlacementRejection.ItemCapacityFull, + null, + player, + Player)); + Assert.Equal( + "The Pack can fit no more containers!", + InventoryContainerPlacementPolicy.ComposeClientLocal( + InventoryContainerPlacementRejection.ContainerCapacityFull, + null, + bag, + Player)); + } +} diff --git a/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs b/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs index 228d563f..f8f0a1c8 100644 --- a/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs +++ b/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs @@ -147,9 +147,53 @@ public sealed class ItemInteractionPolicyTests Assert.Equal(ItemPolicyActionKind.Reject, Assert.Single(ItemInteractionPolicy.DecideUse( Use(direct with { TradeState = 1 })).Actions).Kind); - Assert.Contains("wield", Assert.Single(ItemInteractionPolicy.DecideUse( - Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message, - StringComparison.OrdinalIgnoreCase); + Assert.Equal("You cannot use the item because you are trading it", + Assert.Single(ItemInteractionPolicy.DecideUse( + Use(direct with { TradeState = 1 })).Actions).Message); + Assert.Equal("You must wield the item to use it", + Assert.Single(ItemInteractionPolicy.DecideUse( + Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message); + } + + [Fact] + public void UseObject_targetCompatibilityFailures_useRetailVerbatimMessages() + { + var source = OwnedDirect() with + { + Name = "mana stone", + Useability = 0x00080008u, + TargetType = (uint)ItemType.Misc, + }; + var target = Obj(0x6002) with + { + Name = "armor", + Type = ItemType.Armor, + ContainerId = Player, + OwnedByPlayer = true, + }; + + var missing = ItemInteractionPolicy.DecideUse(Use(source) with + { + UseCurrentSelection = true, + }); + Assert.Equal("Select your target before using the mana stone", + Assert.Single(missing.Actions).Message); + + var incompatible = ItemInteractionPolicy.DecideUse(Use(source) with + { + UseCurrentSelection = true, + SelectedTarget = target, + }); + Assert.Equal("Cannot use the mana stone with the armor", + Assert.Single(incompatible.Actions).Message); + + var traded = ItemInteractionPolicy.DecideUse(Use(source) with + { + UseCurrentSelection = true, + SelectedTarget = target with { TradeState = 1 }, + }); + Assert.Equal("You can't use the mana stone on an item you are trading", + Assert.Single(traded.Actions).Message); } [Fact] @@ -219,7 +263,7 @@ public sealed class ItemInteractionPolicyTests PlayerOnGround = false, }); Assert.False(airborne.ReturnValue); - Assert.Equal(ItemPolicyActionKind.Reject, Assert.Single(airborne.Actions).Kind); + Assert.Equal("You cannot do that in mid air", Assert.Single(airborne.Actions).Message); var split = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item) with { SplitSize = 4 }); Assert.True(split.ReturnValue); @@ -232,8 +276,7 @@ public sealed class ItemInteractionPolicyTests var alreadyWorld = ItemInteractionPolicy.DecidePlacement( PlaceOnGround(item with { IsIn3DView = true })); Assert.False(alreadyWorld.ReturnValue); - Assert.Contains("cancelled", Assert.Single(alreadyWorld.Actions).Message, - StringComparison.OrdinalIgnoreCase); + Assert.Equal("Move cancelled", Assert.Single(alreadyWorld.Actions).Message); } [Fact] diff --git a/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs b/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs index fde7ad5b..45989aee 100644 --- a/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs +++ b/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs @@ -204,6 +204,28 @@ public sealed class VendorStagingListTests Assert.False(list.TryGet(ItemB, out _)); } + [Fact] + public void ReplacePreservesTheSplitPlaceholderPositionAndQuantity() + { + var list = new VendorStagingList(); + list.Add(ItemA, 2); + list.Add(ItemB, 9); + const uint splitGuid = 0x60000003u; + int fired = 0; + list.Changed += () => fired++; + + Assert.True(list.Replace(ItemA, splitGuid)); + + Assert.Equal( + new[] + { + new VendorStagingEntry(splitGuid, 2), + new VendorStagingEntry(ItemB, 9), + }, + list.Entries); + Assert.Equal(1, fired); + } + [Fact] public void ClearRemovesEveryEntryAndFiresChangedOnce() { diff --git a/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs b/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs index 87e0b61c..14ccfb9b 100644 --- a/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs +++ b/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs @@ -59,4 +59,69 @@ public sealed class LiveChatCommandRouteTests route.Publish(new SendServerCommandCmd("@stale")); Assert.Equal(6, sent.Count); } + + [Fact] + public void Say_ConsumesValidDatPoseAndLeavesUnknownTokenAsSpeech() + { + using var communication = new RuntimeCommunicationState(); + using var character = new RuntimeCharacterState(); + var sent = new List(); + var route = new LiveChatCommandRoute(new LiveChatCommandBindings( + _ => { }, + communication, + communication.Chat, + communication.TurbineChat, + character, + () => 0x50000001u, + text => sent.Add($"talk:{text}"), + (_, _) => { }, + (_, _) => { }, + (_, _, _, _, _, _) => { }, + ResolvePose: command => string.Equals( + command, + "wave", + StringComparison.OrdinalIgnoreCase) + ? new RetailChatPose(0x13000087u, "wave.", "waves.") + : null, + ExecuteMotion: motion => sent.Add($"motion:{motion:X8}"), + SendSoulEmote: text => sent.Add($"soul:{text}"))); + route.Activate(); + + route.Publish(new SendChatCmd( + ChatChannelKind.Say, + null, + "hello *WAVE* there *not-a-pose*")); + + Assert.Equal( + [ + "motion:13000087", + "soul:waves.", + "talk:hello there *not-a-pose*", + ], + sent); + ChatEntry local = Assert.Single(communication.Chat.Snapshot()); + Assert.Equal(ChatKind.SoulEmote, local.Kind); + Assert.Equal("You", local.Sender); + Assert.Equal("wave.", local.Text); + } + + [Fact] + public void Say_ContainingOnlyValidPoseDoesNotSendEmptyTalk() + { + using var communication = new RuntimeCommunicationState(); + using var character = new RuntimeCharacterState(); + var sent = new List(); + var route = new LiveChatCommandRoute(new LiveChatCommandBindings( + _ => { }, communication, communication.Chat, + communication.TurbineChat, character, () => 1u, + text => sent.Add($"talk:{text}"), (_, _) => { }, (_, _) => { }, + (_, _, _, _, _, _) => { }, + ResolvePose: _ => new RetailChatPose(7u, string.Empty, string.Empty), + ExecuteMotion: motion => sent.Add($"motion:{motion}"))); + route.Activate(); + + route.Publish(new SendChatCmd(ChatChannelKind.Say, null, " *wave* ")); + + Assert.Equal(["motion:7"], sent); + } } diff --git a/tests/AcDream.Runtime.Tests/Chat/RetailPublicChatParserTests.cs b/tests/AcDream.Runtime.Tests/Chat/RetailPublicChatParserTests.cs new file mode 100644 index 00000000..2f292275 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Chat/RetailPublicChatParserTests.cs @@ -0,0 +1,35 @@ +using AcDream.Runtime.Chat; + +namespace AcDream.Runtime.Tests.Chat; + +public sealed class RetailPublicChatParserTests +{ + [Fact] + public void InvalidAndUnmatchedTokensRemainLiteral() + { + string text = RetailPublicChatParser.ExtractPoses( + "*unknown* and *unfinished", + _ => null, + _ => throw new Xunit.Sdk.XunitException("must not execute")); + + Assert.Equal("*unknown* and *unfinished", text); + } + + [Fact] + public void MultipleValidStarAndAngleTokensAreRemovedInOrder() + { + var motions = new List(); + string text = RetailPublicChatParser.ExtractPoses( + "a *one* b c", + command => command switch + { + "one" => new RetailChatPose(1u, "", ""), + "two" => new RetailChatPose(2u, "", ""), + _ => null, + }, + pose => motions.Add(pose.MotionCommand)); + + Assert.Equal("a b c", text); + Assert.Equal([1u, 2u], motions); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs index 2525ed2c..730f5c46 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs @@ -153,6 +153,7 @@ public sealed class RuntimeCombatAttackStateTests now += 0.5d; controller.ReleaseAttack(); Assert.Single(sent); + Assert.True(controller.RepeatAttackInProgress); controller.HandleCommand(new RuntimeCombatAttackInput( RuntimeCombatAttackCommand.AbortForMovement, @@ -161,6 +162,7 @@ public sealed class RuntimeCombatAttackStateTests Assert.Equal(1, cancels); Assert.Single(sent); + Assert.False(controller.RepeatAttackInProgress); Assert.False(controller.BuildInProgress); Assert.Equal(0f, controller.PowerBarLevel); } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs index 25681f7d..d15c7231 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs @@ -128,6 +128,26 @@ public sealed class RuntimeInventoryStateTests Assert.Empty(inventory.Shortcuts.Items); } + [Fact] + public void RemovingAnObjectRetiresRetailOpenedCorpseHistory() + { + using var entities = new RuntimeEntityObjectLifetime(); + using var inventory = new RuntimeInventoryState(entities); + const uint corpse = 0x70000020u; + inventory.Objects.AddOrUpdate(new ClientObject + { + ObjectId = corpse, + PublicWeenieBitfield = (uint)PublicWeenieFlags.Corpse, + }); + inventory.ExternalContainers.RequestOpen(corpse, isCorpse: true); + Assert.True(inventory.ExternalContainers.HasCorpseBeenOpened(corpse)); + + Assert.True(inventory.Objects.Remove(corpse)); + + Assert.False(inventory.ExternalContainers.HasCorpseBeenOpened(corpse)); + Assert.Equal(0, inventory.CaptureOwnership().OpenedCorpseCount); + } + [Fact] public void DisposalFailureIsReportedAfterTerminalOwnerConvergence() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs index 59ca1e5f..10b78993 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs @@ -6,6 +6,24 @@ namespace AcDream.Runtime.Tests.Gameplay; public sealed class RuntimeLocalPlayerMovementStateTests { + private static PhysicsEngine MakeFlatEngine() + { + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i; + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return engine; + } + [Fact] public void ViewProjectsTheExactCanonicalControllerAndAutorunOwner() { @@ -59,6 +77,30 @@ public sealed class RuntimeLocalPlayerMovementStateTests Assert.False(movement.View.Snapshot.HasCommandInput); } + [Fact] + public void EscapeCommandsFinishJumpAndStopThroughCanonicalController() + { + var controller = new PlayerMovementController(MakeFlatEngine()); + controller.SeedPlacementForTest( + new Vector3(96f, 96f, 50f), + 0xA9B40001u, + new Vector3(96f, 96f, 50f)); + using var movement = new RuntimeLocalPlayerMovementState + { + Controller = controller, + }; + + controller.Update(0.25f, new MovementInput(Jump: true)); + Assert.True(movement.View.JumpCharge.IsCharging); + Assert.True(movement.Execute(RuntimeMovementCommand.FinishJump)); + Assert.False(movement.View.JumpCharge.IsCharging); + + controller.Update(1f / 60f, new MovementInput(Forward: true)); + Assert.False(movement.View.IsStandingStill); + Assert.True(movement.Execute(RuntimeMovementCommand.StopCompletely)); + Assert.Equal(MotionCommand.Ready, controller.Motion.RawState.ForwardCommand); + } + [Fact] public void CommandInputIsDeduplicatedAndResetWithSessionIntent() { @@ -123,6 +165,65 @@ public sealed class RuntimeLocalPlayerMovementStateTests } } + [Fact] + public void CommandMotionUsesCanonicalControllerAndEmitsOneMovementEdge() + { + const uint afkState = 0x43000118u; + var controller = new PlayerMovementController(new PhysicsEngine()); + using var movement = new RuntimeLocalPlayerMovementState + { + Controller = controller, + }; + + Assert.True(movement.ExecuteMotion(afkState)); + Assert.Equal(afkState, controller.Motion.RawState.ForwardCommand); + + MovementResult first = controller.Update(1f / 60f, default); + MovementResult second = controller.Update(1f / 60f, default); + + Assert.True(first.ShouldSendMovementEvent); + RawMotionState outbound = + LocalPlayerOutboundController.BuildRawMotionState(first); + Assert.Equal(afkState, outbound.ForwardCommand); + Assert.False(second.ShouldSendMovementEvent); + } + + [Fact] + public void OutboundRawOverridePreservesRetailActionAndStamp() + { + const uint cheer = 0x1300004Cu; + var raw = new RawMotionState(); + raw.AddAction( + cheer, + speed: 1f, + actionStamp: 7u, + autonomous: true); + var result = new MovementResult( + default, + default, + 0u, + false, + true, + null, + null, + null, + null, + null, + null, + RawMotionStateOverride: new RawMotionState(raw)); + + RawMotionState firstRaw = + LocalPlayerOutboundController.BuildRawMotionState(result); + RawMotionAction firstAction = Assert.Single(firstRaw.Actions); + + Assert.Equal((ushort)0x004C, firstAction.Command); + Assert.Equal(7, firstAction.Stamp); + Assert.True(firstAction.Autonomous); + + raw.RemoveAction(); + Assert.Single(firstRaw.Actions); + } + [Fact] public void ConcurrentRuntimeInstancesHaveIndependentMovementState() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs index 57c8615e..ed093e6a 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs @@ -12,8 +12,8 @@ namespace AcDream.UI.Abstractions.Tests.Input; /// non-modifier chord is reported via the supplied callback and the /// dispatcher does NOT fire normal action events for that chord. Esc /// cancels capture (callback receives a sentinel default chord). -/// Modifier-only key transitions don't complete capture — the user can -/// dial in Shift / Ctrl / Alt before pressing the trigger key. +/// A modifier key is captured on release when used alone, or remains a +/// modifier prefix when another key is pressed while it is held. /// public class InputDispatcherCaptureTests { @@ -93,6 +93,23 @@ public class InputDispatcherCaptureTests Assert.Equal(new KeyChord(Key.A, ModifierMask.Shift | ModifierMask.Ctrl), captured!.Value); } + [Fact] + public void BeginCapture_modifier_released_alone_becomes_bare_primary_key() + { + var (dispatcher, kb, _, _, fired) = Build(); + KeyChord? captured = null; + dispatcher.BeginCapture(chord => captured = chord); + + kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.Shift); + Assert.Null(captured); + kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.Shift); + + Assert.Equal( + new KeyChord(Key.ShiftLeft, ModifierMask.None), + captured); + Assert.Empty(fired); + } + [Fact] public void BeginCapture_completes_with_modifier_state() { @@ -106,6 +123,26 @@ public class InputDispatcherCaptureTests Assert.Equal(new KeyChord(Key.A, ModifierMask.Ctrl), captured!.Value); } + [Fact] + public void BeginCapture_consumes_mouse_button_as_retail_qualified_control() + { + var (dispatcher, _, mouse, bindings, fired) = Build(); + var left = new KeyChord( + InputDispatcher.MouseButtonToKey(MouseButton.Left), + ModifierMask.Ctrl, + Device: 1); + bindings.Add(new Binding(left, InputAction.ToggleInventoryPanel)); + mouse.WantCaptureMouse = true; + + KeyChord? captured = null; + dispatcher.BeginCapture(chord => captured = chord); + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.Ctrl); + + Assert.Equal(left, captured); + Assert.False(dispatcher.IsCapturing); + Assert.Empty(fired); + } + [Fact] public void CancelCapture_invokes_callback_with_default_chord_and_clears_state() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs index 6be58e1e..c3941709 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs @@ -101,6 +101,22 @@ public class InputDispatcherTests fired); } + [Fact] + public void Same_scope_retail_duplicate_chord_fires_every_distinct_action() + { + var (_, kb, _, bindings, fired) = Build(); + var chord = new KeyChord(Key.Number1, ModifierMask.Alt); + bindings.Add(new Binding(chord, InputAction.ToggleFloatingChatWindow1)); + bindings.Add(new Binding(chord, InputAction.UseQuickSlot_10)); + + kb.EmitKeyDown(Key.Number1, ModifierMask.Alt); + + Assert.Equal( + [(InputAction.ToggleFloatingChatWindow1, ActivationType.Press), + (InputAction.UseQuickSlot_10, ActivationType.Press)], + fired); + } + [Fact] public void Changing_combat_scope_releases_hold_resolved_in_previous_scope() { @@ -188,6 +204,30 @@ public class InputDispatcherTests Assert.Empty(fired); // no longer held } + [Fact] + public void RetailBareLeftShiftBinding_NormalizesSilkSelfModifierBit() + { + var kb = new FakeKeyboardSource(); + var mouse = new FakeMouseSource(); + var dispatcher = InputDispatcher.CreateDetached( + kb, + mouse, + KeyBindings.RetailDefaults()); + dispatcher.Attach(); + var fired = new List<(InputAction, ActivationType)>(); + dispatcher.Fired += (action, activation) => fired.Add((action, activation)); + + kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.Shift); + kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.Shift); + + Assert.Contains( + (InputAction.MovementWalkMode, ActivationType.Press), + fired); + Assert.Contains( + (InputAction.MovementWalkMode, ActivationType.Release), + fired); + } + [Fact] public void Hold_callback_scope_change_DoesNotDispatchStaleSnapshotChord() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs index 17189277..d50d9e1c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs @@ -104,10 +104,19 @@ public class KeyBindingsJsonTests var path = TempFile(); try { - // User customizes ONE action — replace MovementForward with Q. - var custom = new KeyBindings(); - custom.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementForward)); - custom.SaveToFile(path); + // A pre-v7 partial file customizes ONE action. Missing actions in + // those schemas mean "not stored yet", so they default-merge. + const string legacyJson = """ + { + "version": 6, + "actions": { + "MovementForward": [ + { "key": "Q" } + ] + } + } + """; + File.WriteAllText(path, legacyJson); var loaded = KeyBindings.LoadOrDefault(path); @@ -128,6 +137,32 @@ public class KeyBindingsJsonTests } } + [Fact] + public void Roundtrip_preserves_explicitly_unbound_retail_action() + { + var path = TempFile(); + try + { + KeyBindings defaults = KeyBindings.RetailDefaults(); + var customized = new KeyBindings(); + foreach (Binding binding in defaults.All) + { + if (binding.Action != InputAction.ToggleHelp) + customized.Add(binding); + } + + customized.SaveToFile(path); + KeyBindings loaded = KeyBindings.LoadOrDefault(path); + + Assert.Empty(loaded.ForAction(InputAction.ToggleHelp)); + Assert.NotEmpty(loaded.ForAction(InputAction.ToggleOptionsPanel)); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + [Fact] public void LoadOrDefault_handles_version_zero_legacy_file() { @@ -193,17 +228,16 @@ public class KeyBindingsJsonTests } [Fact] - public void LoadOrDefault_migratesV1CtrlNumberQuickSlotFromUseToSelect() + public void LoadOrDefault_migratesV5CtrlNumberQuickSlotFromSelectBackToRetailUse() { var path = TempFile(); try { const string json = """ { - "version": 1, + "version": 5, "actions": { - "UseQuickSlot_5": [ - { "key": "Number5" }, + "SelectQuickSlot_5": [ { "key": "Number5", "mod": "Ctrl" } ] } @@ -214,9 +248,8 @@ public class KeyBindingsJsonTests var loaded = KeyBindings.LoadOrDefault(path); Assert.Equal(InputAction.UseQuickSlot_5, - loaded.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press)?.Action); - Assert.Equal(InputAction.SelectQuickSlot_5, loaded.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press)?.Action); + Assert.Empty(loaded.ForAction(InputAction.SelectQuickSlot_5)); } finally { diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs index 6e616f76..626ba0a9 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs @@ -76,9 +76,11 @@ public class KeyBindingsRetailTests { var b = KeyBindings.RetailDefaults(); var binds = b.ForAction(InputAction.MovementWalkMode).ToList(); - Assert.NotEmpty(binds); - Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation)); - Assert.Contains(binds, x => x.Chord.Key == Key.ShiftLeft); + Binding binding = Assert.Single(binds); + Assert.Equal(ActivationType.Hold, binding.Activation); + Assert.Equal( + new KeyChord(Key.ShiftLeft, ModifierMask.None), + binding.Chord); } [Fact] @@ -135,14 +137,15 @@ public class KeyBindingsRetailTests } [Fact] - public void QuickSlot_5_bareUsesAndCtrlSelects() + public void QuickSlot_5_BareAndCtrlBothUseRetailAction() { var b = KeyBindings.RetailDefaults(); var bare = b.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press); var ctrl = b.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press); Assert.Equal(InputAction.UseQuickSlot_5, bare?.Action); - Assert.Equal(InputAction.SelectQuickSlot_5, ctrl?.Action); + Assert.Equal(InputAction.UseQuickSlot_5, ctrl?.Action); + Assert.Empty(b.ForAction(InputAction.SelectQuickSlot_5)); } [Fact] @@ -216,6 +219,18 @@ public class KeyBindingsRetailTests var binds = b.ForAction(InputAction.CameraActivateAlternateMode).ToList(); Assert.Contains(binds, x => x.Chord == new KeyChord(Key.F2, ModifierMask.None)); Assert.Contains(binds, x => x.Chord == new KeyChord(Key.KeypadDivide, ModifierMask.None)); + Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation)); + } + + [Theory] + [InlineData(InputAction.CombatAimLow)] + [InlineData(InputAction.CombatAimMedium)] + [InlineData(InputAction.CombatAimHigh)] + public void Missile_aim_actions_use_retail_press_and_release_edges(InputAction action) + { + var binding = Assert.Single(KeyBindings.RetailDefaults().ForAction(action)); + Assert.Equal(ActivationType.Hold, binding.Activation); + Assert.Equal(InputScope.MissileCombat, binding.Scope); } [Fact] diff --git a/tools/dump-keymap/Program.cs b/tools/dump-keymap/Program.cs index fc29d896..77e84911 100644 --- a/tools/dump-keymap/Program.cs +++ b/tools/dump-keymap/Program.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using DatReaderWriter; using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; // Dumps the retail-default keymap (gmDefaultMap @ 0x14000000) from // client_portal.dat. Used for the Phase K control overhaul — extracts @@ -74,8 +75,57 @@ foreach (uint id in new uint[] { 0x14000000u, 0x14000002u }) } } +// The Configure Keyboard contract is wider than the default map: the +// ActionMap contains every user-bindable row, including initially-unbound +// emotes and character-option toggles. Dump it in stable, machine-readable +// order so conformance work never infers the 306-row universe from the much +// smaller set of actions that happen to have default chords. +Console.WriteLine(); +Console.WriteLine("## ActionMap 0x26000000 user-bindable rows"); +var actionMap = dat.Get(0x26000000u); +var actionStrings = dat.Get(0x23000005u); +if (actionMap is null) +{ + Console.WriteLine(" (not found)"); +} +else +{ + Console.WriteLine(" InputMap|Action|Class|LabelHash|Label|TooltipHash|Tooltip"); + foreach ((uint inputMapId, Dictionary actions) in + actionMap.InputMaps.OrderBy(entry => entry.Key)) + { + foreach ((uint actionId, ActionMapValue value) in + actions.OrderBy(entry => entry.Key)) + { + UserBindingData? binding = value.UserBinding; + if (binding is null || binding.ActionClass == 0u) + continue; + + Console.WriteLine( + $" 0x{inputMapId:X8}|0x{actionId:X8}|{binding.ActionClass}|" + + $"0x{binding.ActionName:X8}|{Resolve(binding.ActionName)}|" + + $"0x{binding.ActionDescription:X8}|{Resolve(binding.ActionDescription)}"); + } + } +} + return 0; +string Resolve(uint hash) +{ + if (actionStrings is null + || !actionStrings.Strings.TryGetValue(hash, out var entry) + || entry.Strings.Count == 0) + { + return string.Empty; + } + + return entry.Strings[0].Value + .Replace("\r", "\\r", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal) + .Replace("|", "\\|", StringComparison.Ordinal); +} + // ── Key decoding (scan code in high word, device id in low word) ────── static (uint scan, uint dev) SplitKey(uint key) => ((key >> 16) & 0xFFFFu, key & 0xFFFFu); diff --git a/tools/run-release-gate.ps1 b/tools/run-release-gate.ps1 index 333fefd6..8e7194e3 100644 --- a/tools/run-release-gate.ps1 +++ b/tools/run-release-gate.ps1 @@ -3,7 +3,8 @@ Runs the complete portable Release gate with bounded child processes. .DESCRIPTION - Verifies that AcDream.slnx owns every project under src/, tests/, and tools/; + Verifies that AcDream.slnx owns every product project under src/, tests/, + and tools/ (deployment-only ACE server mods are intentionally separate); performs a locked restore; builds that complete supported graph; discovers every test project under tests/ that declares itself through Microsoft.NET.Test.Sdk or IsTestProject; and then runs each test assembly in @@ -199,7 +200,13 @@ function Get-SolutionProjects { foreach ($directoryName in @('src', 'tests', 'tools')) { Get-ChildItem -LiteralPath (Join-Path $repoRoot $directoryName) -Recurse -Filter '*.csproj' -File } - ) + ) | Where-Object { + # tools/ace-mods projects compile against a separately installed ACE + # server and are deployed into that server, not shipped as part of the + # portable acdream product graph. + $relative = [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') + -not $relative.StartsWith('tools/ace-mods/', [StringComparison]::OrdinalIgnoreCase) + } $solutionSet = [Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) foreach ($project in $projects) {