fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

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.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -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. - 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. - 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 118, 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") ## #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 **Component:** private entity viewports (examination clone, inventory
paperdoll — shared `PrivateEntityViewportRenderer`). paperdoll — shared `PrivateEntityViewportRenderer`).
**Filed:** 2026-08-25, AS-GF1 gate-fix session. **Narrowed same day at the **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 The owner then live-verified repeated inventory and monster/player assessment
opens against the local ACE test server: "Good. works." 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 Owner report at the Campaign AS connected gate: the animated 3-D paperdoll
in the examination window (LayoutDesc `0x2100006B` element `0x10000148`) in the examination window (LayoutDesc `0x2100006B` element `0x10000148`)
worked correctly at baseline `974fe88a` (praised the same session) and was 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 ## #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 **Status:** DONE 2026-08-26 — fixed as the first #446 keyboard-parity slice.
round 2 (R1's scope boundary).
The DAT ActionMap (DID `0x26000000`) carries a `ConflictingMaps` table The DAT ActionMap (DID `0x26000000`) carries a `ConflictingMaps` table
retail's `UIOption_ActionKeyMap` consults when deciding whether two rows 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 for fewer or none. Accepting the prompt then strips retail-default
bindings that should have survived. bindings that should have survived.
The OP8 round-2 fix already excluded store-only rows (`MappedAction is The OP8 round-2 fix originally excluded store-only rows (`MappedAction is
null`) from the conflict universe — those cannot collide because they null`) from the conflict universe. Campaign KB later mapped and enabled every
never reach the InputDispatcher — but retail-mapped cross-context one of the 306 installed rows, eliminating that tier; retail cross-context
sharing needs the real table. **Fix:** parse `ConflictingMaps` in sharing still needs the real table. **Fix:** parse `ConflictingMaps` in
`RetailActionMap` (the reader already round-trips the field — `RetailActionMap` (the reader already round-trips the field —
`RetailActionMapReaderTests` constructs it), and make `FindConflicts` `RetailActionMapReaderTests` constructs it), and make `FindConflicts`
consult it: two rows sharing a chord conflict only if their contexts' consult it: two rows sharing a chord conflict only if their contexts'
ConflictingMaps entries say so. Conformance-test against the combat ConflictingMaps entries say so. Conformance-test against the combat
cluster's authored defaults (five keys, multi-row each, zero prompts on 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 a no-op rebind).
false prompts as new breakage until this lands.
**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) ## #372 — Options panel: Character/Chat/Config tabs render BLANK on screen and most Gameplay buttons do nothing (connected-gate failure)

File diff suppressed because one or more lines are too long

View file

@ -94,8 +94,9 @@ DAT-authored values.
register row for the ACE-sourced 2013-unverifiable mapping. register row for the ACE-sourced 2013-unverifiable mapping.
- **D4 — Configure Keyboard is the campaign's rebind screen** (it is the - **D4 — Configure Keyboard is the campaign's rebind screen** (it is the
ONLY rebind screen — D1). Port `gmKeyboardUI`'s shape and DAT ActionMap ONLY rebind screen — D1). Port `gmKeyboardUI`'s shape and DAT ActionMap
data (lane D Option C) but persist to `keybinds.json`; retail `.keymap` data (lane D Option C). **Superseded 2026-08-26 by #446:** named retail
file interchange is a register-row deferral. `.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 - **D5 — dead-endpoint buttons short-circuit to their own retail failure
strings.** Urgent Assistance / Report Abuse open a defunct strings.** Urgent Assistance / Report Abuse open a defunct
`support.turbine.com` URL in retail; acdream skips the browser launch and `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 non-user-bindable refusal per lane D §5; Save/Cancel; Reset-to-defaults
reloads the DAT maps. Persistence: `keybinds.json` (D4). reloads the DAT maps. Persistence: `keybinds.json` (D4).
**Register rows:** `.keymap` file interchange not implemented (D4); any **Register rows:** any retail column/behaviour consciously narrowed. The
retail column/behaviour consciously narrowed. former D4 `.keymap` deferral was retired by #446 on 2026-08-26.
**Gate:** connected — rebind a movement key, conflict prompt on a taken **Gate:** connected — rebind a movement key, conflict prompt on a taken
chord, persistence across relaunch, reset restores retail defaults. 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. `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 - A pre-world character-select flow (D6 adapts; its register row carries
the future work). 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 - The `0x21000017` docked `gmPanelUI` host variant — acdream ships the
floating host only (register row in OP3 if the review deems it a floating host only (register row in OP3 if the review deems it a
divergence; retail exposes both). divergence; retail exposes both).

View file

@ -337,44 +337,18 @@ nothing wrong" surprise, matching the register's existing framing of the
### B.2 — Double-click ### B.2 — Double-click
**No dedicated double-click-to-buy mechanism was found for vendor shop **Corrected 2026-08-26:** the original symbol-name search missed the real
items.** Evidence, not absence-of-search: 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 **Conclusion:** browse-row double-click-to-buy is verbatim retail behavior.
read) dispatches on message id 1 (button click → The acdream binding is a port, not an optional modernization. The previous
`HandleButtonClicks`), 7 (dropdown selection change), `0x2c` (page absence-of-symbol inference was false and is superseded by the direct function
change), `0x15` (drop release), and `0x1c` (routes to body.
`HandleMousePresses` only when `m_itemsUI != 0`) — there is no distinct
"double-click" message id handled at the panel level.
- The base list class `UIElement_ItemList` (every method enumerated via
`docs/research/named-retail/symbols.json`, ~50 symbols) has
`HandleSingleSelection`, `HandleTargetedUseLeftClick`,
`ItemList_SetSelectedItem`, `ItemList_OpenContainer` (for double-clicking
a CONTAINER item specifically — opening it, not buying), but **no
generic double-click handler** and no vendor-specific one either.
- Other retail panels DO have an explicit, separately-named double-click
handler when the mechanism exists — e.g. `gmContractsUI::CheckForDoubleClick`
(`0x00497A10`), `gmPageListUI::CheckForDoubleClick` (`0x00493140`). No
`gmVendorUI::CheckForDoubleClick` or `VendorItemsUI::CheckForDoubleClick`
symbol exists in the 18,366-function named table.
**Conclusion:** retail's confirmed vendor-item interaction model is
single-click-to-select (→ drives the global `ACCWeenieObject::selectedID`,
B.3 below) plus an explicit Buy/Add button press. There is no evidence
retail supports double-click-to-buy on the shop list. The user's
expectation likely carries over from inventory-panel muscle memory
(double-click = use/equip elsewhere in retail) — but the vendor "Items"
list is not that panel. **This is flagged as an open question for the
contract, not resolved unilaterally**: per the project's
no-invented-mechanisms discipline, do not silently add a double-click-buy
shortcut and call it retail-faithful. The retail-faithful, fully-evidenced
fix for "double-click does nothing" is: (a) make single-click meaningfully
select (today it only sets a private field with no visible effect — see
B.3), and (b) make the Buy button actually work. If the user still wants a
double-click shortcut after seeing single-click+Buy work, that is a
deliberate, flagged acdream UX addition on top of retail, not a retail port
— call it out explicitly in the commit/register the way AP-116
(Particle Range) or similar user-directed deviations are recorded.
### B.3 — The quantity slider ### 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 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 file it as a fast follow-up if the user notices the round-trip lag on a
refused purchase. refused purchase.
2. **Double-click** — no retail mechanism found (B.2). Ask the user 2. **Double-click — RESOLVED 2026-08-26.** Retail's
directly whether they want a deliberate acdream-only double-click `gmVendorUI::HandleMousePresses @ 0x004C40D0` directly buys a browse row on
shortcut once single-click-select + Buy-button-works is verified live, double-click. Keep this behavior and its staged-row siblings.
rather than assuming yes and inventing behavior.
3. **Where does the vendor-owned split-exempt-mask predicate live** — C.1's 3. **Where does the vendor-owned split-exempt-mask predicate live** — C.1's
design question: fold into `SelectedObjectController` directly (it design question: fold into `SelectedObjectController` directly (it
already owns the seeding logic, would need a `Func<uint,bool> already owns the seeding logic, would need a `Func<uint,bool>

View file

@ -5,6 +5,13 @@ Research lane D of the settings-track campaign
questions **Q5** (Configure Keyboard: retail's keymap UI + storage) and questions **Q5** (Configure Keyboard: retail's keymap UI + storage) and
**Q6** (what every Gameplay Options tab button does). **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 **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 a named symbol + address from the Sept 2013 EoR PDB-paired build. The
PDB/binary pairing was verified first: PDB/binary pairing was verified first:

View file

@ -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 AC and EF 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
`<stack> <appropriate name> (of <total carried Pyreals>)`, 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 <name> 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.

View file

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

View file

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

View file

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

View file

@ -96,7 +96,8 @@ internal sealed record InteractionRetainedUiDependencies(
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar, Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar,
AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null, AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null,
Func<AcDream.App.Rendering.Packs.RenderPackDiagnosticsSnapshot>? Func<AcDream.App.Rendering.Packs.RenderPackDiagnosticsSnapshot>?
RenderPackDiagnostics = null) RenderPackDiagnostics = null,
string? ScreenshotsDirectory = null)
{ {
public RuntimeActionState Actions => Runtime.ActionOwner; public RuntimeActionState Actions => Runtime.ActionOwner;
@ -388,9 +389,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
container, container,
placement, placement,
amount), amount),
sendStackableMerge: (source, target, amount) =>
session.CurrentSession?.SendStackableMerge(source, target, amount),
requestExternalContainer: guid => 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, requestUse: selection.RequestUse,
// Slice 6.3: ItemInteractionController.TryBuy owns the // Slice 6.3: ItemInteractionController.TryBuy owns the
@ -663,16 +670,20 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
CharacterKey: () => d.Settings.ActiveToonKey, CharacterKey: () => d.Settings.ActiveToonKey,
ScreenSize: () => (d.Window.Size.X, d.Window.Size.Y)); ScreenSize: () => (d.Window.Size.X, d.Window.Size.Y));
void ProbeLog(string message) => d.Log("[UI-PROBE] " + message); void ProbeLog(string message) => d.Log("[UI-PROBE] " + message);
FrameScreenshotController? screenshots = null; string screenshotDirectory =
if (d.Options.UiProbeEnabled d.Options.UiProbeEnabled
&& d.Options.AutomationArtifactDirectory is { } artifactDirectory) && d.Options.AutomationArtifactDirectory is { } artifactDirectory
{ ? Path.Combine(artifactDirectory, "screenshots")
screenshots = new FrameScreenshotController( : !string.IsNullOrWhiteSpace(d.ScreenshotsDirectory)
? d.ScreenshotsDirectory
: Path.Combine(
Path.GetDirectoryName(d.KeyBindingsFilePath)!,
"screenshots");
var screenshots = new FrameScreenshotController(
d.BackbufferReader, d.BackbufferReader,
Path.Combine(artifactDirectory, "screenshots"), screenshotDirectory,
ProbeLog, ProbeLog,
d.RenderPackDiagnostics); d.RenderPackDiagnostics);
}
checkpoint(InteractionRetainedUiCompositionPoint.UiProbeCreated); checkpoint(InteractionRetainedUiCompositionPoint.UiProbeCreated);
var assets = new RetailUiAssets( var assets = new RetailUiAssets(
@ -1157,11 +1168,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
late.GameRuntime.CharacterSelectionConfirmDelete, late.GameRuntime.CharacterSelectionConfirmDelete,
late.GameRuntime.CharacterSelectionRestore, late.GameRuntime.CharacterSelectionRestore,
late.GameRuntime.CharacterSelectionCancel, late.GameRuntime.CharacterSelectionCancel,
// Campaign LA gate round 2 finding 1: the SAME // Campaign LA gate round 2 finding 1: the character
// window-close path GameplayInputCommandController's // selection screen's Exit button uses the ordinary host
// Escape fallback uses (IGameplayWindowCommands.Close // close path. Gameplay Escape is independent: retail
// /GameplayWindowCommands wrap this same d.Window.Close // clears selection or toggles the Gameplay Options page.
// delegate) — no separate exit path.
d.Window.Close), d.Window.Close),
// Campaign CC slice CC4: same late-bound generation-capturing // Campaign CC slice CC4: same late-bound generation-capturing
// seam as CharacterSelection above. RequestExit here is a // seam as CharacterSelection above. RequestExit here is a
@ -1203,7 +1213,24 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance, RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance,
RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing, RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing,
GetSkillScore: chargenSkillScoreResolver.Resolve, 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 runtime = lease.Mount(
() => RetailUiRuntime.CreateUninitialized(bindings)); () => RetailUiRuntime.CreateUninitialized(bindings));
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);

View file

@ -63,6 +63,7 @@ internal sealed record LivePresentationDependencies(
CellVisibility CellVisibility, CellVisibility CellVisibility,
LiveWorldOriginState WorldOrigin, LiveWorldOriginState WorldOrigin,
LocalPlayerIdentityState PlayerIdentity, LocalPlayerIdentityState PlayerIdentity,
ChaseCameraInputState ChaseCameraInput,
PointerPositionState PointerPosition, PointerPositionState PointerPosition,
PlayerApproachCompletionState PlayerApproachCompletions, PlayerApproachCompletionState PlayerApproachCompletions,
GameRenderResourceLifetime RenderResourceLifetime, GameRenderResourceLifetime RenderResourceLifetime,
@ -808,7 +809,12 @@ internal sealed class LivePresentationCompositionPhase
d.RetailAlphaQueue, d.RetailAlphaQueue,
alphaScratchBudgets.DispatcherBytes, alphaScratchBudgets.DispatcherBytes,
foundation.TerrainAtlas?.BuildingDetailTexture ?? default, 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()); static value => value.Dispose());
var selectionQuery = new WorldSelectionQuery( var selectionQuery = new WorldSelectionQuery(
liveEntities, liveEntities,
@ -845,7 +851,11 @@ internal sealed class LivePresentationCompositionPhase
localEntityId => localEntityId =>
d.EffectPoses.TryGetRootPose(localEntityId, out Matrix4x4 childRoot) d.EffectPoses.TryGetRootPose(localEntityId, out Matrix4x4 childRoot)
? 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( var radarSnapshotProvider = new RadarSnapshotProvider(
d.EntityObjects.Objects, d.EntityObjects.Objects,
liveEntities, liveEntities,
@ -876,7 +886,12 @@ internal sealed class LivePresentationCompositionPhase
() => d.PlayerController.Controller, () => d.PlayerController.Controller,
d.PlayerApproachCompletions), d.PlayerApproachCompletions),
d.Toast, 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); selectionInteractionSource.Bind(selectionInteractions);
bindings.Adopt( bindings.Adopt(
"world selection", "world selection",

View file

@ -1169,6 +1169,7 @@ internal sealed class SessionPlayerCompositionPhase
live.SelectionInteractions), live.SelectionInteractions),
new LiveSessionWorldRuntime( new LiveSessionWorldRuntime(
content.Dats, content.Dats,
d.DatLock,
content.Audio?.Engine is { } sessionAudioEngine content.Audio?.Engine is { } sessionAudioEngine
? new AcDream.App.Audio.WorldAudioSessionGate( ? new AcDream.App.Audio.WorldAudioSessionGate(
sessionAudioEngine, sessionAudioEngine,
@ -1279,14 +1280,10 @@ internal sealed class SessionPlayerCompositionPhase
new RetainedGameplayWindowCommands( new RetainedGameplayWindowCommands(
interaction.RetainedUi?.Runtime), interaction.RetainedUi?.Runtime),
runtimeDiagnostics, runtimeDiagnostics,
new PlayerModeGameplayCommands( new PlayerModeGameplayCommands(playerMode),
d.PlayerMode,
playerMode),
new ItemTargetModeCommands(interaction.ItemInteraction), new ItemTargetModeCommands(interaction.ItemInteraction),
new GameplayCameraModeCommands(host.CameraController),
gameRuntime, gameRuntime,
gameRuntime.Combat, gameRuntime.Combat,
new GameplayWindowCommands(d.Window.Close),
toggleAudioMute: content.Audio?.Engine is { } audioEngine toggleAudioMute: content.Audio?.Engine is { } audioEngine
? () => ? () =>
{ {
@ -1305,6 +1302,7 @@ internal sealed class SessionPlayerCompositionPhase
gameRuntime, gameRuntime,
gameRuntime.Selection, gameRuntime.Selection,
gameRuntime.MovementCommands, gameRuntime.MovementCommands,
gameRuntime.CharacterCommands,
commands); commands);
GameplayInputActionRouter gameplayActions = GameplayInputActionRouter gameplayActions =
GameplayInputActionRouter.Create( GameplayInputActionRouter.Create(

View file

@ -18,8 +18,8 @@ namespace AcDream.App.Composition;
/// <c>UiHost</c>/<c>UiRoot</c> tree — D1) instead of a new /// <c>UiHost</c>/<c>UiRoot</c> tree — D1) instead of a new
/// <c>IPanelRenderer</c> implementation, and its OP9 closeout retired the /// <c>IPanelRenderer</c> implementation, and its OP9 closeout retired the
/// unrendered ImGui-era SettingsPanel/SettingsVM outright. Keybind remapping /// unrendered ImGui-era SettingsPanel/SettingsVM outright. Keybind remapping
/// is Campaign OP slice OP8's Configure Keyboard screen, persisting to /// is Campaign OP slice OP8's Configure Keyboard screen, persisting retail
/// keybinds.json (not retail's <c>.keymap</c> format — register row AP-202). /// <c>*.keymap</c> profiles with <c>keybinds.json</c> as the host-command mirror.
/// </summary> /// </summary>
internal sealed record SettingsDevToolsResult( internal sealed record SettingsDevToolsResult(
AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality) AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality)

View file

@ -63,6 +63,36 @@ internal sealed class FrameScreenshotController
return true; return true;
} }
/// <summary>
/// Queues the first free retail-style screenshot name. Retail scans
/// <c>ScreenShot00000.jpg</c> through <c>ScreenShot99999.jpg</c> beside
/// its preferences file; acdream keeps the exact stem/numbering while
/// writing lossless PNGs in the portable screenshots directory.
/// </summary>
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) => public bool IsComplete(string name) =>
_status.TryGetValue(name, out CaptureStatus? status) _status.TryGetValue(name, out CaptureStatus? status)
&& status.State == CaptureState.Complete; && status.State == CaptureState.Complete;

View file

@ -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) public string AdjustSensitivity(float factor)
{ {
string mode; string mode;

View file

@ -15,7 +15,9 @@ internal readonly record struct ChaseCameraAdjustmentInput(
bool ZoomIn, bool ZoomIn,
bool ZoomOut, bool ZoomOut,
bool Raise, bool Raise,
bool Lower); bool Lower,
bool RotateLeft,
bool RotateRight);
internal interface ICameraFrameInputSource internal interface ICameraFrameInputSource
{ {
@ -71,9 +73,21 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource
return default; return default;
return new ChaseCameraAdjustmentInput( return new ChaseCameraAdjustmentInput(
dispatcher.IsActionHeld(InputAction.CameraZoomIn), dispatcher.IsActionHeld(InputAction.CameraZoomIn)
dispatcher.IsActionHeld(InputAction.CameraZoomOut), || dispatcher.IsActionHeld(InputAction.CameraMoveToward)
dispatcher.IsActionHeld(InputAction.CameraRaise), || dispatcher.IsActionHeld(InputAction.CameraAlternateMoveToward),
dispatcher.IsActionHeld(InputAction.CameraLower)); 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));
} }
} }

View file

@ -3,6 +3,7 @@ using AcDream.App.Rendering;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input; namespace AcDream.App.Input;
@ -14,6 +15,8 @@ internal interface IGameplayInputActionSurface
void RemoveFired(Action<InputAction, ActivationType> callback); void RemoveFired(Action<InputAction, ActivationType> callback);
void SetCombatScope(InputScope? scope); void SetCombatScope(InputScope? scope);
void SetCameraAlternateScope(bool active);
} }
internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher) internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher)
@ -30,6 +33,9 @@ internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispa
public void SetCombatScope(InputScope? scope) => public void SetCombatScope(InputScope? scope) =>
_dispatcher.SetCombatScope(scope); _dispatcher.SetCombatScope(scope);
public void SetCameraAlternateScope(bool active) =>
_dispatcher.SetCameraAlternateScope(active);
} }
internal interface ICombatModeEventSurface internal interface ICombatModeEventSurface
@ -66,6 +72,8 @@ internal interface IGameplayInputPriorityTargets
bool HandleRetainedUiAction(InputAction action); bool HandleRetainedUiAction(InputAction action);
bool HandleCharacterOptionAction(InputAction action);
bool HandleSelectionAction(InputAction action); bool HandleSelectionAction(InputAction action);
bool HandlePressedMovementAction(InputAction action); bool HandlePressedMovementAction(InputAction action);
@ -87,6 +95,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets
private readonly IGameRuntimeView _runtimeView; private readonly IGameRuntimeView _runtimeView;
private readonly IRuntimeSelectionCommands _runtimeSelection; private readonly IRuntimeSelectionCommands _runtimeSelection;
private readonly IRuntimeMovementCommands _runtimeMovement; private readonly IRuntimeMovementCommands _runtimeMovement;
private readonly IRuntimeCharacterCommands _runtimeCharacter;
private readonly IGameplayInputCommandTarget _commands; private readonly IGameplayInputCommandTarget _commands;
public RuntimeGameplayInputPriorityTargets( public RuntimeGameplayInputPriorityTargets(
@ -97,6 +106,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets
IGameRuntimeView runtimeView, IGameRuntimeView runtimeView,
IRuntimeSelectionCommands runtimeSelection, IRuntimeSelectionCommands runtimeSelection,
IRuntimeMovementCommands runtimeMovement, IRuntimeMovementCommands runtimeMovement,
IRuntimeCharacterCommands runtimeCharacter,
IGameplayInputCommandTarget commands) IGameplayInputCommandTarget commands)
{ {
_frame = frame ?? throw new ArgumentNullException(nameof(frame)); _frame = frame ?? throw new ArgumentNullException(nameof(frame));
@ -109,11 +119,14 @@ internal sealed class RuntimeGameplayInputPriorityTargets
?? throw new ArgumentNullException(nameof(runtimeSelection)); ?? throw new ArgumentNullException(nameof(runtimeSelection));
_runtimeMovement = runtimeMovement _runtimeMovement = runtimeMovement
?? throw new ArgumentNullException(nameof(runtimeMovement)); ?? throw new ArgumentNullException(nameof(runtimeMovement));
_runtimeCharacter = runtimeCharacter
?? throw new ArgumentNullException(nameof(runtimeCharacter));
_commands = commands ?? throw new ArgumentNullException(nameof(commands)); _commands = commands ?? throw new ArgumentNullException(nameof(commands));
} }
public bool HandlePointerAction(InputAction action, ActivationType activation) => public bool HandlePointerAction(InputAction action, ActivationType activation) =>
_frame.HandlePointerAction(action, activation); _frame.HandlePointerAction(action, activation)
|| _pointer.HandleCameraAction(action, activation);
public void HandleScroll(InputAction action) => public void HandleScroll(InputAction action) =>
_pointer.HandleScroll(action); _pointer.HandleScroll(action);
@ -122,10 +135,74 @@ internal sealed class RuntimeGameplayInputPriorityTargets
_frame.HandleCombatAction(action, activation); _frame.HandleCombatAction(action, activation);
public bool HandleRetainedUiAction(InputAction action) => 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) 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 RuntimeSelectionCommand? command = action switch
{ {
InputAction.SelectionClosestMonster => InputAction.SelectionClosestMonster =>
@ -149,15 +226,32 @@ internal sealed class RuntimeGameplayInputPriorityTargets
return _selection?.HandleInputAction(action) == true; 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) public bool HandlePressedMovementAction(InputAction action)
{ {
RuntimeMovementCommand? command = action switch if (RetailEmoteMotionTable.TryGetMotion(action, out uint motion))
{ {
InputAction.MovementRunLock => _runtimeMovement.ExecuteMotion(
RuntimeMovementCommand.ToggleRunLock, _runtimeView.Generation,
InputAction.MovementStop => RuntimeMovementCommand.Stop, motion);
_ => null, return true;
}; }
RuntimeMovementCommand? command = ResolvePressedMovementCommand(action);
if (command is { } typed) if (command is { } typed)
{ {
_runtimeMovement.Execute(_runtimeView.Generation, typed); _runtimeMovement.Execute(_runtimeView.Generation, typed);
@ -167,6 +261,18 @@ internal sealed class RuntimeGameplayInputPriorityTargets
return _frame.HandlePressedMovementAction(action); 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) => public void HandleCommand(InputAction action) =>
_commands.Handle(action); _commands.Handle(action);
} }
@ -298,6 +404,14 @@ internal sealed class GameplayInputActionRouter : IDisposable
{ {
_log($"[input] {action} {activation}"); _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)) if (_targets.HandlePointerAction(action, activation))
return; return;
@ -320,6 +434,8 @@ internal sealed class GameplayInputActionRouter : IDisposable
if (_targets.HandleRetainedUiAction(action)) if (_targets.HandleRetainedUiAction(action))
return; return;
if (_targets.HandleCharacterOptionAction(action))
return;
if (_targets.HandleSelectionAction(action)) if (_targets.HandleSelectionAction(action))
return; return;
if (_targets.HandlePressedMovementAction(action)) if (_targets.HandlePressedMovementAction(action))

View file

@ -1,6 +1,5 @@
using AcDream.App.Combat; using AcDream.App.Combat;
using AcDream.App.Diagnostics; using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
@ -24,6 +23,12 @@ internal interface IRetainedGameplayWindowCommands
/// <c>RetailUiRuntime.BindToolbarPanelButtons</c>. /// <c>RetailUiRuntime.BindToolbarPanelButtons</c>.
/// </summary> /// </summary>
void ToggleOptionsPanel(); void ToggleOptionsPanel();
void ToggleGameplayOptionsPage();
void FocusChatEntry();
void LogOutCharacter();
} }
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime) internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
@ -39,35 +44,31 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
public void ToggleOptionsPanel() => public void ToggleOptionsPanel() =>
_runtime?.ToggleWindow(WindowNames.Options); _runtime?.ToggleWindow(WindowNames.Options);
public void ToggleGameplayOptionsPage() =>
_runtime?.ToggleGameplayOptionsPage();
public void FocusChatEntry() => _runtime?.FocusChatEntry();
public void LogOutCharacter() => _runtime?.LogOutCharacter();
} }
internal interface IPlayerModeGameplayCommands internal interface IPlayerModeGameplayCommands
{ {
bool IsPlayerMode { get; }
void ToggleFlyOrChase(); void ToggleFlyOrChase();
void TogglePlayerMode(); void TogglePlayerMode();
void ExitPlayerMode();
} }
internal sealed class PlayerModeGameplayCommands( internal sealed class PlayerModeGameplayCommands(PlayerModeController controller)
ILocalPlayerModeSource mode, : IPlayerModeGameplayCommands
PlayerModeController controller) : IPlayerModeGameplayCommands
{ {
private readonly ILocalPlayerModeSource _mode = mode
?? throw new ArgumentNullException(nameof(mode));
private readonly PlayerModeController _controller = controller private readonly PlayerModeController _controller = controller
?? throw new ArgumentNullException(nameof(controller)); ?? throw new ArgumentNullException(nameof(controller));
public bool IsPlayerMode => _mode.IsPlayerMode;
public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase(); public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase();
public void TogglePlayerMode() => _controller.Toggle(); public void TogglePlayerMode() => _controller.Toggle();
public void ExitPlayerMode() => _controller.Exit();
} }
internal interface IItemTargetModeCommands internal interface IItemTargetModeCommands
@ -88,37 +89,6 @@ internal sealed class ItemTargetModeCommands(ItemInteractionController items)
public void CancelTargetMode() => _items.CancelTargetMode(); 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 internal interface IGameplayInputCommandTarget
{ {
bool Handle(InputAction action); bool Handle(InputAction action);
@ -135,10 +105,8 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
private readonly IRuntimeDiagnosticCommands _diagnostics; private readonly IRuntimeDiagnosticCommands _diagnostics;
private readonly IPlayerModeGameplayCommands _playerMode; private readonly IPlayerModeGameplayCommands _playerMode;
private readonly IItemTargetModeCommands _targetMode; private readonly IItemTargetModeCommands _targetMode;
private readonly IGameplayCameraModeCommands _camera;
private readonly IGameRuntimeView _runtimeView; private readonly IGameRuntimeView _runtimeView;
private readonly IRuntimeCombatCommands _combat; private readonly IRuntimeCombatCommands _combat;
private readonly IGameplayWindowCommands _window;
private readonly Action? _toggleAudioMute; private readonly Action? _toggleAudioMute;
public GameplayInputCommandController( public GameplayInputCommandController(
@ -146,21 +114,17 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
IRuntimeDiagnosticCommands diagnostics, IRuntimeDiagnosticCommands diagnostics,
IPlayerModeGameplayCommands playerMode, IPlayerModeGameplayCommands playerMode,
IItemTargetModeCommands targetMode, IItemTargetModeCommands targetMode,
IGameplayCameraModeCommands camera,
IGameRuntimeView runtimeView, IGameRuntimeView runtimeView,
IRuntimeCombatCommands combat, IRuntimeCombatCommands combat,
IGameplayWindowCommands window,
Action? toggleAudioMute = null) Action? toggleAudioMute = null)
{ {
_retained = retained ?? throw new ArgumentNullException(nameof(retained)); _retained = retained ?? throw new ArgumentNullException(nameof(retained));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
_targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode)); _targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_runtimeView = runtimeView _runtimeView = runtimeView
?? throw new ArgumentNullException(nameof(runtimeView)); ?? throw new ArgumentNullException(nameof(runtimeView));
_combat = combat ?? throw new ArgumentNullException(nameof(combat)); _combat = combat ?? throw new ArgumentNullException(nameof(combat));
_window = window ?? throw new ArgumentNullException(nameof(window));
_toggleAudioMute = toggleAudioMute; _toggleAudioMute = toggleAudioMute;
} }
@ -206,10 +170,12 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
_playerMode.TogglePlayerMode(); _playerMode.TogglePlayerMode();
return true; return true;
case InputAction.ToggleChatEntry: case InputAction.ToggleChatEntry:
// OP9: IDevToolsGameplayCommands.FocusChatInput() retired — case InputAction.EnterChatMode:
// same shape as AcdreamToggleDebugPanel above (its ImGui // Physical Tab/Enter are normally consumed by UiRoot before
// ChatPanel target was already gone). Tab is still consumed // the dispatcher. This semantic route is what makes a rebound
// here, matching the prior no-op's "handled" contract. // key and headless/UI automation reach that same retained
// chat field.
_retained.FocusChatEntry();
return true; return true;
case InputAction.ToggleOptionsPanel: case InputAction.ToggleOptionsPanel:
// Campaign OP slice OP3 (D1): F11 opens the RETAIL Options // Campaign OP slice OP3 (D1): F11 opens the RETAIL Options
@ -227,6 +193,9 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
_runtimeView.Generation, _runtimeView.Generation,
RuntimeCombatCommand.ToggleMode); RuntimeCombatCommand.ToggleMode);
return true; return true;
case InputAction.LOGOUT:
_retained.LogOutCharacter();
return true;
case InputAction.EscapeKey: case InputAction.EscapeKey:
HandleEscape(); HandleEscape();
return true; return true;
@ -239,9 +208,7 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
{ {
if (_targetMode.IsAnyTargetModeActive) if (_targetMode.IsAnyTargetModeActive)
_targetMode.CancelTargetMode(); _targetMode.CancelTargetMode();
else if (_playerMode.IsPlayerMode)
_playerMode.ExitPlayerMode();
else else
_window.Close(); _retained.ToggleGameplayOptionsPage();
} }
} }

View file

@ -9,6 +9,7 @@ internal interface ICombatInputFrameController
{ {
void Tick(); void Tick();
void HandleMovementInput(InputAction action, ActivationType activation); void HandleMovementInput(InputAction action, ActivationType activation);
void AbortAutomaticAttack();
bool HandleInputAction(InputAction action, ActivationType activation); bool HandleInputAction(InputAction action, ActivationType activation);
} }
@ -38,6 +39,11 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
RuntimeInputActivation.Press)); RuntimeInputActivation.Press));
} }
public void AbortAutomaticAttack() =>
_owner.HandleCommand(new RuntimeCombatAttackInput(
RuntimeCombatAttackCommand.AbortForMovement,
RuntimeInputActivation.Press));
public bool HandleInputAction(InputAction action, ActivationType activation) public bool HandleInputAction(InputAction action, ActivationType activation)
{ {
RuntimeCombatAttackCommand? command = action switch RuntimeCombatAttackCommand? command = action switch
@ -52,16 +58,37 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
RuntimeCombatAttackCommand.DecreasePower, RuntimeCombatAttackCommand.DecreasePower,
InputAction.CombatIncreaseAttackPower => InputAction.CombatIncreaseAttackPower =>
RuntimeCombatAttackCommand.IncreasePower, RuntimeCombatAttackCommand.IncreasePower,
InputAction.CombatDecreaseMissileAccuracy =>
RuntimeCombatAttackCommand.DecreasePower,
InputAction.CombatIncreaseMissileAccuracy =>
RuntimeCombatAttackCommand.IncreasePower,
InputAction.CombatAimLow =>
RuntimeCombatAttackCommand.LowAttack,
InputAction.CombatAimMedium =>
RuntimeCombatAttackCommand.MediumAttack,
InputAction.CombatAimHigh =>
RuntimeCombatAttackCommand.HighAttack,
_ => null, _ => null,
}; };
if (command is null) if (command is null)
return false; 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( return _owner.HandleCommand(new RuntimeCombatAttackInput(
command.Value, command.Value,
activation == ActivationType.Press activation switch
? RuntimeInputActivation.Press {
: RuntimeInputActivation.Release)); ActivationType.Press => RuntimeInputActivation.Press,
ActivationType.Release => RuntimeInputActivation.Release,
_ => RuntimeInputActivation.Press,
}));
} }
} }
@ -114,6 +141,8 @@ internal sealed class GameplayInputFrameController
public bool HandlePressedMovementAction(InputAction action) => public bool HandlePressedMovementAction(InputAction action) =>
_movement.HandlePressedAction(action); _movement.HandlePressedAction(action);
public void AbortAutomaticAttack() => _combat.AbortAutomaticAttack();
public void QueueRawMouseDelta(float dx, float dy) => public void QueueRawMouseDelta(float dx, float dy) =>
_mouseLook?.QueueRawDelta(dx, dy); _mouseLook?.QueueRawDelta(dx, dy);

View file

@ -149,7 +149,9 @@ internal sealed class MouseLookController : IMouseLookInputFrameController
return true; return true;
} }
if (action != InputAction.CameraInstantMouseLook) if (action is not (
InputAction.CameraInstantMouseLook
or InputAction.CameraActivateAlternateMode))
return false; return false;
if (activation == ActivationType.Press) if (activation == ActivationType.Press)

View file

@ -0,0 +1,129 @@
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
/// <summary>
/// Verbatim Sept-2013 <c>ACCmdInterp::InitializeEmoteInputActionHash</c>
/// (<c>0x0058B510</c>). <c>ACCmdInterp::OnAction</c>
/// (<c>0x0058B370</c>) resolves one of these input actions and submits the
/// corresponding raw motion through <c>SetMotion</c> with start=true.
/// </summary>
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;
}
}

View file

@ -0,0 +1,601 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
/// <summary>
/// Parser/writer for retail's editable <c>Documents\Asheron's Call\*.keymap</c>
/// 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 <see cref="KeyBindings"/>.
/// </summary>
public static class RetailKeymapFile
{
private static readonly Regex BindingLine = new(
"^(?<action>[A-Za-z0-9_]+)\\s*\\[\\s*\"\"\\s*\\[\\s*"
+ "(?<device>[0-9]+)\\s+(?<control>[A-Za-z0-9_]+)"
+ "(?:\\s+(?<sub>[A-Za-z]+))?\\s*\\]"
+ "(?:\\s+(?<modifier>0x[0-9A-Fa-f]+|[0-9]+))?"
+ "(?:\\s+(?<activation>[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<string, uint> 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<IReadOnlyDictionary<InputAction, string>> ActionNamesHolder =
new(BuildActionNames);
private static readonly Lazy<IReadOnlyDictionary<string, InputAction>> ActionsByFileNameHolder =
new(BuildActionsByFileName);
private static IReadOnlyDictionary<InputAction, string> ActionNames => ActionNamesHolder.Value;
private static IReadOnlyDictionary<string, InputAction> 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<InputAction, string> BuildActionNames()
{
var names = new Dictionary<InputAction, string>();
foreach (InputAction action in RetailActionIdentityTable.ReverseMap.Keys)
names[action] = FileActionName(action);
return names;
}
private static IReadOnlyDictionary<string, InputAction> BuildActionsByFileName()
{
var actions = new Dictionary<string, InputAction>(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<InputAction, string> CharacterOptionNames =
new Dictionary<InputAction, string>
{
[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);
/// <summary>
/// Owns retail's active-profile preference and <c>*.keymap</c> directory.
/// The profile selector lives beside acdream's portable JSON mirror; profile
/// files live in retail's Documents/Asheron's Call folder.
/// </summary>
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<string> ListFiles()
{
try
{
if (!Directory.Exists(_directory)) return Array.Empty<string>();
return Directory.EnumerateFiles(_directory, "*.keymap", SearchOption.TopDirectoryOnly)
.Select(Path.GetFileName)
.Where(static name => !string.IsNullOrEmpty(name))
.Cast<string>()
.OrderBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
catch (Exception failure)
{
Console.WriteLine($"keymap: profile list failed: {failure.Message}");
return Array.Empty<string>();
}
}
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);
}
}
}

View file

@ -24,6 +24,8 @@ internal sealed class SelectionInteractionController
private readonly IPlayerInteractionMovementSink _movement; private readonly IPlayerInteractionMovementSink _movement;
private readonly PlayerApproachCompletionState _approachCompletions; private readonly PlayerApproachCompletionState _approachCompletions;
private readonly Action<string>? _toast; private readonly Action<string>? _toast;
private readonly Func<uint, bool>? _splitStack;
private readonly Func<IEnumerable<uint>> _fellowshipMembers;
public SelectionInteractionController( public SelectionInteractionController(
SelectionState selection, SelectionState selection,
@ -32,7 +34,9 @@ internal sealed class SelectionInteractionController
IRuntimeInteractionTransport transport, IRuntimeInteractionTransport transport,
IPlayerInteractionMovementSink movement, IPlayerInteractionMovementSink movement,
Action<string>? toast = null, Action<string>? toast = null,
PlayerApproachCompletionState? approachCompletions = null) PlayerApproachCompletionState? approachCompletions = null,
Func<uint, bool>? splitStack = null,
Func<IEnumerable<uint>>? fellowshipMembers = null)
{ {
_selection = selection ?? throw new ArgumentNullException(nameof(selection)); _selection = selection ?? throw new ArgumentNullException(nameof(selection));
_query = query ?? throw new ArgumentNullException(nameof(query)); _query = query ?? throw new ArgumentNullException(nameof(query));
@ -43,14 +47,96 @@ internal sealed class SelectionInteractionController
_toast = toast; _toast = toast;
_approachCompletions = approachCompletions _approachCompletions = approachCompletions
?? new PlayerApproachCompletionState(); ?? new PlayerApproachCompletionState();
_splitStack = splitStack;
_fellowshipMembers = fellowshipMembers ?? (() => Array.Empty<uint>());
} }
public bool HandleInputAction(InputAction action) public bool HandleInputAction(InputAction action)
{ {
switch (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: 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; return true;
case InputAction.SelectionPreviousSelection: case InputAction.SelectionPreviousSelection:
_selection.SelectPrevious(); _selection.SelectPrevious();
@ -87,11 +173,109 @@ internal sealed class SelectionInteractionController
case InputAction.EscapeKey when _items.IsAnyTargetModeActive: case InputAction.EscapeKey when _items.IsAnyTargetModeActive:
_items.CancelTargetMode(); _items.CancelTargetMode();
return true; 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: default:
return false; 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) public uint? PickAtCursor(bool includeSelf)
=> _query.PickAtCursor(includeSelf); => _query.PickAtCursor(includeSelf);

View file

@ -6,7 +6,9 @@ using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Properties;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Ui;
using AcDream.Core.World; using AcDream.Core.World;
namespace AcDream.App.Interaction; namespace AcDream.App.Interaction;
@ -25,6 +27,22 @@ internal readonly record struct WorldInteractionTarget(
internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared); 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( internal readonly record struct InteractionApproach(
WorldInteractionTarget Target, WorldInteractionTarget Target,
PlayerInteractionPose Player, PlayerInteractionPose Player,
@ -36,6 +54,7 @@ internal readonly record struct InteractionApproach(
internal interface IWorldSelectionQuery internal interface IWorldSelectionQuery
{ {
uint PlayerGuid => 0u;
uint? PickAtCursor(bool includeSelf); uint? PickAtCursor(bool includeSelf);
uint? PickAt(float mouseX, float mouseY, bool includeSelf); uint? PickAt(float mouseX, float mouseY, bool includeSelf);
void BeginLightingPulse(uint serverGuid); void BeginLightingPulse(uint serverGuid);
@ -46,6 +65,16 @@ internal interface IWorldSelectionQuery
bool IsHostileMonster(uint serverGuid); bool IsHostileMonster(uint serverGuid);
bool IsAttackableTarget(uint serverGuid); bool IsAttackableTarget(uint serverGuid);
ClosestCombatTarget? FindClosestHostileMonster(); 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 IsUseable(uint serverGuid);
bool IsPickupable(uint serverGuid); bool IsPickupable(uint serverGuid);
bool IsWieldedByPlayer(uint serverGuid); bool IsWieldedByPlayer(uint serverGuid);
@ -111,6 +140,9 @@ internal sealed class WorldSelectionQuery
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _setupCylinder; private readonly Func<uint, WorldEntity, (float Radius, float Height)> _setupCylinder;
private readonly Func<uint, (Vector3 Origin, float Radius)?> _selectionSphere; private readonly Func<uint, (Vector3 Origin, float Radius)?> _selectionSphere;
private readonly Func<uint, Matrix4x4?> _childRootPose; private readonly Func<uint, Matrix4x4?> _childRootPose;
private readonly Func<uint, bool> _hasOpenedCorpse;
private readonly Func<CombatMode> _combatMode;
private readonly Func<uint, bool> _isFellow;
public WorldSelectionQuery( public WorldSelectionQuery(
LiveEntityRuntime liveEntities, LiveEntityRuntime liveEntities,
@ -122,7 +154,10 @@ internal sealed class WorldSelectionQuery
Func<PlayerInteractionPose?> playerPose, Func<PlayerInteractionPose?> playerPose,
Func<uint, WorldEntity, (float Radius, float Height)> setupCylinder, Func<uint, WorldEntity, (float Radius, float Height)> setupCylinder,
Func<uint, (Vector3 Origin, float Radius)?> selectionSphere, Func<uint, (Vector3 Origin, float Radius)?> selectionSphere,
Func<uint, Matrix4x4?> childRootPose) Func<uint, Matrix4x4?> childRootPose,
Func<uint, bool>? hasOpenedCorpse = null,
Func<CombatMode>? combatMode = null,
Func<uint, bool>? isFellow = null)
{ {
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
@ -134,8 +169,13 @@ internal sealed class WorldSelectionQuery
_setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder)); _setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder));
_selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere)); _selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere));
_childRootPose = childRootPose ?? throw new ArgumentNullException(nameof(childRootPose)); _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) public uint? PickAtCursor(bool includeSelf)
{ {
Vector2 cursor = _cursor(); Vector2 cursor = _cursor();
@ -293,6 +333,183 @@ internal sealed class WorldSelectionQuery
return best; return best;
} }
/// <summary>
/// Port of retail <c>CPlayerSystem::SelectNext @ 0x0055F9A0</c>. The
/// ordering scalar is the retail player-space horizontal distance plus
/// <c>1.2 * abs(z)</c>; the object id breaks exact-distance ties through
/// <c>CPlayerSystem::Farther @ 0x0055D830</c>. Previous/next wrap exactly
/// as the paired calls in <c>CPlayerSystem::OnAction @ 0x00561890</c>.
/// </summary>
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;
/// <summary> /// <summary>
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking /// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c> /// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>

View file

@ -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;
/// <summary>
/// Immutable projection of retail's portal-DAT ChatPoseTable (0x0E000007).
/// Command lookup is case-insensitive, matching
/// <c>ChatPoseTable::InqChatPoseCommand @ 0x00570AD0</c>.
/// </summary>
internal sealed class DatChatPoseCatalog
{
private const uint ChatPoseTableId = 0x0E000007u;
private readonly IReadOnlyDictionary<string, RetailChatPose> _poses;
private DatChatPoseCatalog(
IReadOnlyDictionary<string, RetailChatPose> poses) =>
_poses = poses;
public static DatChatPoseCatalog Load(IDatReaderWriter dats, object datLock)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(datLock);
lock (datLock)
{
ChatPoseTable? table = dats.Get<ChatPoseTable>(ChatPoseTableId);
if (table is null)
return new DatChatPoseCatalog(
new Dictionary<string, RetailChatPose>(
StringComparer.OrdinalIgnoreCase));
var emotes = new Dictionary<string, (string Self, string Others)>(
StringComparer.OrdinalIgnoreCase);
foreach (var pair in table.ChatEmotes)
{
emotes[pair.Key.Value] = (
pair.Value.MyEmote.Value,
pair.Value.OtherEmote.Value);
}
var poses = new Dictionary<string, RetailChatPose>(
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),
};
}
}

View file

@ -74,7 +74,10 @@ internal sealed record LiveSessionCommandBindings(
Action<uint> SendAllegianceKick, Action<uint> SendAllegianceKick,
Action<string> SendAllegianceInfoRequest, Action<string> SendAllegianceInfoRequest,
Action<bool> SendAllegianceUpdateRequest, Action<bool> SendAllegianceUpdateRequest,
Action<string>? Log = null); Action<string>? Log = null,
Func<string, RetailChatPose?>? ResolvePose = null,
Action<uint>? ExecuteMotion = null,
Action<string>? SendSoulEmote = null);
internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry); internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry);
internal readonly record struct RemoveShortcutRuntimeCmd(uint Index); internal readonly record struct RemoveShortcutRuntimeCmd(uint Index);
@ -185,7 +188,10 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
bindings.SendTell, bindings.SendTell,
bindings.SendChannel, bindings.SendChannel,
bindings.SendTurbineChat, bindings.SendTurbineChat,
bindings.Log)); bindings.Log,
bindings.ResolvePose,
bindings.ExecuteMotion,
bindings.SendSoulEmote));
// Campaign CH slice CH4 (2026-08-09): the 22 unregistered // Campaign CH slice CH4 (2026-08-09): the 22 unregistered
// ChannelSystem::GetChannelID fallback tags — bypasses // ChannelSystem::GetChannelID fallback tags — bypasses
// ChatChannelKind/ChannelResolver entirely and sends the raw // ChatChannelKind/ChannelResolver entirely and sends the raw

View file

@ -67,6 +67,7 @@ internal sealed record LiveSessionInteractionRuntime(
internal sealed record LiveSessionWorldRuntime( internal sealed record LiveSessionWorldRuntime(
IDatReaderWriter Dats, IDatReaderWriter Dats,
object DatLock,
// Logout-audio round (2026-08-17): null only when audio is disabled // Logout-audio round (2026-08-17): null only when audio is disabled
// (ACDREAM_NO_AUDIO / init failure) — the reset step and entered-world // (ACDREAM_NO_AUDIO / init failure) — the reset step and entered-world
// resume both no-op then. // resume both no-op then.
@ -114,6 +115,7 @@ internal sealed class LiveSessionRuntimeFactory
private readonly IReadOnlyList<string> _loginCommands; private readonly IReadOnlyList<string> _loginCommands;
private readonly TimeSpan _loginCommandDelay; private readonly TimeSpan _loginCommandDelay;
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
private readonly DatChatPoseCatalog _chatPoses;
/// <summary> /// <summary>
/// Where a bare <c>@log</c> filename lands. See <see cref="ChatSessionLog"/> /// Where a bare <c>@log</c> filename lands. See <see cref="ChatSessionLog"/>
@ -162,6 +164,7 @@ internal sealed class LiveSessionRuntimeFactory
_loginCommands = loginCommands is null ? [] : [.. loginCommands]; _loginCommands = loginCommands is null ? [] : [.. loginCommands];
_loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs); _loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs);
_timeProvider = timeProvider ?? TimeProvider.System; _timeProvider = timeProvider ?? TimeProvider.System;
_chatPoses = DatChatPoseCatalog.Load(_world.Dats, _world.DatLock);
// C3c-F1: stat recomputes route through the Runtime movement owner's // C3c-F1: stat recomputes route through the Runtime movement owner's
// typed application seam; App keeps zero direct controller mutations. // typed application seam; App keeps zero direct controller mutations.
_movementStats = new LiveMovementStatsApplier( _movementStats = new LiveMovementStatsApplier(
@ -217,6 +220,15 @@ internal sealed class LiveSessionRuntimeFactory
RestoreLayout: () => RestoreLayout: () =>
{ {
_ui.RetailUi?.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 // MUST-FIX 3 re-fix (FA4 re-review REOPEN): re-declare a
// still-open Fellowship page's 0x00A6 now we are in world — // still-open Fellowship page's 0x00A6 now we are in world —
// RestoreLayout is the post-world UI-restore moment, and // RestoreLayout is the post-world UI-restore moment, and
@ -786,7 +798,14 @@ internal sealed class LiveSessionRuntimeFactory
SendAllegianceKick: session.SendAllegianceKick, SendAllegianceKick: session.SendAllegianceKick,
SendAllegianceInfoRequest: session.SendAllegianceInfoRequest, SendAllegianceInfoRequest: session.SendAllegianceInfoRequest,
SendAllegianceUpdateRequest: session.SendAllegianceUpdateRequest, 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() => private static double ClientTimerNow() =>

View file

@ -85,6 +85,28 @@ internal sealed class CameraFrameController : ICameraFramePhase
retail.AdjustPitch(+adjustment * 0.02f); retail.AdjustPitch(+adjustment * 0.02f);
if (input.Lower) if (input.Lower)
retail.AdjustPitch(-adjustment * 0.02f); 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)) if (!_localFrame.TryGetPresentationAfterNetwork(out var playerFrame))

View file

@ -10,6 +10,21 @@ namespace AcDream.App.Rendering;
/// </summary> /// </summary>
public sealed class ChaseCamera : ICamera 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 Vector3 Position { get; private set; }
public float Aspect { get; set; } = 16f / 9f; public float Aspect { get; set; } = 16f / 9f;
// #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView. // #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
@ -108,17 +123,44 @@ public sealed class ChaseCamera : ICamera
float horizontalDist = Distance * MathF.Cos(Pitch); float horizontalDist = Distance * MathF.Cos(Pitch);
float verticalDist = Distance * MathF.Sin(Pitch); float verticalDist = Distance * MathF.Sin(Pitch);
if (_inHead)
{
Vector3 forward = new(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f);
Position = new Vector3(
playerPosition.X,
playerPosition.Y,
_trackedZ + EyeHeight) + forward * 0.18f;
_lookAt = Position + forward;
}
else if (_targetDirectionLocal is { } localDirection)
{
Vector3 pivot = new(playerPosition.X, playerPosition.Y, _trackedZ + EyeHeight);
var directedPose = RetailChaseCamera.ComputeTargetDirectionPose(
pivot,
new Vector3(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f),
Distance,
Pitch,
localDirection);
Position = directedPose.eye;
Vector3 direction = directedPose.forward;
_lookAt = Position + direction;
}
else
{
Position = new Vector3( Position = new Vector3(
playerPosition.X - forwardX * horizontalDist, playerPosition.X - forwardX * horizontalDist,
playerPosition.Y - forwardY * horizontalDist, playerPosition.Y - forwardY * horizontalDist,
_trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne) _trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
} }
}
/// <summary> /// <summary>
/// Adjust pitch by a delta (from mouse Y movement). /// Adjust pitch by a delta (from mouse Y movement).
/// </summary> /// </summary>
public void AdjustPitch(float delta) public void AdjustPitch(float delta)
{ {
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
} }
@ -127,6 +169,101 @@ public sealed class ChaseCamera : ICamera
/// </summary> /// </summary>
public void AdjustDistance(float delta) public void AdjustDistance(float delta)
{ {
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax); 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);
}
} }

View file

@ -1,5 +1,6 @@
using AcDream.Core.Plugins; using AcDream.Core.Plugins;
using AcDream.App.Composition; using AcDream.App.Composition;
using AcDream.App.Input;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene;
@ -17,6 +18,7 @@ using DatReaderWriter;
using Silk.NET.Input; using Silk.NET.Input;
using Silk.NET.Maths; using Silk.NET.Maths;
using Silk.NET.Windowing; using Silk.NET.Windowing;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -599,14 +601,18 @@ public sealed class GameWindow :
// startup — no other call to RetailDefaults() / AcdreamCurrentDefaults() // startup — no other call to RetailDefaults() / AcdreamCurrentDefaults()
// should land in the GameWindow construction path. // should land in the GameWindow construction path.
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings; private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings;
private bool _keyBindingsPersisted;
private readonly GraphicalHostPlatformServices _platformServices; private readonly GraphicalHostPlatformServices _platformServices;
private readonly ApplicationPathSet _applicationPaths; private readonly ApplicationPathSet _applicationPaths;
private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings( private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings(
string path) string path)
{ {
var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path); var bindings = AcDream.App.Input.RetailKeymapProfileStore.LoadActiveOrJson(
Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}"); path, out string profileName);
Console.WriteLine(
$"keybinds: loaded {bindings.All.Count} bindings; active retail profile "
+ $"'{profileName}', JSON mirror {path}");
return bindings; return bindings;
} }
@ -1522,7 +1528,8 @@ public sealed class GameWindow :
hostInputCamera.GpuFrameLifetime, hostInputCamera.GpuFrameLifetime,
() => WorldTime.CurrentCalendar, () => WorldTime.CurrentCalendar,
settingsDevTools.RenderPacks, settingsDevTools.RenderPacks,
_renderPackDiagnostics.CaptureDiagnostics), _renderPackDiagnostics.CaptureDiagnostics,
_applicationPaths.ScreenshotsDirectory),
_retailUiLease, _retailUiLease,
this).Compose( this).Compose(
platformResult, platformResult,
@ -1569,6 +1576,7 @@ public sealed class GameWindow :
_cellVisibility, _cellVisibility,
_liveWorldOrigin, _liveWorldOrigin,
_localPlayerIdentity, _localPlayerIdentity,
_chaseCameraInput,
_pointerPosition, _pointerPosition,
_playerApproachCompletions, _playerApproachCompletions,
_renderResourceLifetime, _renderResourceLifetime,
@ -1821,6 +1829,7 @@ public sealed class GameWindow :
if (!_lifetime.HasShutdownRoots) if (!_lifetime.HasShutdownRoots)
{ {
PersistKeyBindingsAtShutdown();
// Campaign LA slice LA1: capture BEFORE the shutdown roots run — // Campaign LA slice LA1: capture BEFORE the shutdown roots run —
// by the time teardown completes, IsInWorld is always false // by the time teardown completes, IsInWorld is always false
// regardless of whether a real session was ever connected. // regardless of whether a real session was ever connected.
@ -1861,6 +1870,33 @@ public sealed class GameWindow :
ReportExited(report); 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}");
}
}
/// <summary> /// <summary>
/// Writes the ONE terminal "exited" status event for this session /// Writes the ONE terminal "exited" status event for this session
/// (fix #406). A resource-shutdown transaction can converge cleanly /// (fix #406). A resource-shutdown transaction can converge cleanly

View file

@ -14,6 +14,8 @@ internal interface IPaperdollDollRenderer
{ {
void SetDoll(WorldEntity? doll); void SetDoll(WorldEntity? doll);
void Prepare();
uint Render(int width, int height); uint Render(int width, int height);
} }
@ -73,9 +75,6 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
public void Render() public void Render()
{ {
if (!_view.TryGetVisibleSize(out int width, out int height))
return;
if (_dirty) if (_dirty)
{ {
if (_factory.TryBuild(out WorldEntity? doll)) 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)); _view.SetTextureHandle(_renderer.Render(width, height));
} }

View file

@ -42,6 +42,8 @@ public sealed class PaperdollViewportRenderer :
public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll); public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll);
public void Prepare() => _renderer.Prepare();
public uint Render(int width, int height) => public uint Render(int width, int height) =>
_renderer.Render(width, height); _renderer.Render(width, height);

View file

@ -94,6 +94,11 @@ internal sealed class PrivateEntityViewportRenderer :
/// feature does not exist for them, not just "unused".</summary> /// feature does not exist for them, not just "unused".</summary>
private readonly EntitySlot? _backdropSlot; 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 IGpuRenderTarget? _target;
private IGpuSampler? _sampler; private IGpuSampler? _sampler;
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned; private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
@ -170,6 +175,29 @@ internal sealed class PrivateEntityViewportRenderer :
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity); public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
/// <summary>
/// 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.
/// </summary>
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<WorldEntity> entities = BuildDrawEntities(
_backdropSlot?.Entity,
entity);
return _dispatcher.PreparePrivateEntityResources(entities);
}
/// <summary> /// <summary>
/// Sets or clears the environment backdrop entity drawn BEHIND the main /// Sets or clears the environment backdrop entity drawn BEHIND the main
/// entity — GF-7/GF-14's fix, retail's <c>gmCG3DView::m_pbgObject</c>. Only /// entity — GF-7/GF-14's fix, retail's <c>gmCG3DView::m_pbgObject</c>. Only
@ -219,6 +247,16 @@ internal sealed class PrivateEntityViewportRenderer :
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0) if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u; return 0u;
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(
_backdropSlot?.Entity,
entity);
if (!_dispatcher.PreparePrivateEntityResources(drawEntities))
{
return _hasRenderedScene && _slot.IsAssigned
? UiTextureTableHandle.FromSlot(_slot)
: 0u;
}
EnsureRenderTarget(width, height); EnsureRenderTarget(width, height);
if (_target is null) if (_target is null)
return 0u; return 0u;
@ -254,7 +292,6 @@ internal sealed class PrivateEntityViewportRenderer :
UploadCreatureLight(); UploadCreatureLight();
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity);
var entries = var entries =
new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)[] IReadOnlyDictionary<uint, WorldEntity>?)[]

View file

@ -29,6 +29,12 @@ namespace AcDream.App.Rendering;
/// </summary> /// </summary>
public sealed class RetailChaseCamera : ICamera 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. // ICamera surface.
public Vector3 Position { get; private set; } public Vector3 Position { get; private set; }
@ -75,6 +81,20 @@ public sealed class RetailChaseCamera : ICamera
/// <summary>Height of look-at anchor above the player's feet (m). Retail default 1.5.</summary> /// <summary>Height of look-at anchor above the player's feet (m). Retail default 1.5.</summary>
public float PivotHeight { get; set; } = 1.5f; 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;
/// <summary> /// <summary>
/// Optional spring-arm collision probe. When set (and /// Optional spring-arm collision probe. When set (and
/// <see cref="CameraDiagnostics.CollideCamera"/> is true), the damped eye /// <see cref="CameraDiagnostics.CollideCamera"/> is true), the damped eye
@ -172,7 +192,12 @@ public sealed class RetailChaseCamera : ICamera
// target supplies the frame heading. Without this local rotation, enabling // target supplies the frame heading. Without this local rotation, enabling
// Keep in View snaps the camera behind the target and disables RMB orbit. // Keep in View snaps the camera behind the target and disables RMB orbit.
float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f; float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f;
(Vector3 targetEye, Vector3 targetForward) = ComputeDesiredPose( (Vector3 targetEye, Vector3 targetForward) = _inHead
? ComputeInHeadPose(pivotWorld, heading)
: _targetDirectionLocal is { } localDirection
? ComputeTargetDirectionPose(
pivotWorld, heading, Distance, Pitch, localDirection)
: ComputeDesiredPose(
pivotWorld, heading, Distance, Pitch, viewerYawOffset); pivotWorld, heading, Distance, Pitch, viewerYawOffset);
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera // 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
@ -279,16 +304,120 @@ public sealed class RetailChaseCamera : ICamera
/// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors /// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors
/// legacy <c>ChaseCamera.AdjustDistance</c>. /// legacy <c>ChaseCamera.AdjustDistance</c>.
/// </summary> /// </summary>
public void AdjustDistance(float delta) => public void AdjustDistance(float delta)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax); Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
}
/// <summary> /// <summary>
/// Adjust the camera pitch by a delta (radians), clamped to /// Adjust the camera pitch by a delta (radians), clamped to
/// <see cref="PitchMin"/>..<see cref="PitchMax"/>. Mirrors legacy /// <see cref="PitchMin"/>..<see cref="PitchMax"/>. Mirrors legacy
/// <c>ChaseCamera.AdjustPitch</c>. /// <c>ChaseCamera.AdjustPitch</c>.
/// </summary> /// </summary>
public void AdjustPitch(float delta) => public void AdjustPitch(float delta)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); 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);
}
/// <summary> /// <summary>
/// Public entry point for the mouse-input low-pass filter. Calls /// Public entry point for the mouse-input low-pass filter. Calls
@ -436,6 +565,47 @@ public sealed class RetailChaseCamera : ICamera
return (eye, forward); return (eye, forward);
} }
/// <summary>
/// Retail <c>CameraSet::SetInHead @ 0x00458CE0</c>: 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.
/// </summary>
internal static (Vector3 eye, Vector3 forward) ComputeInHeadPose(
Vector3 pivotWorld,
Vector3 heading)
{
Vector3 forward = Vector3.Normalize(heading);
return (pivotWorld + forward * RetailFirstPersonForward, forward);
}
/// <summary>
/// Transform both retail <c>viewer_offset</c> and <c>target_direction</c>
/// 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.
/// </summary>
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);
}
/// <summary> /// <summary>
/// Build an orthonormal basis with <c>forward = heading</c>. World /// Build an orthonormal basis with <c>forward = heading</c>. World
/// up is <c>(0, 0, 1)</c>; if <c>heading</c> is near-parallel to it /// up is <c>(0, 0, 1)</c>; if <c>heading</c> is near-parallel to it

View file

@ -473,6 +473,7 @@ public sealed unsafe partial class WbDrawDispatcher
} }
float opacity = PackedPartOpacity( float opacity = PackedPartOpacity(
entity.ServerGuid,
entity.LocalEntityId, entity.LocalEntityId,
(uint)setupPartIndex); (uint)setupPartIndex);
if (opacity < 1f) if (opacity < 1f)
@ -527,6 +528,7 @@ public sealed unsafe partial class WbDrawDispatcher
// one-part assumption and kept the Bind Stone's four // one-part assumption and kept the Bind Stone's four
// hook-hidden shard parts visible. // hook-hidden shard parts visible.
float opacity = PackedPartOpacity( float opacity = PackedPartOpacity(
entity.ServerGuid,
entity.LocalEntityId, entity.LocalEntityId,
(uint)partIndex); (uint)partIndex);
if (opacity < 1f) if (opacity < 1f)
@ -585,20 +587,24 @@ public sealed unsafe partial class WbDrawDispatcher
anyVao != 0 && alphaQueueCollecting; anyVao != 0 && alphaQueueCollecting;
private float PackedPartOpacity( private float PackedPartOpacity(
uint serverGuid,
uint localEntityId, uint localEntityId,
uint setupPartIndex) uint setupPartIndex)
{ {
float opacity = EntityOpacity(serverGuid);
if (opacity <= 0f)
return 0f;
if (!_translucencyFades.TryGetCurrentValue( if (!_translucencyFades.TryGetCurrentValue(
localEntityId, localEntityId,
setupPartIndex, setupPartIndex,
out float translucency)) out float translucency))
{ {
return 1f; return opacity;
} }
return translucency >= 1f return translucency >= 1f
? 0f ? 0f
: 1f - translucency; : opacity * (1f - translucency);
} }
private bool ClassifyPackedBatches( private bool ClassifyPackedBatches(

View file

@ -176,7 +176,8 @@ public sealed unsafe partial class WbDrawDispatcher
RetailAlphaQueue? alphaQueue = null, RetailAlphaQueue? alphaQueue = null,
long? alphaScratchBudgetBytes = null, long? alphaScratchBudgetBytes = null,
TerrainAtlas.RetailDetailTextureBinding buildingDetail = default, TerrainAtlas.RetailDetailTextureBinding buildingDetail = default,
Func<bool>? buildingDetailEnabled = null) Func<bool>? buildingDetailEnabled = null,
Func<uint, float>? hierarchicalTranslucency = null)
{ {
_device = device ?? throw new ArgumentNullException(nameof(device)); _device = device ?? throw new ArgumentNullException(nameof(device));
_frames = frames ?? throw new ArgumentNullException(nameof(frames)); _frames = frames ?? throw new ArgumentNullException(nameof(frames));
@ -192,6 +193,7 @@ public sealed unsafe partial class WbDrawDispatcher
_selectionSink = selectionSink; _selectionSink = selectionSink;
_selectionLighting = selectionSink as IRetailSelectionLightingSource; _selectionLighting = selectionSink as IRetailSelectionLightingSource;
_alphaQueue = alphaQueue; _alphaQueue = alphaQueue;
_hierarchicalTranslucency = hierarchicalTranslucency;
_alphaSource = new AlphaDrawSource(this); _alphaSource = new AlphaDrawSource(this);
_buildingDetail = buildingDetail; _buildingDetail = buildingDetail;
_buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures; _buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures;

View file

@ -91,6 +91,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
private readonly IRetailSelectionRenderSink? _selectionSink; private readonly IRetailSelectionRenderSink? _selectionSink;
private readonly IRetailSelectionLightingSource? _selectionLighting; private readonly IRetailSelectionLightingSource? _selectionLighting;
private readonly RetailAlphaQueue? _alphaQueue; private readonly RetailAlphaQueue? _alphaQueue;
private readonly Func<uint, float>? _hierarchicalTranslucency;
private readonly AlphaDrawSource _alphaSource; private readonly AlphaDrawSource _alphaSource;
private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy; private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy;
private int _scratchPeakUnits; 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 // sets draw_state|=1 and skips the whole part outright — not a
// blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees // blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees
// t=1 commits the bitwise-exact value so this check is safe. // 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 (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue))
{ {
if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely 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)) 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 // entity — the Bind Stone's idle cycle hides its four authored
// shard parts (3-6) with TransparentPartHook start=end=1.0 // shard parts (3-6) with TransparentPartHook start=end=1.0
// every loop, and they stayed visible. // every loop, and they stayed visible.
float opacityMultiplier = 1.0f; float opacityMultiplier = EntityOpacity(entity.ServerGuid);
bool fullyInvisible = false; bool fullyInvisible = false;
if (opacityMultiplier <= 0f)
fullyInvisible = true;
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)partIdx, out float translucencyValue)) if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)partIdx, out float translucencyValue))
{ {
if (translucencyValue >= 1.0f) fullyInvisible = true; if (translucencyValue >= 1.0f) fullyInvisible = true;
else opacityMultiplier = 1f - translucencyValue; else opacityMultiplier *= 1f - translucencyValue;
} }
if (!fullyInvisible) if (!fullyInvisible)
@ -1901,6 +1905,32 @@ public sealed partial class WbDrawDispatcher : IDisposable
observeCurrentPath: true); observeCurrentPath: true);
} }
/// <summary>
/// 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.
/// </summary>
internal bool PreparePrivateEntityResources(
IReadOnlyList<WorldEntity> 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);
}
/// <summary> /// <summary>
/// Whether there is a mesh source to draw from. The encoder arm has no /// 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 /// vertex array of its own — the pipeline owns one shaped by

View file

@ -317,6 +317,28 @@ internal sealed class CurrentGameRuntimeCommandAdapter
return Result(status); 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( public RuntimeCommandResult SetIntent(
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
in MovementInput input) in MovementInput input)

View file

@ -736,6 +736,16 @@ internal sealed class LocalPlayerTeleportController
/// </summary> /// </summary>
private readonly ILocalPlayerLogoutOperations _logout; private readonly ILocalPlayerLogoutOperations _logout;
/// <summary>
/// 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 <see cref="ResetGenerationPresentation"/>; this latch transfers
/// that already-completed retirement across the synchronous callback so
/// it is consumed once instead of starting a second old-window pass.
/// </summary>
private bool _logoutStreamingRetirementPrepared;
public LocalPlayerTeleportController( public LocalPlayerTeleportController(
ILocalPlayerTeleportAuthority authority, ILocalPlayerTeleportAuthority authority,
ILocalPlayerTeleportInputLifetime input, ILocalPlayerTeleportInputLifetime input,
@ -984,6 +994,8 @@ internal sealed class LocalPlayerTeleportController
return false; return false;
} }
_logoutStreamingRetirementPrepared = false;
if (!_transit.TryBeginLogoutRequest(_logout.IsLocalPlayerKiller)) if (!_transit.TryBeginLogoutRequest(_logout.IsLocalPlayerKiller))
return false; return false;
@ -1088,8 +1100,23 @@ internal sealed class LocalPlayerTeleportController
private void CompleteLogoutHandoff(long generation) 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; return;
}
_logoutStreamingRetirementPrepared = true;
if (!_transit.CompleteLogout() || _lifetimeGeneration != generation)
{
_logoutStreamingRetirementPrepared = false;
return;
}
Console.WriteLine( Console.WriteLine(
"live: logout confirmed — returning to character select"); "live: logout confirmed — returning to character select");
@ -1102,6 +1129,8 @@ internal sealed class LocalPlayerTeleportController
return; return;
} }
_logoutStreamingRetirementPrepared = false;
// The transaction refused or degraded to a full stop. If a reset // The transaction refused or degraded to a full stop. If a reset
// reached this controller the lifetime moved and everything is // reached this controller the lifetime moved and everything is
// already clean; otherwise retire the presentation here so a // already clean; otherwise retire the presentation here so a
@ -1927,6 +1956,11 @@ internal sealed class LocalPlayerTeleportController
bool clearSession, bool clearSession,
bool resetCanonicalTransit = false) bool resetCanonicalTransit = false)
{ {
bool streamingRetirementPrepared = clearSession
&& _logoutStreamingRetirementPrepared;
if (clearSession)
_logoutStreamingRetirementPrepared = false;
long generation = checked(++_lifetimeGeneration); long generation = checked(++_lifetimeGeneration);
_pendingCell = 0u; _pendingCell = 0u;
@ -1951,6 +1985,7 @@ internal sealed class LocalPlayerTeleportController
if (clearSession) if (clearSession)
_loginPlacementCompleted = false; _loginPlacementCompleted = false;
if (!streamingRetirementPrepared)
_streaming.ResetRecenter(clearSession); _streaming.ResetRecenter(clearSession);
if (_lifetimeGeneration != generation) if (_lifetimeGeneration != generation)
return generation; return generation;

View file

@ -62,10 +62,10 @@ internal sealed class AutoWieldController : IDisposable
private readonly Func<uint> _playerGuid; private readonly Func<uint> _playerGuid;
private readonly Action<uint, uint>? _sendWield; private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<string>? _toast;
private readonly Action<string>? _systemMessage; private readonly Action<string>? _systemMessage;
private readonly CombatState? _combatState; private readonly CombatState? _combatState;
private readonly Action<CombatMode>? _sendChangeCombatMode; private readonly Action<CombatMode>? _sendChangeCombatMode;
private readonly InventoryTransactionState? _transactions;
private PendingSwitch? _pendingSwitch; private PendingSwitch? _pendingSwitch;
private PendingCombatSettlement? _pendingCombatSettlement; private PendingCombatSettlement? _pendingCombatSettlement;
@ -79,19 +79,19 @@ internal sealed class AutoWieldController : IDisposable
Func<uint> playerGuid, Func<uint> playerGuid,
Action<uint, uint>? sendWield, Action<uint, uint>? sendWield,
Action<uint, uint, int>? sendPutItemInContainer, Action<uint, uint, int>? sendPutItemInContainer,
Action<string>? toast,
Action<string>? systemMessage = null, Action<string>? systemMessage = null,
CombatState? combatState = null, CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null) Action<CombatMode>? sendChangeCombatMode = null,
InventoryTransactionState? transactions = null)
{ {
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_sendWield = sendWield; _sendWield = sendWield;
_sendPutItemInContainer = sendPutItemInContainer; _sendPutItemInContainer = sendPutItemInContainer;
_toast = toast;
_systemMessage = systemMessage; _systemMessage = systemMessage;
_combatState = combatState; _combatState = combatState;
_sendChangeCombatMode = sendChangeCombatMode; _sendChangeCombatMode = sendChangeCombatMode;
_transactions = transactions;
_objects.ObjectMoved += OnObjectMoved; _objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectRemoved; _objects.ObjectRemoved += OnObjectRemoved;
@ -238,8 +238,20 @@ internal sealed class AutoWieldController : IDisposable
: BestAvailableEquipMask(item); : BestAvailableEquipMask(item);
if (mask == EquipMask.None) if (mask == EquipMask.None)
{ {
_toast?.Invoke("That slot is already in use"); // UsingItem calls retail AutoWield with its automatic-unblock flag.
return false; // 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); return SendWield(item, mask, combatModeAfterWield: null);
@ -252,10 +264,7 @@ internal sealed class AutoWieldController : IDisposable
CombatMode? combatModeAfterWield) CombatMode? combatModeAfterWield)
{ {
if (_sendPutItemInContainer is null) if (_sendPutItemInContainer is null)
{
_toast?.Invoke("That slot is already in use");
return false; return false;
}
uint player = _playerGuid(); uint player = _playerGuid();
if (player == 0) if (player == 0)
@ -272,8 +281,17 @@ internal sealed class AutoWieldController : IDisposable
// is the transaction boundary and preserves its stance-specific motion. // is the transaction boundary and preserves its stance-specific motion.
_systemMessage?.Invoke( _systemMessage?.Invoke(
$"Moving {blockingItem.GetAppropriateName()} to your backpack"); $"Moving {blockingItem.GetAppropriateName()} to your backpack");
bool dispatched = DispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
blockingItem.ObjectId,
() =>
{
_sendPutItemInContainer(blockingItem.ObjectId, player, 0); _sendPutItemInContainer(blockingItem.ObjectId, player, 0);
return true; return true;
});
if (!dispatched)
_pendingSwitch = null;
return dispatched;
} }
private bool SendWield( private bool SendWield(
@ -288,17 +306,26 @@ internal sealed class AutoWieldController : IDisposable
BlockingItemId: 0, BlockingItemId: 0,
RequestedMask: mask, RequestedMask: mask,
CombatModeAfterWield: combatModeAfterWield); CombatModeAfterWield: combatModeAfterWield);
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
{
_pendingSwitch = null;
return false;
}
// Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590. // Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590.
bool dispatched = DispatchInventoryRequest(
InventoryRequestKind.Wield,
item.ObjectId,
() =>
{
_sendWield(item.ObjectId, (uint)mask); _sendWield(item.ObjectId, (uint)mask);
return true; return true;
});
if (!dispatched)
_pendingSwitch = null;
return dispatched;
} }
private bool DispatchInventoryRequest(
InventoryRequestKind kind,
uint itemId,
Func<bool> dispatch)
=> _transactions?.TryDispatch(kind, itemId, dispatch) ?? dispatch();
private void OnObjectMoved(ClientObjectMove move) private void OnObjectMoved(ClientObjectMove move)
{ {
if (_pendingSwitch is not { } pending if (_pendingSwitch is not { } pending
@ -454,6 +481,14 @@ internal sealed class AutoWieldController : IDisposable
return EquipMask.None; 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( private bool AutoWearIsLegal(
ClientObject item, ClientObject item,
out ClientObject? blocker) out ClientObject? blocker)

View file

@ -43,6 +43,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<uint, uint>? _sendSplitToWorld; private readonly Action<uint, uint>? _sendSplitToWorld;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer; private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
private readonly Action<uint, uint, uint>? _sendStackableMerge;
private readonly Action<uint, uint, uint>? _sendGive; private readonly Action<uint, uint, uint>? _sendGive;
private readonly Action<string>? _toast; private readonly Action<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest; private readonly Func<bool> _readyForInventoryRequest;
@ -118,7 +119,8 @@ public sealed class ItemInteractionController : IDisposable
Func<uint, uint, int, uint, bool>? sendBuy = null, Func<uint, uint, int, uint, bool>? sendBuy = null,
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null, Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null, Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null,
Action<string, RetailLogTextType>? interfaceText = null) Action<string, RetailLogTextType>? interfaceText = null,
Action<uint, uint, uint>? sendStackableMerge = null)
{ {
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -130,6 +132,7 @@ public sealed class ItemInteractionController : IDisposable
_sendSplitToWorld = sendSplitToWorld; _sendSplitToWorld = sendSplitToWorld;
_sendPutItemInContainer = sendPutItemInContainer; _sendPutItemInContainer = sendPutItemInContainer;
_sendSplitToContainer = sendSplitToContainer; _sendSplitToContainer = sendSplitToContainer;
_sendStackableMerge = sendStackableMerge;
_sendGive = sendGive; _sendGive = sendGive;
_nowMs = nowMs ?? (() => Environment.TickCount64); _nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast; _toast = toast;
@ -168,10 +171,10 @@ public sealed class ItemInteractionController : IDisposable
_playerGuid, _playerGuid,
_sendWield, _sendWield,
sendPutItemInContainer, sendPutItemInContainer,
_toast,
_systemMessage, _systemMessage,
combatState, combatState,
sendChangeCombatMode); sendChangeCombatMode,
_transactions);
_interactionState.Changed += OnInteractionModeChanged; _interactionState.Changed += OnInteractionModeChanged;
_transactions.StateChanged += OnTransactionStateChanged; _transactions.StateChanged += OnTransactionStateChanged;
_transactions.RequestCompleted += OnInventoryRequestCompleted; _transactions.RequestCompleted += OnInventoryRequestCompleted;
@ -182,6 +185,12 @@ public sealed class ItemInteractionController : IDisposable
public event Action? StateChanged; public event Action? StateChanged;
/// <summary>
/// Retail <c>ItemHolder::AttemptMerge</c> immediately selects the target
/// stack and publishes the toolbar merge-attempt notice after dispatch.
/// </summary>
public event Action<uint, uint>? MergeAttempted;
/// <summary> /// <summary>
/// Retail's two secure-trade open paths surface here for the trade UI: /// Retail's two secure-trade open paths surface here for the trade UI:
/// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player /// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player
@ -457,6 +466,40 @@ public sealed class ItemInteractionController : IDisposable
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending) public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
=> _transactions.TryGetPending(out pending); => _transactions.TryGetPending(out pending);
/// <summary>
/// Retail <c>ACCWeenieObject::UIAttemptSplitToContainer</c>: 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.
/// </summary>
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;
});
}
/// <summary> /// <summary>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a /// Increments retail's shared <c>ClientUISystem</c> busy reference after a
/// request issued by another retained controller has been sent. The /// request issued by another retained controller has been sent. The
@ -486,6 +529,29 @@ public sealed class ItemInteractionController : IDisposable
public bool IsPendingSource(uint itemGuid) public bool IsPendingSource(uint itemGuid)
=> itemGuid != 0 && itemGuid == PendingSourceItem; => itemGuid != 0 && itemGuid == PendingSourceItem;
/// <summary>
/// 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.
/// </summary>
public bool IsPendingInventorySource(uint itemGuid)
=> itemGuid != 0
&& _transactions.TryGetPending(out PendingInventoryRequest pending)
&& pending.ItemId == itemGuid;
/// <summary>Route a literal local refusal to retail's SpewBox channel.</summary>
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);
}
/// <summary> /// <summary>
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with /// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
/// toolbar shortcut creation. /// toolbar shortcut creation.
@ -689,15 +755,46 @@ public sealed class ItemInteractionController : IDisposable
/// publishes the waiting destination slot before issuing the move request, /// publishes the waiting destination slot before issuing the move request,
/// exactly like double-click pickup through ItemHolder. /// exactly like double-click pickup through ItemHolder.
/// </summary> /// </summary>
public bool PlaceWorldItemInBackpack(uint itemGuid) public bool PlaceWorldItemInBackpack(uint itemGuid, bool mainPack = false)
{ {
if (itemGuid == 0u || _placeInBackpack is null) if (itemGuid == 0u || _placeInBackpack is null)
return false; return false;
uint containerId = _backpackContainerId(); uint containerId = mainPack ? _playerGuid() : _backpackContainerId();
if (containerId == 0u) if (containerId == 0u)
containerId = _playerGuid(); containerId = _playerGuid();
const int placement = 0; 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( if (!TryBeginPendingBackpackPlacement(
itemGuid, itemGuid,
containerId, containerId,
@ -716,6 +813,67 @@ public sealed class ItemInteractionController : IDisposable
return true; 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<uint>();
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<uint> ExhaustiveContents(
uint containerId,
HashSet<uint> 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( public bool TryBeginPendingBackpackPlacement(
uint itemGuid, uint itemGuid,
uint containerId, uint containerId,
@ -1043,6 +1201,14 @@ public sealed class ItemInteractionController : IDisposable
public bool DropToWorld(ItemDragPayload payload) public bool DropToWorld(ItemDragPayload payload)
=> PlaceIn3D(payload, targetGuid: 0u); => PlaceIn3D(payload, targetGuid: 0u);
/// <summary>
/// Keyboard equivalent of dropping the selected inventory item into the
/// 3-D view. Retail routes Give Selected and Drop Selected through the
/// same <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c> policy as a drag.
/// </summary>
public bool PlaceSelectedIn3D(uint itemGuid, uint targetGuid)
=> PlaceIn3D(itemGuid, ItemDragSource.Inventory, targetGuid);
/// <summary> /// <summary>
/// Retail inventory drag released into SmartBox. The release target is the /// 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 /// 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); 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; return false;
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item) if (itemGuid == 0 || _objects.Get(itemGuid) is not { } item)
return false; return false;
if (!EnsureInventoryRequestReady()) if (!EnsureInventoryRequestReady())
return false; return false;
@ -1154,7 +1328,7 @@ public sealed class ItemInteractionController : IDisposable
break; break;
case ItemPolicyActionKind.Reject: case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message)) if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message); ReportClientLocal(action.Message);
break; break;
case ItemPolicyActionKind.OpenSecureTrade: case ItemPolicyActionKind.OpenSecureTrade:
// Use-on-player (ItemHolder::DetermineUseResult // Use-on-player (ItemHolder::DetermineUseResult
@ -1169,8 +1343,9 @@ public sealed class ItemInteractionController : IDisposable
PolicyActionRequested?.Invoke(action); PolicyActionRequested?.Invoke(action);
bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null; bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null;
if (!handled) if (!handled)
_toast?.Invoke(PolicyActionMessage(action)); ReportClientLocal(PolicyActionMessage(action));
acted |= handled || _toast is not null; acted |= handled || _interfaceText is not null
|| _systemMessage is not null || _toast is not null;
break; break;
} }
} }
@ -1199,14 +1374,8 @@ public sealed class ItemInteractionController : IDisposable
action.ObjectId, action.ObjectId,
() => () =>
{ {
if (_sendDrop is null if (_sendDrop is null)
|| !_objects.MoveItemOptimistic(
action.ObjectId,
newContainerId: 0u,
newSlot: -1))
{
return false; return false;
}
_sendDrop(action.ObjectId); _sendDrop(action.ObjectId);
return true; return true;
}); });
@ -1290,13 +1459,13 @@ public sealed class ItemInteractionController : IDisposable
} }
case ItemPolicyActionKind.Reject: case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message)) if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message); ReportClientLocal(action.Message);
break; break;
default: default:
_auxiliaryAction?.Invoke(action); _auxiliaryAction?.Invoke(action);
PolicyActionRequested?.Invoke(action); PolicyActionRequested?.Invoke(action);
if (_auxiliaryAction is null && PolicyActionRequested is null) if (_auxiliaryAction is null && PolicyActionRequested is null)
_toast?.Invoke(PolicyActionMessage(action)); ReportClientLocal(PolicyActionMessage(action));
break; break;
} }
} }
@ -1308,7 +1477,7 @@ public sealed class ItemInteractionController : IDisposable
_interactionState.EnterUseItemOnTarget(sourceGuid); _interactionState.EnterUseItemOnTarget(sourceGuid);
var name = _objects.Get(sourceGuid)?.Name; var name = _objects.Get(sourceGuid)?.Name;
if (!string.IsNullOrWhiteSpace(name)) if (!string.IsNullOrWhiteSpace(name))
_toast?.Invoke($"Choose a target for the {name}"); ReportClientLocal($"Choose a target for the {name}");
} }
private void ClearTargetMode() private void ClearTargetMode()
@ -1387,8 +1556,6 @@ public sealed class ItemInteractionController : IDisposable
PendingInventoryRequest request, PendingInventoryRequest request,
uint weenieError) uint weenieError)
{ {
if (_interfaceText is null)
return;
ClientObject? item = request.ItemIdentity ?? _objects.Get(request.ItemId); ClientObject? item = request.ItemIdentity ?? _objects.Get(request.ItemId);
if (item is null) if (item is null)
return; return;
@ -1407,7 +1574,7 @@ public sealed class ItemInteractionController : IDisposable
if (InventoryFailureMessages.Compose(request.Kind, name, weenieError) if (InventoryFailureMessages.Compose(request.Kind, name, weenieError)
is { } text) is { } text)
{ {
_interfaceText(text, RetailLogTextType.ClientLocal); ReportClientLocal(text);
} }
} }
@ -1443,6 +1610,7 @@ public sealed class ItemInteractionController : IDisposable
_transactions.RequestCompleted -= OnInventoryRequestCompleted; _transactions.RequestCompleted -= OnInventoryRequestCompleted;
_transactions.StateChanged -= OnTransactionStateChanged; _transactions.StateChanged -= OnTransactionStateChanged;
WorldDropDispatched = null; WorldDropDispatched = null;
MergeAttempted = null;
_autoWield.Dispose(); _autoWield.Dispose();
} }
@ -1575,7 +1743,8 @@ public sealed class ItemInteractionController : IDisposable
stackSize, stackSize,
stackSize, stackSize,
IsIn3DView: item.ContainerId == 0 && item.WielderId == 0 IsIn3DView: item.ContainerId == 0 && item.WielderId == 0
&& item.ObjectId != _playerGuid()); && item.ObjectId != _playerGuid(),
Name: item.GetAppropriateName());
} }
/// <summary> /// <summary>

View file

@ -255,13 +255,23 @@ public static class CharacterStatController
// RetailScrollbarChrome (2026-08-24: the previous local constants seated // RetailScrollbarChrome (2026-08-24: the previous local constants seated
// the DOWN-arrow art on the top button). // the DOWN-arrow art on the top button).
private enum CharacterStatTab public enum CharacterStatTab
{ {
Attributes, Attributes,
Skills, Skills,
Titles, Titles,
} }
/// <summary>
/// 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.
/// </summary>
public sealed record Binding(
Action Refresh,
Action<CharacterStatTab> ShowTab,
Func<CharacterStatTab> CurrentTab);
public enum RaiseTargetKind public enum RaiseTargetKind
{ {
Attribute, Attribute,
@ -386,7 +396,7 @@ public static class CharacterStatController
/// next click. The caller invokes this from the sheet-changed /// next click. The caller invokes this from the sheet-changed
/// subscription. /// subscription.
/// </returns> /// </returns>
public static Action Bind( public static Binding Bind(
ImportedLayout layout, ImportedLayout layout,
Func<CharacterSheet> data, Func<CharacterSheet> data,
UiDatFont? datFont = null, UiDatFont? datFont = null,
@ -881,7 +891,10 @@ public static class CharacterStatController
// luminance-award quality change. // luminance-award quality change.
} }
return () => RefreshAfterRaise(null); return new Binding(
() => RefreshAfterRaise(null),
SwitchTab,
() => activeTab[0]);
} }
private static UiScrollbar? PrepareSkillScrollbar( private static UiScrollbar? PrepareSkillScrollbar(

View file

@ -100,9 +100,10 @@ internal static class ChatTranscriptRenderer
/// accumulating, so the two-threshold hysteresis has nothing to damp — it /// accumulating, so the two-threshold hysteresis has nothing to damp — it
/// exists to stop retail trimming on every single append. A single cap /// exists to stop retail trimming on every single append. A single cap
/// gives a STABLE window here; oscillating one would make the oldest /// gives a STABLE window here; oscillating one would make the oldest
/// visible line jump around as messages arrive. Cutting at whole lines is /// visible line jump around as messages arrive. Most entries are already
/// automatic for the same reason: our unit already is the line, which is /// one line; an oversized server entry with embedded newlines is clipped
/// what retail's newline preference is trying to achieve. /// at the first complete line inside the retained suffix, matching
/// retail's newline preference.
/// </para> /// </para>
/// </remarks> /// </remarks>
public const int MaxTranscriptCharacters = 0x2710; public const int MaxTranscriptCharacters = 0x2710;
@ -119,6 +120,14 @@ internal static class ChatTranscriptRenderer
IReadOnlyList<FormattedLine> detailed, IReadOnlyList<FormattedLine> detailed,
Func<uint, bool>? accept, Func<uint, bool>? accept,
int budget = MaxTranscriptCharacters) int budget = MaxTranscriptCharacters)
=> FindBudgetStart(detailed, accept, budget).LineIndex;
private readonly record struct BudgetStart(int LineIndex, int CharacterOffset);
private static BudgetStart FindBudgetStart(
IReadOnlyList<FormattedLine> detailed,
Func<uint, bool>? accept,
int budget = MaxTranscriptCharacters)
{ {
long used = 0; long used = 0;
for (int i = detailed.Count - 1; i >= 0; i--) for (int i = detailed.Count - 1; i >= 0; i--)
@ -127,11 +136,68 @@ internal static class ChatTranscriptRenderer
continue; continue;
// +1 for the newline retail stores between lines. // +1 for the newline retail stores between lines.
used += detailed[i].Text.Length + 1; long cost = detailed[i].Text.Length + 1L;
if (used > budget) 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 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 i + 1;
} }
return 0; 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<ChatTextSpan>();
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 };
} }
/// <summary> /// <summary>
@ -257,12 +323,14 @@ internal static class ChatTranscriptRenderer
// (defaultColor), matching retail's DoFontReset — not the color table's // (defaultColor), matching retail's DoFontReset — not the color table's
// unrelated index-0x00 slot. // unrelated index-0x00 slot.
Vector4 currentColor = defaultColor; Vector4 currentColor = defaultColor;
int firstLine = FirstLineWithinBudget(detailed, accept); BudgetStart start = FindBudgetStart(detailed, accept);
for (int lineIndex = firstLine; lineIndex < detailed.Count; lineIndex++) for (int lineIndex = start.LineIndex; lineIndex < detailed.Count; lineIndex++)
{ {
FormattedLine d = detailed[lineIndex]; FormattedLine d = detailed[lineIndex];
if (accept is not null && !accept(d.LogTextType)) if (accept is not null && !accept(d.LogTextType))
continue; continue;
if (lineIndex == start.LineIndex && start.CharacterOffset > 0)
d = SliceLine(d, start.CharacterOffset);
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved)) if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
currentColor = resolved; currentColor = resolved;
// Wrapping can DROP the space it broke on, so a fragment is not // Wrapping can DROP the space it broke on, so a fragment is not

View file

@ -5,6 +5,7 @@ using AcDream.App.Rendering;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Chat; using AcDream.Core.Chat;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -1029,6 +1030,44 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
FindRootOf(Input)?.SetKeyboardFocus(Input); FindRootOf(Input)?.SetKeyboardFocus(Input);
} }
/// <summary>
/// Retail <c>EnterChatMode</c>: enter write mode and select the complete
/// existing entry so the next typed character replaces it.
/// </summary>
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();
}
/// <summary>Retail <c>ToggleChatEntry</c>: toggle write-mode focus.</summary>
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);
}
/// <summary>Retail command/alias hotkey: begin an ordinary slash command.</summary>
internal void StartCommand()
{
Input.SetText("/");
FindRootOf(Input)?.SetKeyboardFocus(Input);
}
/// <summary>Retail reply keys are silent when their independent target is empty.</summary>
internal void StartReply(string? name)
{
if (!string.IsNullOrEmpty(name))
StartTell(name);
}
private static UiRoot? FindRootOf(UiElement element) private static UiRoot? FindRootOf(UiElement element)
{ {
for (UiElement? at = element; at is not null; at = at.Parent) for (UiElement? at = element; at is not null; at = at.Parent)

View file

@ -115,7 +115,7 @@ public static class DatWidgetFactory
// pre-OP2 UiDatElement fallback (media drawn, ClickThrough=true, state // pre-OP2 UiDatElement fallback (media drawn, ClickThrough=true, state
// propagation) because nothing ever activates it. // propagation) because nothing ever activates it.
5 => new UiTemplateListBox(info, resolve, info.TemplateList, info.ScrollbarElementId), 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 7 => BuildMeter(info, resolve, elementFont, stringResolve), // UIElement_Meter
// UIElement_Panel (Type 8) — retail's tab-strip host (dat property 0x2E; // 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- // 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) 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text 12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
0x13 => new UiDialogRoot(), // ConfirmationDialog 0x13 => new UiDialogRoot(), // ConfirmationDialog
0x14 => new UiDialogRoot(), // ConfirmationMenuDialog
0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog 0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog
0x17 => new UiDialogRoot(), // MessageDialog 0x17 => new UiDialogRoot(), // MessageDialog
0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396) 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 // ArrowCapClosedSprite doc comment). Built blank, exactly like the Type-6
// case above — a page controller wires its sprites/items the same way // case above — a page controller wires its sprites/items the same way
// ChatWindowController wires the channel menu. // ChatWindowController wires the channel menu.
0x10000038u => new UiMenu(), 0x10000038u => BuildMenu(info, resolve, elementFont, fontResolve),
// UIOption_CheckboxBitfield64 (Type 0x10000044): the Chat tab's per-window // UIOption_CheckboxBitfield64 (Type 0x10000044): the Chat tab's per-window
// text-filter block. OP2 rework (docs/research/2026-08-11-op2-review- // text-filter block. OP2 rework (docs/research/2026-08-11-op2-review-
// mechanism.md MUST-FIX 4): the authored template (0x10000520) DOES author // mechanism.md MUST-FIX 4): the authored template (0x10000520) DOES author
@ -209,6 +210,42 @@ public static class DatWidgetFactory
return e; return e;
} }
/// <summary>
/// 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 <see cref="UiMenu.ConsumesDatChildren"/> intentionally absorbs the
/// authored label child. Existing chat/vendor/options controllers overwrite
/// these defaults with their own probed variants.
/// </summary>
private static UiMenu BuildMenu(
ElementInfo info,
Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont,
Func<uint, UiDatFont?>? 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;
}
/// <summary> /// <summary>
/// Bind inherited scrollbar media structurally. Property 0x77 names the /// Bind inherited scrollbar media structurally. Property 0x77 names the
/// increment button and 0x78 the decrement button; retail /// increment button and 0x78 the decrement button; retail

View file

@ -43,6 +43,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
private readonly UiItemList _contentsList; private readonly UiItemList _contentsList;
private uint _openContainer; private uint _openContainer;
private PendingBackpackPlacement? _pendingPlacement;
private bool _closeRequested; private bool _closeRequested;
private bool _disposed; private bool _disposed;
@ -115,6 +116,9 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
_objects.Cleared += OnObjectsCleared; _objects.Cleared += OnObjectsCleared;
_selection.Changed += OnSelectionChanged; _selection.Changed += OnSelectionChanged;
_itemInteraction.StateChanged += OnInteractionStateChanged; _itemInteraction.StateChanged += OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested += OnPendingPlacementRequested;
_itemInteraction.PendingBackpackPlacementCancelled += OnPendingPlacementCancelled;
_itemInteraction.PendingBackpackPlacementResolved += OnPendingPlacementResolved;
ClearLists(); ClearLists();
} }
@ -210,13 +214,14 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
UiItemSlot targetCell, UiItemSlot targetCell,
ItemDragPayload payload) ItemDragPayload payload)
{ {
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return ItemDragAcceptance.None;
if (!ReferenceEquals(targetList, _contentsList) if (!ReferenceEquals(targetList, _contentsList)
|| payload.SourceKind == ItemDragSource.ShortcutBar || _openContainer == 0u)
|| payload.ObjId == 0u
|| _openContainer == 0u
|| payload.ObjId == _openContainer)
return ItemDragAcceptance.Reject; return ItemDragAcceptance.Reject;
return ItemDragAcceptance.Accept; return EvaluateDrop(payload.ObjId) == InventoryContainerPlacementRejection.None
? ItemDragAcceptance.Accept
: ItemDragAcceptance.Reject;
} }
public void HandleDropRelease( public void HandleDropRelease(
@ -224,8 +229,19 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
UiItemSlot targetCell, UiItemSlot targetCell,
ItemDragPayload payload) 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; return;
}
if (!_itemInteraction.EnsureInventoryRequestReady()) if (!_itemInteraction.EnsureInventoryRequestReady())
return; return;
if (_objects.Get(payload.ObjId) is not { } item) if (_objects.Get(payload.ObjId) is not { } item)
@ -246,18 +262,31 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
InventoryRequestKind kind = amount < fullStack InventoryRequestKind kind = amount < fullStack
? InventoryRequestKind.SplitToContainer ? InventoryRequestKind.SplitToContainer
: InventoryRequestKind.PutInContainer; : InventoryRequestKind.PutInContainer;
if (amount < fullStack)
{
_itemInteraction.TryDispatchInventoryRequest( _itemInteraction.TryDispatchInventoryRequest(
kind, kind,
item.ObjectId, item.ObjectId,
() => () =>
{ {
if (amount < fullStack)
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount); _sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
return true;
});
}
else else
{
_itemInteraction.TryDispatchPendingBackpackPlacement(
item.ObjectId,
_openContainer,
placement,
kind,
() =>
{
_sendPutItemInContainer(item.ObjectId, _openContainer, placement); _sendPutItemInContainer(item.ObjectId, _openContainer, placement);
return true; return true;
}); });
} }
}
private void OnExternalContainerChanged(ExternalContainerTransition transition) private void OnExternalContainerChanged(ExternalContainerTransition transition)
{ {
@ -314,10 +343,29 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
AddContainerCell(guid); AddContainerCell(guid);
} }
var visibleContents = new List<uint>();
foreach (uint guid in _objects.GetContents(_openContainer)) foreach (uint guid in _objects.GetContents(_openContainer))
{ {
if (!IsContainer(_objects.Get(guid))) 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(); ApplyIndicators();
} }
@ -334,15 +382,15 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
private void AddContainerCell(uint guid) private void AddContainerCell(uint guid)
{ {
UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground); UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground);
cell.Clicked = () => OpenNestedContainer(guid);
SetCapacity(cell, guid); SetCapacity(cell, guid);
_containerList.AddItem(cell); _containerList.AddItem(cell);
} }
private void AddContentsCell(uint guid) private void AddContentsCell(uint guid, bool waiting = false)
{ {
UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground); UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground);
cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid); cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid);
cell.SetWaitingState(waiting);
cell.DragAcceptSprite = 0x060011F9u; cell.DragAcceptSprite = 0x060011F9u;
cell.DragRejectSprite = 0x060011F8u; cell.DragRejectSprite = 0x060011F8u;
_contentsList.AddItem(cell); _contentsList.AddItem(cell);
@ -387,6 +435,9 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
{ {
if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive) if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive)
return true; return true;
if (IsContainer(_objects.Get(guid)) && guid != _state.CurrentContainerId)
OpenNestedContainer(guid);
else
Select(guid); Select(guid);
return false; return false;
} }
@ -406,7 +457,8 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
bool pendingSource = _itemInteraction.IsPendingSource(cell.ItemId); bool pendingSource = _itemInteraction.IsPendingSource(cell.ItemId);
cell.Selected = cell.ItemId != 0u cell.Selected = cell.ItemId != 0u
&& cell.ItemId == _selection.SelectedObjectId && cell.ItemId == _selection.SelectedObjectId
&& !pendingSource; && !pendingSource
&& !_itemInteraction.IsPendingInventorySource(cell.ItemId);
cell.IsOpenContainer = cell.ItemId != 0u && cell.ItemId == _openContainer; 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 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) private static bool IsContainer(ClientObject? item)
=> item is not null => item is not null
@ -573,6 +666,9 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
_objects.Cleared -= OnObjectsCleared; _objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged; _selection.Changed -= OnSelectionChanged;
_itemInteraction.StateChanged -= OnInteractionStateChanged; _itemInteraction.StateChanged -= OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested -= OnPendingPlacementRequested;
_itemInteraction.PendingBackpackPlacementCancelled -= OnPendingPlacementCancelled;
_itemInteraction.PendingBackpackPlacementResolved -= OnPendingPlacementResolved;
_topContainer.PrimaryItemPressed = null; _topContainer.PrimaryItemPressed = null;
_containerList.PrimaryItemPressed = null; _containerList.PrimaryItemPressed = null;
_contentsList.PrimaryItemPressed = null; _contentsList.PrimaryItemPressed = null;

View file

@ -116,6 +116,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_itemInteraction = itemInteraction; _itemInteraction = itemInteraction;
_stackSplitQuantity = stackSplitQuantity; _stackSplitQuantity = stackSplitQuantity;
_selection = selection ?? throw new ArgumentNullException(nameof(selection)); _selection = selection ?? throw new ArgumentNullException(nameof(selection));
if (_itemInteraction is not null)
_itemInteraction.MergeAttempted += OnMergeAttempted;
WindowChromeController.BindCloseButton(layout, onClose); WindowChromeController.BindCloseButton(layout, onClose);
@ -299,14 +301,13 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (containerId == EffectiveOpen() || containerId == _playerGuid()) if (containerId == EffectiveOpen() || containerId == _playerGuid())
Populate(); Populate();
} }
private void OnInteractionStateChanged() => ApplyIndicators(); private void OnInteractionStateChanged() => Populate();
private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending) private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending)
{ {
if (_pendingListPlacement is not null if (_pendingListPlacement is not null
|| pending.ItemId == 0u || pending.ItemId == 0u
|| pending.ContainerId != EffectiveOpen() || pending.ContainerId == 0u
|| _objects.Get(pending.ItemId) is not { } item || _objects.Get(pending.ItemId) is null)
|| IsBag(item))
{ {
return; 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 // Side-bag column: ALWAYS the player's bags (constant across container switches; only the
// open/selected indicators move). Equipped items never appear here. // open/selected indicators move). Equipped items never appear here.
var visibleBags = new List<uint>();
foreach (var guid in _objects.GetContents(p)) foreach (var guid in _objects.GetContents(p))
{ {
var item = _objects.Get(guid); var item = _objects.Get(guid);
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
bool isBag = IsBag(item); 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 // 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); if (!isBag) visibleContents.Add(guid);
} }
PendingListPlacement? pending = _pendingListPlacement;
if (pending is { } projection if (pending is { } projection
&& projection.ContainerId == open && projection.ContainerId == open
&& !visibleContents.Contains(projection.ItemId)
&& _objects.Get(projection.ItemId) is { } pendingItem && _objects.Get(projection.ItemId) is { } pendingItem
&& !IsBag(pendingItem)) && !IsBag(pendingItem))
{ {
visibleContents.Remove(projection.ItemId);
int index = Math.Clamp(projection.Placement, 0, visibleContents.Count); int index = Math.Clamp(projection.Placement, 0, visibleContents.Count);
visibleContents.Insert(index, projection.ItemId); visibleContents.Insert(index, projection.ItemId);
} }
foreach (uint guid in visibleContents) foreach (uint guid in visibleContents)
{ {
bool waiting = pending is { } waitingProjection bool waiting = IsWaitingSource(guid)
|| pending is { } waitingProjection
&& waitingProjection.ContainerId == open && waitingProjection.ContainerId == open
&& waitingProjection.ItemId == guid; && waitingProjection.ItemId == guid;
AddCell(_contentsGrid, guid, isContainer: false, waiting); AddCell(_contentsGrid, guid, isContainer: false, waiting);
@ -455,8 +477,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
dragIconTexture: _dragIconIds?.Invoke( dragIconTexture: _dragIconIds?.Invoke(
ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u) ?? 0u); ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u) ?? 0u);
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
main.SetWaitingState(IsWaitingSource(p));
main.Clicked = () => OpenContainer(p); main.Clicked = () => OpenContainer(p);
main.DoubleClicked = () => _itemInteraction?.ActivateItem(p);
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity) SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
_topContainer.AddItem(main); _topContainer.AddItem(main);
} }
@ -474,6 +496,21 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|| item.Type.HasFlag(ItemType.Container) || item.Type.HasFlag(ItemType.Container)
|| item.ItemsCapacity > 0; || 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(); private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
/// <summary>The owned destination retail PlaceInBackpack currently uses.</summary> /// <summary>The owned destination retail PlaceInBackpack currently uses.</summary>
@ -499,12 +536,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
cell.SetWaitingState(waiting); cell.SetWaitingState(waiting);
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list) cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
ConfigureDropFeedback(list, cell); ConfigureDropFeedback(list, cell);
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
if (isContainer) if (isContainer)
{ {
cell.Clicked = () => OpenContainer(guid); cell.Clicked = () => OpenContainer(guid);
SetCapacityBar(cell, guid); SetCapacityBar(cell, guid);
} }
else
{
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
}
list.AddItem(cell); list.AddItem(cell);
} }
@ -513,6 +553,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (_itemInteraction?.OfferPrimaryClick(guid) if (_itemInteraction?.OfferPrimaryClick(guid)
is not null and not ItemPrimaryClickResult.NotActive) is not null and not ItemPrimaryClickResult.NotActive)
return true; return true;
if (_objects.Get(guid) is { } item && IsBag(item))
OpenContainer(guid);
else
SelectItem(guid); SelectItem(guid);
return false; return false;
} }
@ -522,7 +565,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (_itemInteraction?.OfferSelfPrimaryClick() if (_itemInteraction?.OfferSelfPrimaryClick()
is not null and not ItemPrimaryClickResult.NotActive) is not null and not ItemPrimaryClickResult.NotActive)
return true; return true;
SelectItem(guid); OpenContainer(guid);
return false; return false;
} }
@ -556,11 +599,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{ {
int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0; int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0;
if (cap <= 0) { cell.CapacityFill = -1f; return; } 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); 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 ────────────────────
/// <summary>Retail ItemList_BeginDrag selects an unselected item before enabling its waiting /// <summary>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 /// mesh. Inventory items do not lift-remove (unlike the toolbar): the item stays in its slot
/// until the server confirms the eventual drop.</summary> /// until the server confirms the eventual drop.</summary>
@ -583,35 +630,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// remove-on-lift stands. // remove-on-lift stands.
if (payload.SourceKind == ItemDragSource.ShortcutBar) if (payload.SourceKind == ItemDragSource.ShortcutBar)
return ItemDragAcceptance.None; return ItemDragAcceptance.None;
if (payload.ObjId == 0) return EvaluateDrop(targetList, targetCell, payload.ObjId, out _, out _)
return ItemDragAcceptance.Reject; == InventoryContainerPlacementRejection.None
bool sourceIsBag = _objects.Get(payload.ObjId) is { } source && IsBag(source); ? ItemDragAcceptance.Accept
if (targetList == _contentsGrid) : ItemDragAcceptance.Reject;
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;
} }
/// <summary>Resolve the destination and either split or move the stack. A partial split waits /// <summary>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:
/// <c>ItemHolder::AttemptToPlaceInContainer @ 0x00588140</c>.</summary> /// <c>ItemHolder::AttemptToPlaceInContainer @ 0x00588140</c>.</summary>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) 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; // DropReleased is still delivered to the list after a reject overlay;
// pin the release to the same retail policy instead of relying on the // pin the release to the same retail policy instead of relying on the
// advisory color alone. // 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; return;
}
// UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every // UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every
// release while m_pendingItem exists, before merge, split, or ordinary // release while m_pendingItem exists, before merge, split, or ordinary
@ -662,7 +705,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
container = EffectiveOpen(); container = EffectiveOpen();
placement = targetCell.ItemId != 0 placement = targetCell.ItemId != 0
? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot ? 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) else if (targetList == _containerList || targetList == _topContainer)
{ {
@ -697,6 +740,16 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{ {
// UIAttemptSplitToContainer leaves the source stack where it is. ACE will // UIAttemptSplitToContainer leaves the source stack where it is. ACE will
// publish the reduced source plus a newly-guided destination stack. // publish the reduced source plus a newly-guided destination stack.
if (_itemInteraction is not null)
{
_itemInteraction.TrySplitToContainer(
item,
container,
(uint)placement,
splitSize);
}
else
{
DispatchInventoryRequest( DispatchInventoryRequest(
InventoryRequestKind.SplitToContainer, InventoryRequestKind.SplitToContainer,
item, item,
@ -711,24 +764,24 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
splitSize); splitSize);
return true; return true;
}); });
}
return; return;
} }
} }
// External-container contents retain canonical ownership while the request // Canonical ownership never changes on request. Retail immediately
// is in flight, but retail immediately inserts an m_pendingItem copy into // publishes the destination ItemList's m_pendingItem projection and
// the chosen destination slot and ghosts it. The server move/failure notice // resolves it from the server move/failure response.
// resolves that visual projection. UIElement_ItemList::HandleDropRelease
// @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680.
if (payload.SourceKind == ItemDragSource.Ground)
{
if (_itemInteraction is not null) if (_itemInteraction is not null)
{ {
InventoryRequestKind kind = payload.SourceKind == ItemDragSource.Ground
? InventoryRequestKind.Pickup
: InventoryRequestKind.PutInContainer;
if (!_itemInteraction.TryDispatchPendingBackpackPlacement( if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
item, item,
container, container,
placement, placement,
InventoryRequestKind.Pickup, kind,
() => () =>
{ {
if (_sendPutItemInContainer is null) if (_sendPutItemInContainer is null)
@ -741,35 +794,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
} }
return; return;
} }
else
{
if (_pendingListPlacement is not null) if (_pendingListPlacement is not null)
return; return;
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement); _pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
Populate(); Populate();
}
}
else
{
if (_itemInteraction is not null)
{
DispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
item,
() =>
{
if (_sendPutItemInContainer is null
|| !_objects.MoveItemOptimistic(item, container, placement))
{
return false;
}
_sendPutItemInContainer(item, container, placement);
return true;
});
return;
}
_objects.MoveItemOptimistic(item, container, placement);
}
_sendPutItemInContainer?.Invoke(item, container, placement); _sendPutItemInContainer?.Invoke(item, container, placement);
} }
@ -832,9 +861,57 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{ {
int cap = _objects.Get(container)?.ItemsCapacity ?? 0; int cap = _objects.Get(container)?.ItemsCapacity ?? 0;
if (cap <= 0) return false; 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;
/// <summary>Select an item (panel-wide green square) without changing the open container or /// <summary>Select an item (panel-wide green square) without changing the open container or
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary> /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary>
private void SelectItem(uint guid) private void SelectItem(uint guid)
@ -895,7 +972,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{ {
var cell = list.GetItem(i); var cell = list.GetItem(i);
if (cell is null) continue; 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.Selected = cell.ItemId != 0
&& cell.ItemId == _selection.SelectedObjectId && cell.ItemId == _selection.SelectedObjectId
&& !pendingTargetSource; && !pendingTargetSource;
@ -1012,6 +1090,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
} }
if (_itemInteraction is not null) if (_itemInteraction is not null)
{ {
_itemInteraction.MergeAttempted -= OnMergeAttempted;
_itemInteraction.StateChanged -= OnInteractionStateChanged; _itemInteraction.StateChanged -= OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested; _itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested;
_itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled; _itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled;

View file

@ -180,6 +180,9 @@ public sealed class JournalPanelController : IRetainedPanelController
/// <summary>Switches to the notes tab — what opening a page from the index does.</summary> /// <summary>Switches to the notes tab — what opening a page from the index does.</summary>
public void ShowNotes() => _tabPanel.SwitchTo(NotesPageId); public void ShowNotes() => _tabPanel.SwitchTo(NotesPageId);
/// <summary>Switches to the authored journal index tab.</summary>
public void ShowPageList() => _tabPanel.SwitchTo(PageListPageId);
/// <summary> /// <summary>
/// Completes construction. The index needs a callback that switches tabs, /// Completes construction. The index needs a callback that switches tabs,
/// which needs the panel — so it is attached rather than constructed. /// which needs the panel — so it is attached rather than constructed.

View file

@ -66,19 +66,17 @@ namespace AcDream.App.UI.Layout;
/// ///
/// <para> /// <para>
/// <b>Row identity and binding storage (D4).</b> Every row's identity is the DAT /// <b>Row identity and binding storage (D4).</b> Every row's identity is the DAT
/// pair <c>(InputMapId, ActionId)</c> — retail's own row key. Where /// pair <c>(InputMapId, ActionId)</c> — retail's own row key. The installed EoR
/// <see cref="RetailActionIdentityTable"/> resolves that pair to an acdream /// ActionMap's 306 pairs each resolve to one distinct acdream
/// <see cref="InputAction"/> (research: roughly half of the DAT's 306 rows — see /// <see cref="InputAction"/>; the row's bindings ARE
/// that table's class doc for the full accounting), the row's bindings ARE
/// <see cref="KeyBindings"/>'s bindings for that action: a rebind here takes /// <see cref="KeyBindings"/>'s bindings for that action: a rebind here takes
/// effect immediately for live gameplay dispatch through the SAME /// effect immediately for live gameplay dispatch through the SAME
/// <see cref="InputDispatcher"/> every other input path uses, and persists to /// <see cref="InputDispatcher"/> every other input path uses. Retail Load File /
/// <c>keybinds.json</c> exactly like any other rebind (D4 — no separate /// Save As exchange the original PFile <c>*.keymap</c> format; acdream also writes
/// <c>.keymap</c> file format). Where no <see cref="InputAction"/> exists yet /// <c>keybinds.json</c> as its portable mirror for host-only commands. The
/// (mostly Emotes and CharacterSettings — see the identity table's class doc), /// nullable/unmapped delegates remain solely
/// the row is still fully rendered, bindable, conflict-checked, and persisted /// so an unknown future-DAT row stays visible and round-trippable instead of
/// (<see cref="Bindings.CurrentForUnmapped"/>/<see cref="Bindings.SetForUnmapped"/>), /// crashing an older client.
/// it just has no live gameplay consumer yet (register row).
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -90,8 +88,8 @@ namespace AcDream.App.UI.Layout;
/// against every multi-chord action in <c>KeyBindings.RetailDefaults()</c>: /// against every multi-chord action in <c>KeyBindings.RetailDefaults()</c>:
/// walk-mode's Hold, the three melee/missile/magic combat scopes, ...), so this /// 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 /// row captures that pair ONCE at build time (from the first live binding, or
/// <see cref="ActivationType.Press"/>/<see cref="InputScope.Game"/> if the action /// the retail identity table if the action starts wholly unbound) and reapplies
/// starts wholly unbound) and reapplies it to every chord this row ever writes — /// it to every chord this row ever writes —
/// on a live rebind, on Cancel/Revert (<c>RestoreSavedValue</c>), and on Defaults /// on a live rebind, on Cancel/Revert (<c>RestoreSavedValue</c>), and on Defaults
/// (<c>RestoreDefaultValue</c>, which restores DAT-sourced KEYS only; Activation/ /// (<c>RestoreDefaultValue</c>, which restores DAT-sourced KEYS only; Activation/
/// Scope are retail-side properties of the ACTION, not of which physical key /// 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 /// 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 /// analogue is a chord already bound to an acdream-only action with no
/// <see cref="RetailActionIdentityTable"/> row at all (Ctrl+M mute, the debug /// <see cref="RetailActionIdentityTable"/> row at all (Ctrl+M mute, the debug
/// F-keys, ...) — refused via <see cref="Bindings.NonBindableRefusalText"/> /// F-keys, ...) — refused through retail's type-3
/// exactly like retail's distinct <c>OpenCantOverwriteBindingDialog</c>, with no /// <c>OpenCantOverwriteBindingDialog</c> with the exact DAT template. A genuine
/// dialog (a hard stop, matching the DAT-verified refusal string). A genuine
/// cross-row conflict collects EVERY conflicting row (not just the first) and /// cross-row conflict collects EVERY conflicting row (not just the first) and
/// opens a real confirm dialog through <see cref="Bindings.ConfirmOverwrite"/> — /// opens a real confirm dialog through <see cref="Bindings.ConfirmOverwrite"/> —
/// retail's <c>OpenOverwriteBindingDialog(&amp;conflicts)</c> — BEFORE reassigning; /// retail's <c>OpenOverwriteBindingDialog(&amp;conflicts)</c> — BEFORE reassigning;
@ -123,15 +120,10 @@ namespace AcDream.App.UI.Layout;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Caption dimming (AD-78, user-directed, 2026-08-11, gate 2).</b> A row /// Campaign KB maps all 306 installed EoR rows to distinct live actions, so
/// whose <see cref="RowView.MappedAction"/> is null (AP-203's store-only /// every authored command is enabled and uses the normal caption color. The
/// set — mostly Emotes and CharacterSettings, plus every non-user-bindable /// nullable defensive path remains only to make an unknown future DAT row
/// InputMap this screen renders) dims its synthesized caption via /// visible without crashing an older client.
/// <see cref="UiRenderContext.StoreOnlyCaptionColor"/> in
/// <see cref="BuildActionRow"/>. 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.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class KeyboardConfigController public sealed class KeyboardConfigController
@ -204,8 +196,14 @@ public sealed class KeyboardConfigController
Action<Action<KeyChord?>> BeginCapture, Action<Action<KeyChord?>> BeginCapture,
Action Save, Action Save,
Action Toggle, Action Toggle,
Action<string> DisplaySystemMessage, // Resolves one of retail's ID_ActionKeyMap_* templates from string-table
string NonBindableRefusalText, // 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, IReadOnlyDictionary<uint, string>, string?> ResolveTemplate,
// Retail OpenCantOverwriteBindingDialog is a type-3 priority message on
// keyboard queue 0x10000001, not a scrolling-chat/system message.
Action<string> ShowMessage,
// M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — confirm // M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — confirm
// BEFORE reassigning a chord already bound to another row on this screen. // BEFORE reassigning a chord already bound to another row on this screen.
// message is pre-composed (real row labels, no invented retail text); // 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 // or ESC). Null keeps the pre-dialog capture behavior for hosts with
// no dialog factory (unit fixtures). // no dialog factory (unit fixtures).
Func<string, uint>? OpenCaptureInstructions = null, Func<string, uint>? OpenCaptureInstructions = null,
Action<uint>? CloseCaptureInstructions = null); Action<uint>? CloseCaptureInstructions = null,
// Retail gmKeyboardUI's Load File / Save As workflows. Each opener
// invokes its callback only after a successful profile operation.
Func<string>? CurrentKeymapFilename = null,
Action<Action>? OpenLoadKeymap = null,
Action<Action>? OpenSaveKeymap = null);
public OptionPage Page { get; } = new(); public OptionPage Page { get; } = new();
public IReadOnlyList<RowView> Rows => _rows; public IReadOnlyList<RowView> Rows => _rows;
private readonly List<RowView> _rows = new(); private readonly List<RowView> _rows = new();
private readonly Dictionary<(uint LayoutId, uint ElementId), UiDatFont?> _templateFontCache = new(); private readonly Dictionary<(uint LayoutId, uint ElementId), UiDatFont?> _templateFontCache = new();
private RetailActionMapSnapshot? _snapshot;
private Bindings? _bindings; private Bindings? _bindings;
private Func<KeyChord, string> _describe = DescribeChord; private Func<KeyChord, string> _describe = DescribeChord;
private Func<uint, uint, UiDatFont?>? _resolveTemplateFont; private Func<uint, uint, UiDatFont?>? _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() { } private KeyboardConfigController() { }
/// <summary> /// <summary>
/// Builds every header + row across all six pages from /// Builds every header + row across all six pages from
/// <paramref name="snapshot"/>, wires each row's key buttons to modal /// <paramref name="snapshot"/>, wires each row's key buttons to modal
/// capture / right-click erase, and wires the screen's own six buttons /// capture / right-click erase, and wires the screen's own six buttons
/// (Defaults/Revert/OK/Cancel; Load/Save File are INERT — D4, no /// (Load File/Save As/Defaults/Revert/OK/Cancel). Returns null if the layout's window root
/// <c>.keymap</c> interchange). Returns null if the layout's window root
/// did not import (a missing/malformed LayoutDesc). /// did not import (a missing/malformed LayoutDesc).
/// </summary> /// </summary>
public static KeyboardConfigController? Bind( public static KeyboardConfigController? Bind(
@ -266,6 +275,7 @@ public sealed class KeyboardConfigController
var controller = new KeyboardConfigController var controller = new KeyboardConfigController
{ {
_snapshot = snapshot,
_bindings = bindings, _bindings = bindings,
_resolveTemplateFont = resolveTemplateFont, _resolveTemplateFont = resolveTemplateFont,
// OP8 re-gate (2026-08-14): key-button captions through retail's // 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 // The row's own caption — synthesized, composed beside the authored key
// buttons (UiText is sealed; see class doc). Occupies the "Command" column // buttons (UiText is sealed; see class doc). Occupies the "Command" column
// (x=0..270, matching the authored column headers). AD-78 (user-directed, // (x=0..270, matching the authored column headers). All 306 EoR rows
// 2026-08-11, gate 2): an unmapped row (MappedAction null — no live // are mapped; the dim color is only a forward-compatible signal for
// InputDispatcher consumer, AP-203) dims its caption; the key buttons // a row introduced by a different DAT revision.
// themselves stay fully interactive (bindable/persisted/conflict-checked,
// see class doc).
var captionText = new UiText var captionText = new UiText
{ {
Left = 0f, Left = 0f,
@ -423,39 +431,41 @@ public sealed class KeyboardConfigController
}; };
if (label is not null) if (label is not null)
captionText.LinesProvider = () => new[] { new UiText.Line(label, captionText.DefaultColor) }; 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); built.AddChild(captionText);
// M1: capture this row's live Activation/Scope ONCE, from the first // M1: capture this row's live Activation/Scope ONCE, from the first
// existing binding for the action (every multi-chord action in // existing binding for the action (every multi-chord action in
// KeyBindings.RetailDefaults() shares one Activation/Scope pair across // KeyBindings.RetailDefaults() shares one Activation/Scope pair across
// all its bindings — see class doc). Falls back to the Binding record's // 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<Binding> liveBindings = mapped IReadOnlyList<Binding> liveBindings = mapped
? bindings.CurrentForAction(action) ? bindings.CurrentForAction(action)
: Array.Empty<Binding>(); : Array.Empty<Binding>();
(ActivationType Activation, InputScope Scope) template = liveBindings.Count > 0 (ActivationType Activation, InputScope Scope) template = liveBindings.Count > 0
? (liveBindings[0].Activation, liveBindings[0].Scope) ? (liveBindings[0].Activation, liveBindings[0].Scope)
: (ActivationType.Press, InputScope.Game); : (
RetailActionIdentityTable.ActivationFor(row.InputMapId, row.ActionId),
RetailActionIdentityTable.ScopeForInputMap(row.InputMapId));
IReadOnlyList<KeyChord> defaults = DatDefaultsToChords(row.DefaultBindings); IReadOnlyList<KeyChord> defaults = DatDefaultsToChords(row.DefaultBindings);
IReadOnlyList<KeyChord> storedUnmapped = mapped IReadOnlyList<KeyChord> storedUnmapped = mapped
? Array.Empty<KeyChord>() ? Array.Empty<KeyChord>()
: bindings.CurrentForUnmapped((row.InputMapId, row.ActionId)); : bindings.CurrentForUnmapped((row.InputMapId, row.ActionId));
// OP8 re-review round 2 (SHOULD-FIX): an unmapped/store-only row with // An unknown future-DAT row with no persisted chords displays its DAT
// no persisted chords displays its DAT DEFAULTS — retail shows the // defaults. Installed EoR rows always take the mapped branch.
// authored bindings (the Camera Alternate rows' arrow keys) and a
// blank row misreads as "unbound". Display-only: nothing here feeds
// the InputDispatcher, and the store only gains the defaults if the
// user actually edits the row (the apply closure below).
IReadOnlyList<KeyChord> initial = mapped IReadOnlyList<KeyChord> initial = mapped
? liveBindings.Select(b => b.Chord).ToArray() ? liveBindings.Select(b => b.Chord).ToArray()
: storedUnmapped.Count > 0 ? storedUnmapped : defaults; : storedUnmapped.Count > 0 ? storedUnmapped : defaults;
var model = new ActionKeyMapOptionRow(initial, defaults, apply: value => var model = new ActionKeyMapOptionRow(initial, defaults, apply: value =>
{ {
// Interior/padding default(KeyChord) entries (S4 — sparse-slot // A legacy compatibility store can still contain padding
// display, see ReplaceSlotValue) are never real bindings; filter // default(KeyChord) entries even though the retail production
// them out at the write boundary, not at storage time. // editor is dense; never publish those sentinels as bindings.
IReadOnlyList<KeyChord> real = value.Where(c => c != default).ToArray(); IReadOnlyList<KeyChord> real = value.Where(c => c != default).ToArray();
if (mapped) if (mapped)
bindings.SetForAction( bindings.SetForAction(
@ -474,7 +484,6 @@ public sealed class KeyboardConfigController
for (int slot = 0; slot < keyButtons.Count; slot++) for (int slot = 0; slot < keyButtons.Count; slot++)
{ {
int capturedSlot = slot; int capturedSlot = slot;
keyButtons[slot].TooltipText = tooltip;
keyButtons[slot].OnClick = () => BeginSlotCapture(view, capturedSlot, bindings); keyButtons[slot].OnClick = () => BeginSlotCapture(view, capturedSlot, bindings);
keyButtons[slot].OnRightClick = () => EraseSlot(view, capturedSlot); keyButtons[slot].OnRightClick = () => EraseSlot(view, capturedSlot);
} }
@ -516,10 +525,36 @@ public sealed class KeyboardConfigController
for (int i = 0; i < view.KeyButtons.Count; i++) for (int i = 0; i < view.KeyButtons.Count; i++)
{ {
bool bound = i < current.Count && current[i] != default; 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<uint, string> { [LabelVariable] = keyName });
view.KeyButtons[i].Label = buttonLabel;
view.KeyButtons[i].TooltipText = buttonLabel is null
? null
: ResolveTemplate(
"ID_ActionKeyMap_TT_ExistingBinding",
new Dictionary<uint, string> { [ValueVariable] = buttonLabel });
} }
} }
private static readonly IReadOnlyDictionary<uint, string> EmptyTemplateVariables =
new Dictionary<uint, string>();
private string? ResolveTemplate(
string key,
IReadOnlyDictionary<uint, string> variables) =>
_bindings?.ResolveTemplate(key, variables);
/// <summary>Raw enum spelling — construction-time default until Bind swaps /// <summary>Raw enum spelling — construction-time default until Bind swaps
/// in <see cref="RetailKeyNames.Describe"/>, and that class's own fallback /// in <see cref="RetailKeyNames.Describe"/>, and that class's own fallback
/// for controls outside the DIK table.</summary> /// for controls outside the DIK table.</summary>
@ -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) if (instructionsContext != 0u)
bindings.CloseCaptureInstructions?.Invoke(instructionsContext); bindings.CloseCaptureInstructions?.Invoke(instructionsContext);
if (captured is not { } chord) return; // Escape — retail cancels silently. 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<RowView> conflictRows) = FindConflicts(chord, exclude: view); (ConflictOutcome outcome, List<RowView> conflictRows) = FindConflicts(chord, exclude: view);
switch (outcome) switch (outcome)
{ {
@ -564,18 +620,23 @@ public sealed class KeyboardConfigController
// conflicting target is non-user-bindable. This port's // conflicting target is non-user-bindable. This port's
// analogue: a chord already bound to an acdream-only action // analogue: a chord already bound to an acdream-only action
// with no DAT row at all (Ctrl+M mute, the debug F-keys, ...) — // with no DAT row at all (Ctrl+M mute, the debug F-keys, ...) —
// OpenCantOverwriteBindingDialog's ported refusal, no dialog. // OpenCantOverwriteBindingDialog @ 0x00489300: exact
bindings.DisplaySystemMessage(bindings.NonBindableRefusalText); // ID_ActionKeyMap_NonUserBindableBinding(KEY) text in a
// type-3 priority message dialog on queue 0x10000001.
string? refusal = bindings.ResolveTemplate(
"ID_ActionKeyMap_NonUserBindableBinding",
new Dictionary<uint, string> { [KeyVariable] = _describe(chord) });
if (refusal is not null)
bindings.ShowMessage(refusal);
return; return;
case ConflictOutcome.Rows: case ConflictOutcome.Rows:
// M3: retail's OpenOverwriteBindingDialog — confirm BEFORE // M3: retail's OpenOverwriteBindingDialog — confirm BEFORE
// reassigning (N-way: every conflicting row is named, not just // reassigning (N-way: every conflicting row is named, not just
// the first). Only on accept do the losing rows lose the slot. // the first). Only on accept do the losing rows lose the slot.
string names = string.Join(", ", conflictRows.Select(r => r.Label ?? "?")); string? message = ComposeOverwriteMessage(chord, conflictRows, bindings);
string message = if (message is null)
$"'{_describe(chord)}' is already bound to {names}. " return;
+ $"Reassign it to '{view.Label}'?";
bindings.ConfirmOverwrite(message, accepted => bindings.ConfirmOverwrite(message, accepted =>
{ {
if (!accepted) return; if (!accepted) return;
@ -593,13 +654,76 @@ public sealed class KeyboardConfigController
return; 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<RowView> 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<uint, string>
{
[KeyVariable] = keyName,
[ActionVariable] = action,
});
}
var lines = new List<string>(conflicts.Count);
foreach (RowView conflict in conflicts)
{
if (conflict.Label is null) return null;
string? line = bindings.ResolveTemplate(
"ID_ActionKeyMap_Binding",
new Dictionary<uint, string>
{
[ActionVariable] = conflict.Label,
[KeyVariable] = keyName,
});
if (line is null) return null;
lines.Add(line);
}
return bindings.ResolveTemplate(
"ID_ActionKeyMap_OverwriteExistingBindings",
new Dictionary<uint, string>
{
[KeyVariable] = keyName,
[BindingsVariable] = string.Join("\n", lines),
});
} }
private void ApplySlot(RowView view, int slot, KeyChord chord) private void ApplySlot(RowView view, int slot, KeyChord chord)
{ {
List<KeyChord> updated = new(view.Model.Current); List<KeyChord> updated = new(view.Model.Current);
while (updated.Count <= slot) updated.Add(default); // SetBinding @ 0x00487B32..0x00487B47 clamps a requested slot past
updated[slot] = chord; // 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); ReplaceSlotValue(view, updated);
RefreshRowButtons(view); RefreshRowButtons(view);
} }
@ -616,13 +740,9 @@ public sealed class KeyboardConfigController
private static void ReplaceSlotValue(RowView view, IReadOnlyList<KeyChord> value) private static void ReplaceSlotValue(RowView view, IReadOnlyList<KeyChord> value)
{ {
// S4 (2026-08-11 review): only trim TRAILING empty slots. Retail's // The production path is dense (ApplySlot clamps to Count and erase
// SetBinding(qc, slot) writes the SPECIFIC slot the user clicked — a row // removes an element). Keep the trailing-default trim as a defensive
// with no bindings whose "Mapping 3" button is set must keep the chord at // boundary for compatibility stores created by older schema versions.
// display index 2, not collapse it onto index 0. Interior default(KeyChord)
// entries only ever come from ApplySlot's own padding, so trimming just the
// tail keeps RefreshRowButtons' positional read correct without inventing a
// nullable-chord storage type.
int lastReal = -1; int lastReal = -1;
for (int i = 0; i < value.Count; i++) for (int i = 0; i < value.Count; i++)
if (value[i] != default) lastReal = i; if (value[i] != default) lastReal = i;
@ -639,9 +759,8 @@ public sealed class KeyboardConfigController
/// <c>ICIDM::FindConflictingInputMaps</c>/<c>FindConflictingControls</c>), /// <c>ICIDM::FindConflictingInputMaps</c>/<c>FindConflictingControls</c>),
/// scoped to this screen's own universe: the non-user-bindable check runs /// 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 /// 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 /// (all 306 installed EoR rows are mapped) is collected in full, not just
/// unmapped row is just as real a conflict as one claimed by a mapped one) is /// the first match.
/// collected in full, not just the first match.
/// </summary> /// </summary>
private (ConflictOutcome Outcome, List<RowView> Rows) FindConflicts(KeyChord chord, RowView exclude) private (ConflictOutcome Outcome, List<RowView> Rows) FindConflicts(KeyChord chord, RowView exclude)
{ {
@ -659,15 +778,20 @@ public sealed class KeyboardConfigController
foreach (RowView other in _rows) foreach (RowView other in _rows)
{ {
if (ReferenceEquals(other, exclude)) continue; if (ReferenceEquals(other, exclude)) continue;
// OP8 re-review round 2 R1: store-only rows (MappedAction null — // A future unknown-DAT row never reaches the dispatcher, so its
// the Camera Alternate scheme, Emote/CharacterSettings hotkeys) // display-only chord cannot create a live conflict. #373: mapped
// never reach the InputDispatcher, so a chord they display cannot // cross-context sharing consults the
// actually collide with anything; counting them made the ten // installed DAT's ActionMap.ConflictingMaps table. In particular,
// arrow-key defaults trip a false N-way confirm on any arrow // the melee/missile/magic contexts legitimately share the retail
// rebind. Retail-mapped cross-context sharing (ConflictingMaps — // Insert/Delete/End/PageUp/PageDown cluster and must not erase one
// the Insert/Delete/End/PageUp/PageDown combat cluster) remains // another.
// deferred as ISSUES #373; only INERT rows are excluded here.
if (other.MappedAction is null) continue; if (other.MappedAction is null) continue;
if (_snapshot?.InputMapsConflict(
exclude.InputMapId,
other.InputMapId) != true)
{
continue;
}
if (other.Model.Current.Contains(chord)) if (other.Model.Current.Contains(chord))
rows.Add(other); rows.Add(other);
} }
@ -677,24 +801,42 @@ public sealed class KeyboardConfigController
private static void WireScreenButtons( private static void WireScreenButtons(
ImportedLayout layout, KeyboardConfigController controller, Bindings bindings) ImportedLayout layout, KeyboardConfigController controller, Bindings bindings)
{ {
// Load File / Save As — INERT (D4: keybinds.json only, no .keymap UiText? filename = layout.FindElement(FilenameLabelId) as UiText;
// interchange). Authored, clickable, no handler — same shape as OP3's void RefreshFilename()
// still-inert buttons. {
_ = layout.FindElement(LoadButtonId); if (filename is null || bindings.CurrentKeymapFilename is null) return;
_ = layout.FindElement(SaveAsButtonId); string value = bindings.CurrentKeymapFilename();
_ = layout.FindElement(FilenameLabelId); 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) if (layout.FindElement(DefaultsButtonId) is UiButton defaultsButton)
defaultsButton.OnClick = () => defaultsButton.OnClick = () =>
{ {
foreach (RowView row in controller._rows)
row.Model.SetDefaultValue(row.Model.DefaultValue);
controller.Page.Defaults(); controller.Page.Defaults();
foreach (RowView row in controller._rows) foreach (RowView row in controller._rows)
controller.RefreshRowButtons(row); controller.RefreshRowButtons(row);
}; };
if (layout.FindElement(RevertButtonId) is UiButton revertButton) if (layout.FindElement(RevertButtonId) is UiButton revertButton)
{
revertButton.OnClick = () => revertButton.OnClick = () =>
{ {
controller.Page.Reset(); controller.Page.Reset();
@ -702,16 +844,29 @@ public sealed class KeyboardConfigController
controller.RefreshRowButtons(row); controller.RefreshRowButtons(row);
}; };
// OK — right-click release in retail (idMessage 0x19); ported as a plain // gmKeyboardUI::OnOptionChanged @ 0x004DA890 addresses the
// left-click here, matching every other Campaign OP button (the asymmetry // m_pKeyboardRevertToSavedButton slot through the secondary
// is authored-input-only — no user-visible affordance differs, since // IOptionChangeHandler base. It is Normal (state 1) exactly while
// retail's own right-click-release on just this pair of buttons carries // OptionPage::Changed is true, otherwise Ghosted (state 0xD).
// no distinguishing visual cue either). 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) if (layout.FindElement(OkButtonId) is UiButton okButton)
okButton.OnClick = () => okButton.OnClick = () =>
{ {
controller.Page.Apply(); 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(); bindings.Save();
controller.Page.Apply();
bindings.Toggle(); bindings.Toggle();
}; };
@ -724,4 +879,17 @@ public sealed class KeyboardConfigController
bindings.Toggle(); bindings.Toggle();
}; };
} }
private void ReloadRowsFromBindings(Bindings bindings)
{
foreach (RowView row in _rows)
{
IReadOnlyList<KeyChord> 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();
}
} }

View file

@ -140,6 +140,12 @@ public sealed class MapHousePanelController : IRetainedPanelController
public bool IsShowingHouse => _tabPanel.ActivePageElementId == HousePageId; 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() public void OnShown()
{ {
_visible = true; _visible = true;

View file

@ -554,10 +554,10 @@ public sealed class ActionKeyMapOptionRow : IOptionRow
public bool Changed => !_current.SequenceEqual(_saved); public bool Changed => !_current.SequenceEqual(_saved);
/// <summary>Reset-to-Defaults reloads the DAT master maps fresh /// <summary>Replaces the DAT master-map default used by the next
/// (<c>gmKeyboardUI::RestoreDefaultValues</c> — research doc §5.6) before /// Reset-to-Defaults operation. The installed DAT is immutable during one
/// restoring each row, so the default slot list itself can change between /// client process, so the keyboard controller normally seeds this once
/// presses (a fresh DAT read), not just at construction time.</summary> /// when it builds the row.</summary>
public void SetDefaultValue(IReadOnlyList<KeyChord> value) => _default = value; public void SetDefaultValue(IReadOnlyList<KeyChord> value) => _default = value;
/// <summary>The capture/erase entry point — writes <c>m_current</c> and applies /// <summary>The capture/erase entry point — writes <c>m_current</c> and applies
@ -574,6 +574,16 @@ public sealed class ActionKeyMapOptionRow : IOptionRow
public void SaveCurrentValue() => _saved = _current; public void SaveCurrentValue() => _saved = _current;
/// <summary>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.</summary>
public void ReloadCurrentAndSaved(IReadOnlyList<KeyChord> value)
{
_current = value;
_saved = value;
_notifyPageOptionChanged?.Invoke();
}
public void RestoreSavedValue() public void RestoreSavedValue()
{ {
_current = _saved; _current = _saved;

View file

@ -134,6 +134,28 @@ public sealed class OptionsPanelController : IRetainedPanelController
public OptionPage ConfigPage => _pages[ConfigPageId]; public OptionPage ConfigPage => _pages[ConfigPageId];
/// <summary>True when the authored Gameplay Options page is active.</summary>
public bool IsShowingGameplay =>
_tabPanel.ActivePageElementId == GameplayPageId;
public bool IsShowingCharacter =>
_tabPanel.ActivePageElementId == CharacterPageId;
public bool IsShowingConfiguration =>
_tabPanel.ActivePageElementId == ConfigPageId;
/// <summary>
/// Programmatic form of retail action <c>0x1000001B</c>, resolved from
/// the installed ActionMap as "Show/Hide Gameplay Options Page". This is
/// the final fallback of <c>ClientUISystem::OnAction(EscapeKey)</c> at
/// <c>0x00564CBF</c>.
/// </summary>
public void ShowGameplay() => _tabPanel.SwitchTo(GameplayPageId);
public void ShowCharacter() => _tabPanel.SwitchTo(CharacterPageId);
public void ShowConfiguration() => _tabPanel.SwitchTo(ConfigPageId);
private OptionsPanelController(UiTabPanel tabPanel, Action? afterApply) private OptionsPanelController(UiTabPanel tabPanel, Action? afterApply)
{ {
_tabPanel = tabPanel; _tabPanel = tabPanel;

View file

@ -115,6 +115,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated += OnObjectChanged; _objects.ObjectUpdated += OnObjectChanged;
_objects.Cleared += OnObjectsCleared; _objects.Cleared += OnObjectsCleared;
_selection.Changed += OnSelectionChanged; _selection.Changed += OnSelectionChanged;
_itemInteraction.StateChanged += OnInteractionStateChanged;
// ── Slots-toggle wiring ─────────────────────────────────────────────────────────────────── // ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
foreach (var id in ArmorSlotElementIds) foreach (var id in ArmorSlotElementIds)
@ -216,6 +217,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
Populate(); Populate();
} }
private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators(); private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators();
private void OnInteractionStateChanged() => Populate();
private void OnObjectsCleared() private void OnObjectsCleared()
{ {
ApplyAetheriaVisibility(); ApplyAetheriaVisibility();
@ -225,8 +227,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may /// <summary>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 /// 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 /// 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 /// has WielderId==p (login, from CreateObject) or ContainerId==p, so the
/// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved carries the /// equip-location need not be tested here; OnObjectMoved carries the
/// complete old/new retail placement for transitions that satisfy neither after mutation.</summary> /// complete old/new retail placement for transitions that satisfy neither after mutation.</summary>
private bool Concerns(ClientObject o) private bool Concerns(ClientObject o)
{ {
@ -256,6 +258,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
uint dragTex = _dragIconIds?.Invoke( uint dragTex = _dragIconIds?.Invoke(
worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects) ?? 0u; worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects) ?? 0u;
list.Cell.SetItem(worn.ObjectId, tex, dragIconTexture: dragTex); list.Cell.SetItem(worn.ObjectId, tex, dragIconTexture: dragTex);
list.Cell.SetWaitingState(
_itemInteraction.IsPendingInventorySource(worn.ObjectId));
} }
ApplyAetheriaVisibility(); ApplyAetheriaVisibility();
ApplySelectionIndicators(); ApplySelectionIndicators();
@ -278,7 +282,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
foreach (var (_, list) in _slots) foreach (var (_, list) in _slots)
{ {
list.Cell.Selected = list.Cell.ItemId != 0 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.ObjectUpdated -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared; _objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged; _selection.Changed -= OnSelectionChanged;
_itemInteraction.StateChanged -= OnInteractionStateChanged;
foreach (var (_, list) in _slots) foreach (var (_, list) in _slots)
{ {
list.PrimaryItemPressed = null; list.PrimaryItemPressed = null;

View file

@ -0,0 +1,122 @@
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>Retail type-7 <c>ConfirmationMenuDialog</c>, used by Configure
/// Keyboard's authored Load File button.</summary>
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<uint> _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<uint> 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<string> items = _data.TryGet<string[]>(
RetailDialogProperty.MenuItems, out string[] values)
? values
: Array.Empty<string>();
_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);
}
}

View file

@ -14,6 +14,11 @@ public static class RetailDialogProperty
public const uint TextInputAcceptLabel = 0x9Au; public const uint TextInputAcceptLabel = 0x9Au;
public const uint TextInputRejectLabel = 0x9Bu; public const uint TextInputRejectLabel = 0x9Bu;
public const uint TextInputResult = 0x9Cu; 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;
/// <summary> /// <summary>
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean /// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable. /// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable.
@ -97,6 +102,19 @@ public sealed class RetailDialogData
} }
: defaultValue; : 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) public string? GetString(uint propertyId)
=> _values.TryGetValue(propertyId, out object? raw) ? raw as string : null; => _values.TryGetValue(propertyId, out object? raw) ? raw as string : null;
@ -148,4 +166,18 @@ public sealed class RetailDialogData
.Set(RetailDialogProperty.ElementAttribute40, true) .Set(RetailDialogProperty.ElementAttribute40, true)
.Set(RetailDialogProperty.Message, message); .Set(RetailDialogProperty.Message, message);
} }
/// <summary>Type-7 confirmation menu used by retail's keyboard-profile
/// Load File workflow (<c>gmKeyboardUI::MakeLoadKeymapDialog</c>).</summary>
public static RetailDialogData ConfirmationMenu(
IReadOnlyList<string> 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);
}
} }

View file

@ -169,20 +169,28 @@ public sealed class RetailDialogFactory : IDisposable
/// <c>UIOption_ActionKeyMap::OpenMapWarnDialog @ 0x00488A00</c>: type 2, /// <c>UIOption_ActionKeyMap::OpenMapWarnDialog @ 0x00488A00</c>: type 2,
/// caller-chosen queue key, element attribute 0x40 set, message text. /// caller-chosen queue key, element attribute 0x40 set, message text.
/// </summary> /// </summary>
public uint MakeWait(string message, uint queueKey = DefaultQueueKey) public uint MakeWait(
string message,
uint queueKey = DefaultQueueKey,
bool priority = false)
{ {
RetailDialogData data = RetailDialogData.Wait(message) RetailDialogData data = RetailDialogData.Wait(message)
.Set(RetailDialogProperty.QueueKey, queueKey); .Set(RetailDialogProperty.QueueKey, queueKey);
if (priority)
data.Set(RetailDialogProperty.Priority, true);
return MakeDialog(data, callback: null); return MakeDialog(data, callback: null);
} }
public uint MakeMessage( public uint MakeMessage(
string message, string message,
Action<RetailDialogData>? callback = null, Action<RetailDialogData>? callback = null,
uint queueKey = DefaultQueueKey) uint queueKey = DefaultQueueKey,
bool priority = false)
{ {
RetailDialogData data = RetailDialogData.Message(message) RetailDialogData data = RetailDialogData.Message(message)
.Set(RetailDialogProperty.QueueKey, queueKey); .Set(RetailDialogProperty.QueueKey, queueKey);
if (priority)
data.Set(RetailDialogProperty.Priority, true);
return MakeDialog(data, callback); return MakeDialog(data, callback);
} }
@ -196,6 +204,17 @@ public sealed class RetailDialogFactory : IDisposable
return MakeDialog(data, callback); return MakeDialog(data, callback);
} }
public uint MakeConfirmationMenu(
IReadOnlyList<string> items,
int selectedIndex,
Action<RetailDialogData>? callback = null,
uint queueKey = DefaultQueueKey)
{
RetailDialogData data = RetailDialogData.ConfirmationMenu(items, selectedIndex)
.Set(RetailDialogProperty.QueueKey, queueKey);
return MakeDialog(data, callback);
}
/// <summary> /// <summary>
/// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active /// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active
/// nonqueued dialog, an active queued dialog, or an item still pending in a queue. /// 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 if (type is not (RetailDialogType.Confirmation
or RetailDialogType.Wait or RetailDialogType.Wait
or RetailDialogType.Message or RetailDialogType.Message
or RetailDialogType.ConfirmationTextInput)) or RetailDialogType.ConfirmationTextInput
or RetailDialogType.ConfirmationMenu))
{ {
throw new NotSupportedException( throw new NotSupportedException(
$"Retail dialog type {(uint)type} does not have a ported presenter yet."); $"Retail dialog type {(uint)type} does not have a ported presenter yet.");
@ -415,6 +435,10 @@ public sealed class RetailDialogFactory : IDisposable
new RetailConfirmationTextInputDialogView( new RetailConfirmationTextInputDialogView(
_host, layout, info.Data, info.Context, _host, layout, info.Data, info.Context,
context => CloseDialog(context)), context => CloseDialog(context)),
RetailDialogType.ConfirmationMenu =>
new RetailConfirmationMenuDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),
_ => new RetailConfirmationDialogView( _ => new RetailConfirmationDialogView(
_host, layout, info.Data, info.Context, _host, layout, info.Data, info.Context,
context => CloseDialog(context)), context => CloseDialog(context)),

View file

@ -37,8 +37,7 @@ namespace AcDream.App.UI.Layout;
/// / DIK_LMENU), names its METAKEY through the meta table + OS fallback and /// / DIK_LMENU), names its METAKEY through the meta table + OS fallback and
/// joins with the authored <c>ID_KeyDescDelimiter</c> ("+", table enum 3 → /// joins with the authored <c>ID_KeyDescDelimiter</c> ("+", table enum 3 →
/// DID <c>0x23000007</c>). A binding whose KEY IS a modifier key (retail's /// DID <c>0x23000007</c>). A binding whose KEY IS a modifier key (retail's
/// walk-mode DIK_LSHIFT row has meta-mode 0; acdream's <see cref="KeyChord"/> /// walk-mode DIK_LSHIFT row has meta-mode 0) shows only the key name — never
/// carries the wire-side self-modifier bit) shows only the key name — never
/// "Shift+ShiftLeft". /// "Shift+ShiftLeft".
/// </para> /// </para>
/// </summary> /// </summary>
@ -79,21 +78,34 @@ public sealed class RetailKeyNames
/// <summary> /// <summary>
/// Display name for one bound chord — retail /// Display name for one bound chord — retail
/// <c>GetNameFromKey(QualifiedControl)</c>. Mouse chords keep the /// <c>GetNameFromKey(QualifiedControl)</c>. Mouse controls use retail's
/// pre-existing enum spelling: retail names mouse controls through the /// DIMOFS semantic/table lookup. If the table misses, DirectInput would
/// DirectInput mouse device, which this port does not have (AD-95a). /// provide a localized object name; acdream's non-DirectInput fallback is
/// the stable user-facing "Mouse Button N".
/// </summary> /// </summary>
public string Describe(KeyChord chord) public string Describe(KeyChord chord)
{ {
if (chord == default) if (chord == default)
return string.Empty; 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)) if (!TryGetDik(chord.Key, out byte dik, out string? dikName))
return FallbackSpelling(chord); return FallbackSpelling(chord);
return Compose(chord, LookupName(dikName!, dik, KeyNameTableId));
}
private string Compose(KeyChord chord, string keyName)
{
var composed = new System.Text.StringBuilder(); var composed = new System.Text.StringBuilder();
// Meta-mode bits ascending, skipping the key's own self-modifier bit // 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 // (retail's walk-mode LSHIFT row carries meta-mode 0 on the wire).
// chord's stored self bit is acdream's encoding, not display truth).
foreach ((ModifierMask flag, Key metaKey) in MetaOrder) foreach ((ModifierMask flag, Key metaKey) in MetaOrder)
{ {
if ((chord.Modifiers & flag) == 0 || IsSelfModifier(chord.Key, flag)) if ((chord.Modifiers & flag) == 0 || IsSelfModifier(chord.Key, flag))
@ -104,10 +116,36 @@ public sealed class RetailKeyNames
composed.Append(_delimiter); composed.Append(_delimiter);
} }
composed.Append(LookupName(dikName!, dik, KeyNameTableId)); composed.Append(keyName);
return composed.ToString(); 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) private string LookupName(string dikName, byte dik, uint tableId)
=> _resolveString(tableId, DatStringResolver.ComputeHash(dikName)) => _resolveString(tableId, DatStringResolver.ComputeHash(dikName))
?? _osKeyName?.Invoke((byte)(dik & 0x7F), (dik & 0x80) != 0) ?? _osKeyName?.Invoke((byte)(dik & 0x7F), (dik & 0x80) != 0)
@ -126,6 +164,7 @@ public sealed class RetailKeyNames
(ModifierMask.Shift, Key.ShiftLeft), (ModifierMask.Shift, Key.ShiftLeft),
(ModifierMask.Ctrl, Key.ControlLeft), (ModifierMask.Ctrl, Key.ControlLeft),
(ModifierMask.Alt, Key.AltLeft), (ModifierMask.Alt, Key.AltLeft),
(ModifierMask.Win, Key.SuperLeft),
}; };
private static bool IsSelfModifier(Key key, ModifierMask flag) 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.Shift => key is Key.ShiftLeft or Key.ShiftRight,
ModifierMask.Ctrl => key is Key.ControlLeft or Key.ControlRight, ModifierMask.Ctrl => key is Key.ControlLeft or Key.ControlRight,
ModifierMask.Alt => key is Key.AltLeft or Key.AltRight, ModifierMask.Alt => key is Key.AltLeft or Key.AltRight,
ModifierMask.Win => key is Key.SuperLeft or Key.SuperRight,
_ => false, _ => false,
}; };
/// <summary> /// <summary>
/// Silk key → DirectInput scan code + DIK name — the reverse of /// Silk key → DirectInput scan code + DIK name — the reverse of
/// <see cref="RetailScanCodeMap.ToSilkKey"/>'s keyboard table (same 84 /// <see cref="RetailScanCodeMap.ToSilkKey"/>'s keyboard table: the 84
/// DAT-observed codes) plus the modifier keys live capture can produce /// DAT-default codes plus the additional controls accepted by retail's
/// that no DAT default binds directly (DIK_LCONTROL 0x1D, DIK_LMENU 0x38, /// plain-text keymap format. DIK codes with bit 0x80 are the extended set — the
/// DIK_RMENU 0xB8). DIK codes with bit 0x80 are the extended set — the
/// same split Win32's GetKeyNameText expects in bit 24. /// same split Win32's GetKeyNameText expects in bit 24.
/// </summary> /// </summary>
private static bool TryGetDik(Key key, out byte dik, out string? name) 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.KeypadMultiply => ((byte)0x37, "DIK_MULTIPLY"),
Key.AltLeft => ((byte)0x38, "DIK_LMENU"), Key.AltLeft => ((byte)0x38, "DIK_LMENU"),
Key.Space => ((byte)0x39, "DIK_SPACE"), Key.Space => ((byte)0x39, "DIK_SPACE"),
Key.CapsLock => ((byte)0x3A, "DIK_CAPITAL"),
Key.F1 => ((byte)0x3B, "DIK_F1"), Key.F1 => ((byte)0x3B, "DIK_F1"),
Key.F2 => ((byte)0x3C, "DIK_F2"), Key.F2 => ((byte)0x3C, "DIK_F2"),
Key.F3 => ((byte)0x3D, "DIK_F3"), Key.F3 => ((byte)0x3D, "DIK_F3"),
@ -233,10 +273,15 @@ public sealed class RetailKeyNames
Key.KeypadDecimal => ((byte)0x53, "DIK_DECIMAL"), Key.KeypadDecimal => ((byte)0x53, "DIK_DECIMAL"),
Key.F11 => ((byte)0x57, "DIK_F11"), Key.F11 => ((byte)0x57, "DIK_F11"),
Key.F12 => ((byte)0x58, "DIK_F12"), 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.KeypadEnter => ((byte)0x9C, "DIK_NUMPADENTER"),
Key.ControlRight => ((byte)0x9D, "DIK_RCONTROL"), Key.ControlRight => ((byte)0x9D, "DIK_RCONTROL"),
Key.KeypadDivide => ((byte)0xB5, "DIK_DIVIDE"), Key.KeypadDivide => ((byte)0xB5, "DIK_DIVIDE"),
Key.PrintScreen => ((byte)0xB7, "DIK_SYSRQ"),
Key.AltRight => ((byte)0xB8, "DIK_RMENU"), Key.AltRight => ((byte)0xB8, "DIK_RMENU"),
Key.Pause => ((byte)0xC5, "DIK_PAUSE"),
Key.Home => ((byte)0xC7, "DIK_HOME"), Key.Home => ((byte)0xC7, "DIK_HOME"),
Key.Up => ((byte)0xC8, "DIK_UP"), Key.Up => ((byte)0xC8, "DIK_UP"),
Key.PageUp => ((byte)0xC9, "DIK_PRIOR"), Key.PageUp => ((byte)0xC9, "DIK_PRIOR"),
@ -247,6 +292,9 @@ public sealed class RetailKeyNames
Key.PageDown => ((byte)0xD1, "DIK_NEXT"), Key.PageDown => ((byte)0xD1, "DIK_NEXT"),
Key.Insert => ((byte)0xD2, "DIK_INSERT"), Key.Insert => ((byte)0xD2, "DIK_INSERT"),
Key.Delete => ((byte)0xD3, "DIK_DELETE"), 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), _ => ((byte)0, null),
}; };
return name is not null; return name is not null;

View file

@ -94,6 +94,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
private readonly StackSplitQuantityState _splitQuantity; private readonly StackSplitQuantityState _splitQuantity;
private readonly SelectionState _selection; private readonly SelectionState _selection;
private readonly Func<uint, bool> _isVendorSplitExempt; private readonly Func<uint, bool> _isVendorSplitExempt;
private readonly Func<uint, bool> _isCoinstack;
private readonly Func<int> _coinTotal;
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged; private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged; private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged;
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated; private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
@ -128,7 +130,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
StackSplitQuantityState splitQuantity, StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated, Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated, Action<Action<ClientObject>> unsubscribeObjectUpdated,
Func<uint, bool> isVendorSplitExempt) Func<uint, bool> isVendorSplitExempt,
Func<uint, bool>? isCoinstack,
Func<int>? coinTotal)
{ {
_isHealthTarget = isHealthTarget; _isHealthTarget = isHealthTarget;
_isOwnedByPlayer = isOwnedByPlayer; _isOwnedByPlayer = isOwnedByPlayer;
@ -143,6 +147,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
_selection = selection ?? throw new ArgumentNullException(nameof(selection)); _selection = selection ?? throw new ArgumentNullException(nameof(selection));
_isVendorSplitExempt = isVendorSplitExempt _isVendorSplitExempt = isVendorSplitExempt
?? throw new ArgumentNullException(nameof(isVendorSplitExempt)); ?? throw new ArgumentNullException(nameof(isVendorSplitExempt));
_isCoinstack = isCoinstack ?? (_ => false);
_coinTotal = coinTotal ?? (() => 0);
_unsubscribeHealthChanged = unsubscribeHealthChanged; _unsubscribeHealthChanged = unsubscribeHealthChanged;
_unsubscribeItemManaChanged = unsubscribeItemManaChanged; _unsubscribeItemManaChanged = unsubscribeItemManaChanged;
_unsubscribeObjectUpdated = unsubscribeObjectUpdated; _unsubscribeObjectUpdated = unsubscribeObjectUpdated;
@ -319,7 +325,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
StackSplitQuantityState splitQuantity, StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated, Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated, Action<Action<ClientObject>> unsubscribeObjectUpdated,
Func<uint, bool> isVendorSplitExempt) Func<uint, bool> isVendorSplitExempt,
Func<uint, bool>? isCoinstack = null,
Func<int>? coinTotal = null)
=> new SelectedObjectController( => new SelectedObjectController(
layout, selection, layout, selection,
subscribeHealthChanged, unsubscribeHealthChanged, subscribeHealthChanged, unsubscribeHealthChanged,
@ -327,7 +335,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize, isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize,
sendQueryHealth, manaPercent, sendQueryItemMana, datFont, sendQueryHealth, manaPercent, sendQueryItemMana, datFont,
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated, splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated,
isVendorSplitExempt); isVendorSplitExempt, isCoinstack, coinTotal);
/// <summary> /// <summary>
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>): /// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
@ -373,7 +381,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
// ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ── // ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ──
uint stackSize = _stackSize(g); uint stackSize = _stackSize(g);
string? objectName = _resolveName(g); string? objectName = _resolveName(g);
_currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName) _currentName = _isCoinstack(g) && _isOwnedByPlayer(g)
? $"{stackSize} {objectName} (of {_coinTotal()})"
: stackSize > 1u && !string.IsNullOrEmpty(objectName)
? $"{stackSize} {objectName}" ? $"{stackSize} {objectName}"
: objectName; : objectName;
@ -522,6 +532,26 @@ public sealed class SelectedObjectController : IRetainedPanelController
} }
} }
/// <summary>
/// Retail <c>gmToolbarUI::RecvNotice_SplitStack @ 0x004BD2A0</c>: 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.
/// </summary>
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) private void OnObjectUpdated(ClientObject updated)
{ {
if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum) if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum)

View file

@ -243,6 +243,8 @@ public sealed class SocialPanelController : IRetainedPanelController
/// <summary>F4 <c>ToggleFellowshipPanel</c>'s tab-switch half.</summary> /// <summary>F4 <c>ToggleFellowshipPanel</c>'s tab-switch half.</summary>
public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId); public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId);
public void ShowFriends() => _tabPanel.SwitchTo(FriendsPageId);
/// <summary>True when the Allegiance tab is the active page — lets /// <summary>True when the Allegiance tab is the active page — lets
/// <see cref="RetailUiRuntime.HandleInputAction"/> implement the /// <see cref="RetailUiRuntime.HandleInputAction"/> implement the
/// close-on-second-press-of-the-SAME-tab semantics every other /// close-on-second-press-of-the-SAME-tab semantics every other
@ -264,6 +266,8 @@ public sealed class SocialPanelController : IRetainedPanelController
/// <summary>True when the Fellowship tab is the active page.</summary> /// <summary>True when the Fellowship tab is the active page.</summary>
public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId; public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId;
public bool IsShowingFriends => _tabPanel.ActivePageElementId == FriendsPageId;
/// <summary>True while the social panel's own window is shown — set by /// <summary>True while the social panel's own window is shown — set by
/// <see cref="OnShown"/>/<see cref="OnHidden"/>. Fix-round blast SF-2: /// <see cref="OnShown"/>/<see cref="OnHidden"/>. Fix-round blast SF-2:
/// gates the Friends/Squelch rebuild (see <see cref="Tick"/>) so their /// gates the Friends/Squelch rebuild (see <see cref="Tick"/>) so their

View file

@ -216,9 +216,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
public bool Handle(InputAction action) 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<uint> spells = _spellbook.GetFavorites(_activeTab); IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
if (index < spells.Count) 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) private void SelectTab(int tab)
{ {
_activeTab = Math.Clamp(tab, 0, 7); _activeTab = Math.Clamp(tab, 0, 7);

View file

@ -56,6 +56,23 @@ public sealed class ToolbarInputController
return true; 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 if (value >= (int)InputAction.UseQuickSlot_14
&& value <= (int)InputAction.UseQuickSlot_18) && value <= (int)InputAction.UseQuickSlot_18)
{ {

View file

@ -381,10 +381,18 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// X-close confirmation is already up; HandleButtonClicks' 0x100000d6 // X-close confirmation is already up; HandleButtonClicks' 0x100000d6
// case only opens a NEW one when this is 0 (pc:204155). // case only opens a NEW one when this is 0 (pc:204155).
private uint _closeConfirmContext; private uint _closeConfirmContext;
private int _lastAlternateCurrencyPurchase;
private bool _alternateCurrencyInventoryObserved;
private PendingVendorSplit? _pendingVendorSplit;
// F5: see DragOverGlobalTimeSink's own doc comment. // F5: see DragOverGlobalTimeSink's own doc comment.
private readonly DragOverGlobalTimeSink _dragOverSink; private readonly DragOverGlobalTimeSink _dragOverSink;
private bool _disposed; private bool _disposed;
private readonly record struct PendingVendorSplit(
uint SourceGuid,
uint WeenieClassId,
int Quantity);
private VendorUiController( private VendorUiController(
VendorState vendor, VendorState vendor,
RetailWindowHandle window, 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 // succeeds and this stops being a dead end — closes half of AP-161
// finding #2. // finding #2.
_itemList.ExamineItemRequested = ExamineItem; _itemList.ExamineItemRequested = ExamineItem;
_itemList.PrimaryItemPressed = PressVendorItem;
if (itemScrollbar is not null) if (itemScrollbar is not null)
{ {
itemScrollbar.Model = _itemList.Scroll; 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 // gate (pc:204229-204246) — the Selling tab's list is the ONLY drop
// target. UiItemList.RegisterDragHandler is the structural analogue. // target. UiItemList.RegisterDragHandler is the structural analogue.
_sellingList?.RegisterDragHandler(this); _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 // F5: mount the global-time sink so a live drag hovering anywhere
// over this window auto-switches to the Selling tab — see // 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 // separate "staging changed" gate from "holdings changed" (both
// UpdateTotalValue calls read the LIVE holding fresh, same as // UpdateTotalValue calls read the LIVE holding fresh, same as
// BuildCostText's own PropertyInt.CoinValue read). // BuildCostText's own PropertyInt.CoinValue read).
_objects.ObjectAdded += OnObjectAdded;
_objects.ObjectUpdated += OnObjectMoneyChanged; _objects.ObjectUpdated += OnObjectMoneyChanged;
_objects.StackSizeUpdated += OnStackSizeUpdated;
_objects.ObjectMoved += OnObjectMoved;
ShowTab(VendorPanelTab.Items); ShowTab(VendorPanelTab.Items);
ClearContent(); ClearContent();
@ -661,6 +683,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// mechanism every other panel already uses, not a vendor-specific // mechanism every other panel already uses, not a vendor-specific
// special case. // special case.
_objects.ObjectRemoved += OnObjectRemoved; _objects.ObjectRemoved += OnObjectRemoved;
_itemInteraction.RuntimeTransactions.Inventory.RequestFailed += OnInventoryRequestFailed;
// Slice 6.3: mirrors ExternalContainerController's own // Slice 6.3: mirrors ExternalContainerController's own
// _itemInteraction.StateChanged subscription — the Buy button must // _itemInteraction.StateChanged subscription — the Buy button must
// disable the instant a reservation is taken (BeginUseRequestReservation // disable the instant a reservation is taken (BeginUseRequestReservation
@ -866,6 +889,14 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
private void ShowTab(VendorPanelTab tab) 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; _itemsPage.Visible = tab == VendorPanelTab.Items;
_buyingPage.Visible = tab == VendorPanelTab.Buying; _buyingPage.Visible = tab == VendorPanelTab.Buying;
_sellingPage.Visible = tab == VendorPanelTab.Selling; _sellingPage.Visible = tab == VendorPanelTab.Selling;
@ -894,6 +925,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// staging lists the same way the category selection resets. // staging lists the same way the category selection resets.
_buyStaging.Clear(); _buyStaging.Clear();
_sellStaging.Clear(); _sellStaging.Clear();
_pendingVendorSplit = null;
ResetAlternateCurrencyTracking();
RefreshMoneyText();
_selectedCategoryIndex = -1; _selectedCategoryIndex = -1;
ShowTab(VendorPanelTab.Items); ShowTab(VendorPanelTab.Items);
RebuildCategories(); RebuildCategories();
@ -910,6 +944,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// the time this fires the relevant list is already empty in // the time this fires the relevant list is already empty in
// the normal flow, and the OTHER (untouched) list must // the normal flow, and the OTHER (untouched) list must
// survive a refresh triggered by its sibling. // survive a refresh triggered by its sibling.
ResetAlternateCurrencyTracking();
RefreshMoneyText();
ShowTab(VendorPanelTab.Items); ShowTab(VendorPanelTab.Items);
RebuildCategories(); RebuildCategories();
_window.Show(); _window.Show();
@ -920,6 +956,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// the session (contract's C2/C3 close semantics). // the session (contract's C2/C3 close semantics).
_buyStaging.Clear(); _buyStaging.Clear();
_sellStaging.Clear(); _sellStaging.Clear();
_pendingVendorSplit = null;
ResetAlternateCurrencyTracking();
ClearContent(); ClearContent();
ShowTab(VendorPanelTab.Items); ShowTab(VendorPanelTab.Items);
_window.Hide(); _window.Hide();
@ -1112,15 +1150,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
cell.SetItem(item.ItemGuid, icon); cell.SetItem(item.ItemGuid, icon);
cell.Selected = item.ItemGuid == selectedGuid; cell.Selected = item.ItemGuid == selectedGuid;
VendorShopItem captured = item; VendorShopItem captured = item;
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); cell.Clicked = () =>
// AP-171: double-click buys the item — a DELIBERATE, _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
// user-approved modernization. Retail has NO // gmVendorUI::HandleMousePresses @ 0x004C40D0: a
// double-click-to-buy anywhere in the named function // double-click in the browse list calls BuySingleItem.
// table (negative evidence recorded at the Slice 6
// research); the user requested it explicitly
// 2026-08-08 after being told so. Select-then-buy so
// the quantity/price path is identical to the Buy
// button's.
cell.DoubleClicked = () => cell.DoubleClicked = () =>
{ {
_selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
@ -1252,14 +1285,23 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
SetPlainText(_itemNameText, nameText); SetPlainText(_itemNameText, nameText);
VendorShopProfile profile = _vendor.Profile; VendorShopProfile profile = _vendor.Profile;
int rawValue = item.Value ?? 0; int price = ComputeShopItemPrice(item, quantity);
int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize);
int price = VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, quantity);
SetPlainText(_itemCostText, BuildCostText(profile, quantity, price)); SetPlainText(_itemCostText, BuildCostText(profile, quantity, price));
SetActionButtonsEnabled(true); 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);
}
/// <summary> /// <summary>
/// Right-click examine on a shop row — mirrors /// Right-click examine on a shop row — mirrors
/// <c>ExternalContainerController.ExamineItem</c>'s "select then /// <c>ExternalContainerController.ExamineItem</c>'s "select then
@ -1276,6 +1318,13 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
_itemInteraction.ExamineSelectedOrEnterMode(guid); _itemInteraction.ExamineSelectedOrEnterMode(guid);
} }
private bool PressVendorItem(uint guid)
{
if (guid != 0u)
_selection.Select(guid, SelectionChangeSource.Vendor);
return false;
}
/// <summary> /// <summary>
/// Slice 6.2: reacts to ANY global selection change, not just ones this /// Slice 6.2: reacts to ANY global selection change, not just ones this
/// panel originated — mirrors <c>ExternalContainerController.OnSelectionChanged</c>. /// panel originated — mirrors <c>ExternalContainerController.OnSelectionChanged</c>.
@ -1390,6 +1439,15 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// </summary> /// </summary>
private void OnObjectRemoved(ClientObject item) 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) if (_selection.SelectedObjectId == item.ObjectId)
{ {
_selection.Clear( _selection.Clear(
@ -1463,11 +1521,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// (<c>pc:203494-203497</c>) via the SAME <see cref="ClientObjectTable"/> /// (<c>pc:203494-203497</c>) via the SAME <see cref="ClientObjectTable"/>
/// generic int-property bundle every other PropertyInt-driven display /// generic int-property bundle every other PropertyInt-driven display
/// reads. The alt-currency holding is retail's /// reads. The alt-currency holding is retail's
/// <c>shopVendorProfile-&gt;trade_num - m_last_sale</c>; /// <c>shopVendorProfile-&gt;trade_num - m_last_sale</c>. This controller
/// <c>m_last_sale</c> only changes on a completed Slice-6 purchase, so /// mirrors the immediate subtraction after dispatch and then reconciles
/// with no purchase mechanism yet this port uses /// to the authoritative player-owned currency stacks when their object
/// <see cref="VendorShopProfile.AlternateCurrencyAmount"/> directly /// updates arrive; the profile amount is only the pre-observation fallback.
/// (retail's <c>m_last_sale == 0</c> case — see the register, AP-161).
/// </para> /// </para>
/// </summary> /// </summary>
private string BuildCostText(VendorShopProfile profile, int quantity, int price) 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}.", "This item costs {0} {1}. You have {2} {1}.",
price, price,
profile.AlternateCurrencyPluralName, profile.AlternateCurrencyPluralName,
(int)profile.AlternateCurrencyAmount); ResolveAlternateCurrencyAmount(profile));
} }
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0; int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
@ -1576,11 +1633,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
return; return;
uint quantity = ResolveBuyQuantity(shopItem); uint quantity = ResolveBuyQuantity(shopItem);
_itemInteraction.TryBuy( VendorShopProfile profile = _vendor.Profile;
if (_itemInteraction.TryBuy(
_vendor.VendorId, _vendor.VendorId,
shopItem.ItemGuid, shopItem.ItemGuid,
(int)quantity, (int)quantity,
_vendor.Profile.AlternateCurrencyWcid); profile.AlternateCurrencyWcid))
{
RecordAlternateCurrencyPurchase(
profile,
ComputeShopItemPrice(shopItem, (int)quantity));
}
} }
private bool TryFindShopItem(uint guid, out VendorShopItem shopItem) private bool TryFindShopItem(uint guid, out VendorShopItem shopItem)
@ -1653,6 +1716,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
(int)quantity, (int)quantity,
_vendor.Profile.AlternateCurrencyWcid)) _vendor.Profile.AlternateCurrencyWcid))
{ {
RecordAlternateCurrencyPurchase(
_vendor.Profile,
ComputeShopItemPrice(shopItem, (int)quantity));
_buyStaging.Remove(shopItem.ItemGuid, BuyStagingRemovalAmount(shopItem)); _buyStaging.Remove(shopItem.ItemGuid, BuyStagingRemovalAmount(shopItem));
} }
} }
@ -1684,11 +1750,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// <list type="number"> /// <list type="number">
/// <item>pyreal affordability — transaction total vs. purse /// <item>pyreal affordability — transaction total vs. purse
/// (<c>pc:204017</c>: <c>m_transactionValue &lt;= m_totalValue</c>).</item> /// (<c>pc:204017</c>: <c>m_transactionValue &lt;= m_totalValue</c>).</item>
/// <item>alt-currency affordability — vs. held trade currency minus /// <item>alt-currency affordability — vs. the authoritative held trade
/// <c>m_last_sale</c> (<c>pc:204032</c>). This session tracks no /// currency minus <c>m_last_sale</c> (<c>pc:204032</c>).</item>
/// <c>m_last_sale</c> credit yet (see the register's AP-161 residual),
/// so this uses the vendor's raw held count, retail's own
/// <c>m_last_sale == 0</c> case.</item>
/// <item>container-slot capacity (<c>pc:204053</c>: /// <item>container-slot capacity (<c>pc:204053</c>:
/// <c>containerSlotsNeeded &gt; player.ContainersCapacity - containersUsed</c>).</item> /// <c>containerSlotsNeeded &gt; player.ContainersCapacity - containersUsed</c>).</item>
/// <item>item-slot capacity (<c>pc:204067</c>: the same shape for /// <item>item-slot capacity (<c>pc:204067</c>: the same shape for
@ -1753,7 +1816,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
return; return;
} }
} }
else if (transactionValue > (int)profile.AlternateCurrencyAmount) else if (transactionValue > ResolveAlternateCurrencyAmount(profile))
{ {
_systemMessage?.Invoke(NotEnoughMoneyMessage); _systemMessage?.Invoke(NotEnoughMoneyMessage);
return; return;
@ -1778,8 +1841,11 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
} }
if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, profile.AlternateCurrencyWcid)) if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, profile.AlternateCurrencyWcid))
{
RecordAlternateCurrencyPurchase(profile, transactionValue);
_buyStaging.Clear(); _buyStaging.Clear();
} }
}
/// <summary>F1: the SAME per-row price formula <see cref="ApplyItemDisplay"/> shows, summed over every staged entry.</summary> /// <summary>F1: the SAME per-row price formula <see cref="ApplyItemDisplay"/> shows, summed over every staged entry.</summary>
private int ComputeBuyTransactionValue() private int ComputeBuyTransactionValue()
@ -1904,7 +1970,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
return string.Format( return string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"You have {0} {1}.", "You have {0} {1}.",
(int)profile.AlternateCurrencyAmount, ResolveAlternateCurrencyAmount(profile),
profile.AlternateCurrencyPluralName); profile.AlternateCurrencyPluralName);
} }
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0; int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
@ -1961,8 +2027,52 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// </summary> /// </summary>
private void OnObjectMoneyChanged(ClientObject updated) private void OnObjectMoneyChanged(ClientObject updated)
{ {
TryResolvePendingVendorSplit(updated);
if (updated.ObjectId != _playerGuid()) if (updated.ObjectId != _playerGuid())
return; 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(); UpdateBuyTransactionText();
UpdateSellTransactionText(); UpdateSellTransactionText();
// Post-buy gate finding (2026-08-08): the Items tab's cost sentence // Post-buy gate finding (2026-08-08): the Items tab's cost sentence
@ -1972,6 +2082,57 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
RefreshSelectionDisplay(); 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()));
}
/// <summary> /// <summary>
/// F1: port of <c>gmVendorUI::InqListSlotCount</c> (<c>pc:200038-200065</c>, /// F1: port of <c>gmVendorUI::InqListSlotCount</c> (<c>pc:200038-200065</c>,
/// <c>0x004c0c10</c>) — see <see cref="BuyAllButtonPressed"/>'s own doc /// <c>0x004c0c10</c>) — see <see cref="BuyAllButtonPressed"/>'s own doc
@ -2216,7 +2377,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
cell.SetItem(shopItem.ItemGuid, icon); cell.SetItem(shopItem.ItemGuid, icon);
cell.Selected = shopItem.ItemGuid == selectedGuid; cell.Selected = shopItem.ItemGuid == selectedGuid;
VendorShopItem captured = shopItem; 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); list.AddItem(cell);
} }
} }
@ -2249,13 +2412,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
{ {
SpriteResolve = list.SpriteResolve, SpriteResolve = list.SpriteResolve,
SlotIndex = list.GetNumUIItems(), SlotIndex = list.GetNumUIItems(),
AllowDragSource = false, AllowDragSource = true,
SourceKind = ItemDragSource.Inventory,
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(), TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
}; };
cell.SetItem(item.ObjectId, icon); cell.SetItem(item.ObjectId, icon);
cell.Selected = item.ObjectId == selectedGuid; cell.Selected = item.ObjectId == selectedGuid;
uint captured = item.ObjectId; 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); list.AddItem(cell);
} }
} }
@ -2267,15 +2433,62 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// gate, pc:204229-204246) ────────────────────────────────────────────── // gate, pc:204229-204246) ──────────────────────────────────────────────
/// <summary> /// <summary>
/// The Selling list never sources a drag of its own — every staged cell /// Retail <c>RecvNotice_ItemListBeginDrag @ 0x004C4380</c>: lifting an
/// sets <c>AllowDragSource = false</c> (F3, Slice 6 review), the same /// already-staged Selling row removes it in full. A partial toolbar split
/// non-drag-source convention every vendor row uses — so /// is not applied to this list; retail prints the literal refusal and
/// <see cref="UiItemSlot"/>'s drag-lift dispatch (which routes to the /// restores the slider to its maximum.
/// SOURCE list's own registered handler) can never actually reach this
/// method in practice. Implemented as a no-op for interface completeness.
/// </summary> /// </summary>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) 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( public ItemDragAcceptance OnDragOver(
@ -2335,8 +2548,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// <c>silent=0</c>, showing a rejection string) chained into /// <c>silent=0</c>, showing a rejection string) chained into
/// <c>VendorSellUI::AddItemToSell</c> (<c>pc:203546-203567</c>) on /// <c>VendorSellUI::AddItemToSell</c> (<c>pc:203546-203567</c>) on
/// success: auto-switch to the "Selling" tab, globally select the /// success: auto-switch to the "Selling" tab, globally select the
/// dropped item, stage it. Purely client-local — sends nothing to the /// dropped item, and stage it. For a partial stack retail first calls
/// server, matching the Buying tab's "Add to List". /// <c>AttemptToPlaceInContainer</c>, stages the source as a temporary
/// row, then replaces that row when the new split object arrives.
/// </summary> /// </summary>
public void HandleDropRelease( public void HandleDropRelease(
UiItemList targetList, UiItemList targetList,
@ -2356,6 +2570,29 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
ShowTab(VendorPanelTab.Selling); ShowTab(VendorPanelTab.Selling);
_selection.Select(payload.ObjId, SelectionChangeSource.Vendor); _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); _sellStaging.Add(payload.ObjId, quantity);
} }
@ -2366,18 +2603,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// <paramref name="quantity"/> is the staged quantity a successful drop /// <paramref name="quantity"/> is the staged quantity a successful drop
/// would use. /// would use.
/// <para> /// <para>
/// F6 (Slice 6b/6c review, byte-verified): this is ALWAYS the item's /// Retail's full-stack branch does pass the literal <c>-1</c> sentinel
/// FULL current stack — retail's <c>VendorSellUI::AddItemToSell</c> /// to <c>AddItemToSell</c>. The enclosing
/// (<c>pc:203546-203567</c>) stages via <c>gmVendorUI::AddItem(..., /// <c>VendorSellUI::AcceptDragObject</c>, however, first compares the
/// itemGuid, -1, ...)</c>, a LITERAL <c>-1</c> "full stack" sentinel /// live split slider with the maximum and creates a separate stack when
/// argument, never a slider read. A prior version of this port read the /// they differ. Therefore the quantity exposed here is the live slider
/// LIVE split-quantity slider here instead (the Slice 6b/6c research /// amount for stackables, not always the source's full count.
/// doc's Q4 section had flagged this exact source as an unverified
/// inferred analogy to the Buying tab's <c>AddToBuyList</c>) — that
/// inference is now known WRONG: Sell staging has no partial-quantity
/// feature in retail at all, unlike Buy. See
/// <c>VendorStagingList.Add</c>'s own doc comment for the Buy side's
/// (genuinely slider-driven) contrast.
/// </para> /// </para>
/// </summary> /// </summary>
private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity) private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity)
@ -2401,10 +2632,44 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
item.PublicWeenieBitfield ?? 0u); item.PublicWeenieBitfield ?? 0u);
if (rejection == VendorSellRejection.None) 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; 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;
}
/// <summary> /// <summary>
/// G4/Slice 6b: port of retail's close/pushpin button handler — /// G4/Slice 6b: port of retail's close/pushpin button handler —
/// <c>gmVendorUI::HandleButtonClicks</c>'s <c>0x100000d6</c> case /// <c>gmVendorUI::HandleButtonClicks</c>'s <c>0x100000d6</c> case
@ -2564,8 +2829,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
_disposed = true; _disposed = true;
_vendor.Changed -= OnVendorChanged; _vendor.Changed -= OnVendorChanged;
_selection.Changed -= OnSelectionTransition; _selection.Changed -= OnSelectionTransition;
_objects.ObjectAdded -= OnObjectAdded;
_objects.ObjectRemoved -= OnObjectRemoved; _objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectUpdated -= OnObjectMoneyChanged; _objects.ObjectUpdated -= OnObjectMoneyChanged;
_objects.StackSizeUpdated -= OnStackSizeUpdated;
_objects.ObjectMoved -= OnObjectMoved;
_itemInteraction.RuntimeTransactions.Inventory.RequestFailed -= OnInventoryRequestFailed;
_itemInteraction.StateChanged -= OnInteractionStateChanged; _itemInteraction.StateChanged -= OnInteractionStateChanged;
_splitQuantity.Changed -= OnSplitQuantityChanged; _splitQuantity.Changed -= OnSplitQuantityChanged;
_buyStaging.Changed -= RebuildBuyingList; _buyStaging.Changed -= RebuildBuyingList;
@ -2581,6 +2850,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
_typeMenu.OnSelect = null; _typeMenu.OnSelect = null;
_typeMenu.ButtonLabelProvider = null; _typeMenu.ButtonLabelProvider = null;
_itemList.ExamineItemRequested = 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) if (_close is not null)
_close.OnClick = null; _close.OnClick = null;
if (_buyButton is not null) if (_buyButton is not null)

View file

@ -12,6 +12,7 @@ using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Properties;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Runtime; using AcDream.Runtime;
@ -441,7 +442,8 @@ public sealed record VendorRuntimeBindings(
/// Campaign OP slice OP8: the Configure Keyboard screen's live read/write seam — /// Campaign OP slice OP8: the Configure Keyboard screen's live read/write seam —
/// the ONE live <see cref="InputDispatcher"/> (Bindings for reads, /// the ONE live <see cref="InputDispatcher"/> (Bindings for reads,
/// SetBindings+BeginCapture for writes/capture) plus the portable /// SetBindings+BeginCapture for writes/capture) plus the portable
/// <c>keybinds.json</c> path (D4 — no <c>.keymap</c> file interchange). Null /// <c>keybinds.json</c> mirror path. Retail <c>*.keymap</c> profiles live in
/// Documents/Asheron's Call and the selected profile is reloaded at startup. Null
/// <see cref="Dispatcher"/> (headless/no-window hosts, or before the graphical /// <see cref="Dispatcher"/> (headless/no-window hosts, or before the graphical
/// input stack finishes constructing) degrades to "Configure Keyboard has no /// input stack finishes constructing) degrades to "Configure Keyboard has no
/// live effect" exactly like every other null-dependency Options-panel seam. /// live effect" exactly like every other null-dependency Options-panel seam.
@ -464,11 +466,10 @@ public sealed record KeyboardRuntimeBindings(
/// (<c>RecvNotice_CloseDialog@0x004ed760</c> case 1) retail queues UI mode /// (<c>RecvNotice_CloseDialog@0x004ed760</c> case 1) retail queues UI mode
/// <c>0x10000009</c> (<c>gmEpilogueUI</c>) rather than exiting immediately — /// <c>0x10000009</c> (<c>gmEpilogueUI</c>) rather than exiting immediately —
/// out of scope here. This is a plain host action, not a generation-gated /// out of scope here. This is a plain host action, not a generation-gated
/// Runtime command: it is the SAME window-close path /// Runtime command. It closes through <c>d.Window.Close</c>, so status events
/// <c>GameplayWindowCommands</c>/<c>IGameplayWindowCommands.Close</c> already /// <c>disconnected</c>/<c>exited</c> still fire through
/// use for the in-world Escape fallback (<c>d.Window.Close</c> at /// <c>GameWindow.OnClosing</c> → <c>CompleteShutdown</c>. In-world Escape does
/// composition), so status events <c>disconnected</c>/<c>exited</c> still /// not use this path; retail clears selection or toggles Gameplay Options.
/// fire through <c>GameWindow.OnClosing</c> → <c>CompleteShutdown</c>.
/// </param> /// </param>
public sealed record CharacterSelectionRuntimeBindings( public sealed record CharacterSelectionRuntimeBindings(
Func<IRuntimeCharacterSelectionView?> View, Func<IRuntimeCharacterSelectionView?> View,
@ -529,7 +530,8 @@ public sealed record RetailUiRuntimeBindings(
KeyboardRuntimeBindings? Keyboard = null, KeyboardRuntimeBindings? Keyboard = null,
CharacterSelectionRuntimeBindings? CharacterSelection = null, CharacterSelectionRuntimeBindings? CharacterSelection = null,
// Campaign CC slice CC4: sibling of CharacterSelection above. // Campaign CC slice CC4: sibling of CharacterSelection above.
CharacterCreationRuntimeBindings? CharacterCreation = null); CharacterCreationRuntimeBindings? CharacterCreation = null,
Action? CaptureScreenshot = null);
/// <summary> /// <summary>
/// Composition owner for the production retained gameplay UI. GameWindow supplies /// 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 VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; }
private CharacterStatController.Binding? _characterStatBinding;
/// <summary>Campaign QT slice QT5 — the three-tab Journal panel.</summary> /// <summary>Campaign QT slice QT5 — the three-tab Journal panel.</summary>
public Layout.JournalPanelController? JournalPanelController { get; private set; } public Layout.JournalPanelController? JournalPanelController { get; private set; }
@ -1006,56 +1009,278 @@ public sealed class RetailUiRuntime : IDisposable
{ {
if (SpellcastingUiController?.Handle(action) == true) if (SpellcastingUiController?.Handle(action) == true)
return true; return true;
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel)
switch (action)
{ {
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); OpenSpellbook(SpellbookWindowPage.Spells);
return true; return true;
} case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel:
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel)
{
OpenSpellbook(SpellbookWindowPage.Components); OpenSpellbook(SpellbookWindowPage.Components);
return true; return true;
} case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterDetailPanel:
// Campaign FA slice FA3: F3/F4 — keyboard-only open paths (lane A ToggleWindow(WindowNames.Character);
// §6.1: neither action authors a toolbar button). Both share the return true;
// one social panel (RetailPanelCatalog.SocialPanel) and switch to case AcDream.UI.Abstractions.Input.InputAction.ToggleAttributesPanel:
// their own tab; the panel participates in the SAME gmPanelUI OpenCharacterPanel(CharacterStatController.CharacterStatTab.Attributes);
// one-active-panel exclusivity every sibling panel gets from return true;
// RetailPanelUiController.RegisterMainPanel. case AcDream.UI.Abstractions.Input.InputAction.ToggleSkillsPanel:
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel) OpenCharacterPanel(CharacterStatController.CharacterStatTab.Skills);
{ return true;
OpenSocialPanel(showAllegiance: true); case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterTitlesPage:
return true; OpenCharacterPanel(CharacterStatController.CharacterStatTab.Titles);
} return true;
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel) case AcDream.UI.Abstractions.Input.InputAction.ToggleWorldPanel:
{ ToggleWindow(WindowNames.MapHouse);
OpenSocialPanel(showAllegiance: false); 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 true;
} }
return ToolbarInputController?.Handle(action) == 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);
}
/// <summary>
/// Retail Escape's final fallback: toggle action <c>0x1000001B</c>,
/// whose installed-DAT ActionMap label is "Show/Hide Gameplay Options
/// Page". Reuses the authored Options tab and panel owners.
/// </summary>
public void ToggleGameplayOptionsPage()
=> OpenOptionsPage(OptionsPanelPage.Gameplay);
/// <summary>Semantic/rebound form of retail Enter/Tab chat activation.</summary>
public void FocusChatEntry()
{
if (Host.Root.DefaultTextInput is { } input)
Host.Root.SetKeyboardFocus(input);
}
/// <summary>
/// Shift+Escape's retail LOGOUT action: no confirmation dialog; the
/// normal grounded/airborne/no-player gate still applies.
/// </summary>
public void LogOutCharacter() => EndCharacterSessionWithRetailGates();
/// <summary>Shared F3/F4 handler — same "toggle closes on a repeat press /// <summary>Shared F3/F4 handler — same "toggle closes on a repeat press
/// of the SAME tab, otherwise show + switch" shape as <see cref="OpenSpellbook"/>.</summary> /// of the SAME tab, otherwise show + switch" shape as <see cref="OpenSpellbook"/>.</summary>
private void OpenSocialPanel(bool showAllegiance) private enum SocialPanelPage { Friends, Allegiance, Fellowship }
private void OpenSocialPanel(SocialPanelPage page)
{ {
bool visible = Host.IsWindowVisible(WindowNames.SocialPanel); bool visible = Host.IsWindowVisible(WindowNames.SocialPanel);
bool onTargetTab = showAllegiance bool onTargetTab = page switch
? SocialPanelController?.IsShowingAllegiance == true {
: SocialPanelController?.IsShowingFellowship == true; SocialPanelPage.Friends => SocialPanelController?.IsShowingFriends == true,
SocialPanelPage.Allegiance => SocialPanelController?.IsShowingAllegiance == true,
SocialPanelPage.Fellowship => SocialPanelController?.IsShowingFellowship == true,
_ => false,
};
if (visible && onTargetTab) if (visible && onTargetTab)
{ {
CloseWindow(WindowNames.SocialPanel); CloseWindow(WindowNames.SocialPanel);
return; return;
} }
if (showAllegiance) switch (page)
SocialPanelController?.ShowAllegiance(); {
else case SocialPanelPage.Friends: SocialPanelController?.ShowFriends(); break;
SocialPanelController?.ShowFellowship(); case SocialPanelPage.Allegiance: SocialPanelController?.ShowAllegiance(); break;
case SocialPanelPage.Fellowship: SocialPanelController?.ShowFellowship(); break;
}
_panelUi.SetPanelVisibility(RetailPanelCatalog.SocialPanel, visible: true); _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) private void OpenSpellbook(SpellbookWindowPage page)
{ {
bool visible = Host.IsWindowVisible(WindowNames.Spellbook); bool visible = Host.IsWindowVisible(WindowNames.Spellbook);
@ -1853,7 +2078,10 @@ public sealed class RetailUiRuntime : IDisposable
StackSplitQuantity, StackSplitQuantity,
handler => b.Objects.ObjectUpdated += handler, handler => b.Objects.ObjectUpdated += handler,
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; UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount( RetailWindowHandle handle = RetailWindowFrame.Mount(
@ -3072,12 +3300,88 @@ public sealed class RetailUiRuntime : IDisposable
string unmappedPath = UnmappedKeyBindingsPath(keyboard.KeyBindingsFilePath); string unmappedPath = UnmappedKeyBindingsPath(keyboard.KeyBindingsFilePath);
var unmapped = RetailUnmappedKeyBindings.LoadOrEmpty(unmappedPath); var unmapped = RetailUnmappedKeyBindings.LoadOrEmpty(unmappedPath);
var keymaps = new RetailKeymapProfileStore(keyboard.KeyBindingsFilePath);
// ID_KeyMapCantOverwriteReadOnlyKeymap_Label — table 0x23000004, byte- string? ResolveKeymapTemplate(string key, string fileName)
// verified 2026-08-11 (live probe): "Could not overwrite ". Falls back {
// to silence (no invented English) if the DAT string is ever missing. // The localized templates use one named filename variable. Keep
string? refusalText = strings.Resolve( // the common retail spellings populated; ResolveTemplate selects
0x23000004u, DatStringResolver.ComputeHash("ID_KeyMapCantOverwriteReadOnlyKeymap_Label")); // only the hash actually authored by the DAT entry.
var variables = new Dictionary<uint, string>
{
[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.KeyboardConfigController? controller = Layout.KeyboardConfigController.Bind(
layout, layout,
@ -3120,15 +3424,12 @@ public sealed class RetailUiRuntime : IDisposable
chord => onResult(chord == default ? null : chord)), chord => onResult(chord == default ? null : chord)),
Save: () => 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 try
{ {
dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath); HandleSaveResult(
unmapped.SaveToFile(unmappedPath); keymaps.SaveActive(dispatcher.Bindings),
keymaps.CurrentFileName,
static () => { });
} }
catch (Exception failure) catch (Exception failure)
{ {
@ -3136,11 +3437,23 @@ public sealed class RetailUiRuntime : IDisposable
} }
}, },
Toggle: () => ToggleWindow(WindowNames.KeyboardConfig), 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 — // M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog —
// confirm through the SAME RetailDialogFactory/MakeConfirmation // confirm through the SAME RetailDialogFactory/MakeConfirmation
// seam GameplayConfirmationController already uses, before // seam GameplayConfirmationController already uses, before
@ -3153,7 +3466,9 @@ public sealed class RetailUiRuntime : IDisposable
if (DialogFactory is null) { onResult(false); return; } if (DialogFactory is null) { onResult(false); return; }
DialogFactory.MakeConfirmation( DialogFactory.MakeConfirmation(
message, 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 // OP8 re-gate (2026-08-14): retail's capture-instruction dialog
// (InitiateBinding @ 0x004899D0 → OpenMapWarnDialog @ 0x00488A00): // (InitiateBinding @ 0x004899D0 → OpenMapWarnDialog @ 0x00488A00):
@ -3179,7 +3494,10 @@ public sealed class RetailUiRuntime : IDisposable
// `text` arrives with real line breaks. // `text` arrives with real line breaks.
try try
{ {
return DialogFactory.MakeWait(text, queueKey: 0x10000001u); return DialogFactory.MakeWait(
text,
queueKey: 0x10000001u,
priority: true);
} }
catch (Exception failure) catch (Exception failure)
{ {
@ -3196,7 +3514,64 @@ public sealed class RetailUiRuntime : IDisposable
} }
}, },
CloseCaptureInstructions: context => CloseCaptureInstructions: context =>
DialogFactory?.CloseDialog(context)), DialogFactory?.CloseDialog(context),
CurrentKeymapFilename: () => keymaps.CurrentFileName,
OpenLoadKeymap: onLoaded =>
{
if (DialogFactory is null) return;
IReadOnlyList<string> 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) => resolveTemplateFont: (templateLayoutId, templateElementId) =>
{ {
lock (_bindings.Assets.DatLock) lock (_bindings.Assets.DatLock)
@ -4073,7 +4448,7 @@ public sealed class RetailUiRuntime : IDisposable
lock (_bindings.Assets.DatLock) lock (_bindings.Assets.DatLock)
return RetailDataIdResolver.Resolve(_bindings.Assets.Dats, enumValue, category); return RetailDataIdResolver.Resolve(_bindings.Assets.Dats, enumValue, category);
} }
Action refreshRows = CharacterStatController.Bind( _characterStatBinding = CharacterStatController.Bind(
layout, layout,
() => currentSheet, () => currentSheet,
_bindings.Assets.DefaultFont, _bindings.Assets.DefaultFont,
@ -4090,7 +4465,7 @@ public sealed class RetailUiRuntime : IDisposable
_characterSheetSubscription = provider.SubscribeChanged(() => _characterSheetSubscription = provider.SubscribeChanged(() =>
{ {
currentSheet = provider.BuildSheet(); currentSheet = provider.BuildSheet();
refreshRows(); _characterStatBinding?.Refresh();
}); });
// CT3 (2026-08-24): the Titles page's row template lives in a // CT3 (2026-08-24): the Titles page's row template lives in a

View file

@ -184,6 +184,12 @@ public sealed class UiRoot : UiElement
/// <summary>Widget currently receiving keyboard events.</summary> /// <summary>Widget currently receiving keyboard events.</summary>
public UiElement? KeyboardFocus { get; private set; } 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;
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's /// <summary>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.</summary> /// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
public UiElement? DefaultTextInput { get; set; } public UiElement? DefaultTextInput { get; set; }
@ -497,7 +503,18 @@ public sealed class UiRoot : UiElement
internal void OnSubtreeRemoving(UiElement subtree) 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); WindowManager.OnSubtreeRemoving(subtree);
} }
@ -511,12 +528,15 @@ public sealed class UiRoot : UiElement
internal void OnElementVisibilityChanged(UiElement element, bool visible) internal void OnElementVisibilityChanged(UiElement element, bool visible)
=> ElementVisibilityChanged?.Invoke(element, visible); => ElementVisibilityChanged?.Invoke(element, visible);
internal void ClearSubtreeOwnership(UiElement subtree) internal void ClearSubtreeOwnership(UiElement subtree, bool preserveDetachedDrag = false)
{ {
if (IsWithinSubtree(KeyboardFocus, subtree)) if (IsWithinSubtree(KeyboardFocus, subtree))
SetKeyboardFocus(null); SetKeyboardFocus(null);
if (IsWithinSubtree(Captured, subtree)) if (IsWithinSubtree(Captured, subtree))
{ {
if (preserveDetachedDrag && ReferenceEquals(Captured, DragSource))
SetCapture(this);
else
ReleaseCapture(); ReleaseCapture();
_dragCandidate = false; _dragCandidate = false;
} }
@ -527,11 +547,14 @@ public sealed class UiRoot : UiElement
if (IsWithinSubtree(DragSource, subtree)) if (IsWithinSubtree(DragSource, subtree))
{ {
DragSource?.SetDragSourceActive(false, DragPayload); DragSource?.SetDragSourceActive(false, DragPayload);
if (!preserveDetachedDrag)
{
DragSource = null; DragSource = null;
DragPayload = null; DragPayload = null;
_dragGhost = null; _dragGhost = null;
_dragCandidate = false; _dragCandidate = false;
} }
}
if (IsWithinSubtree(_hoverWidget, subtree)) if (IsWithinSubtree(_hoverWidget, subtree))
{ {
var leave = new UiEvent(_hoverWidget!.EventId, _hoverWidget, UiEventType.HoverLeave); var leave = new UiEvent(_hoverWidget!.EventId, _hoverWidget, UiEventType.HoverLeave);
@ -1090,13 +1113,15 @@ public sealed class UiRoot : UiElement
public void OnKeyDown(int vk, uint lparam = 0) 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 // 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 // input (retail's chat-activation hotkeys). Consumed so the same press doesn't
// also fall through to a game hotkey. // also fall through to a game hotkey.
if (KeyboardFocus is null && DefaultTextInput is not null if (KeyboardFocus is null && DefaultTextInput is not null
&& (vk == (int)Silk.NET.Input.Key.Tab && (vk == (int)Silk.NET.Input.Key.Tab
|| vk == (int)Silk.NET.Input.Key.Enter || vk == (int)Silk.NET.Input.Key.Enter))
|| vk == (int)Silk.NET.Input.Key.KeypadEnter))
{ {
SetKeyboardFocus(DefaultTextInput); SetKeyboardFocus(DefaultTextInput);
return; return;
@ -1125,6 +1150,11 @@ public sealed class UiRoot : UiElement
public void OnKeyUp(int vk, uint lparam = 0) public void OnKeyUp(int vk, uint lparam = 0)
{ {
if (_suppressedPhysicalKey == vk)
{
_suppressedPhysicalKey = null;
return;
}
if (KeyboardFocus is not null) if (KeyboardFocus is not null)
{ {
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyUp, var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyUp,
@ -1136,12 +1166,18 @@ public sealed class UiRoot : UiElement
public void OnChar(int codepoint) public void OnChar(int codepoint)
{ {
if (_suppressedPhysicalKey is not null)
return;
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl) return; if (KeyboardFocus is null || !KeyboardFocus.IsEditControl) return;
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.Char, var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.Char,
Data0: codepoint); Data0: codepoint);
BubbleEvent(KeyboardFocus, in e); BubbleEvent(KeyboardFocus, in e);
} }
/// <summary>Suppress the raw retained-UI tail of a semantic key action.</summary>
public void SuppressPhysicalKeyUntilRelease(Silk.NET.Input.Key key)
=> _suppressedPhysicalKey = (int)key;
// ── Focus + capture ───────────────────────────────────────────────── // ── Focus + capture ─────────────────────────────────────────────────
public void SetKeyboardFocus(UiElement? e) public void SetKeyboardFocus(UiElement? e)

View file

@ -24,6 +24,7 @@ public static class ClientCommandRequests
public const uint SetAfkModeOpcode = 0x000Fu; public const uint SetAfkModeOpcode = 0x000Fu;
public const uint SetAfkMessageOpcode = 0x0010u; public const uint SetAfkMessageOpcode = 0x0010u;
public const uint EmoteOpcode = 0x01DFu; public const uint EmoteOpcode = 0x01DFu;
public const uint SoulEmoteOpcode = 0x01E1u;
public const uint AddFriendOpcode = 0x0018u; public const uint AddFriendOpcode = 0x0018u;
public const uint AbandonContractOpcode = 0x0316u; public const uint AbandonContractOpcode = 0x0316u;
public const uint RemoveFriendOpcode = 0x0017u; public const uint RemoveFriendOpcode = 0x0017u;
@ -139,6 +140,10 @@ public static class ClientCommandRequests
public static byte[] BuildEmote(uint sequence, string message) => public static byte[] BuildEmote(uint sequence, string message) =>
BuildString(sequence, EmoteOpcode, 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 // CM_Social::Event_AddFriend/RemoveFriend/ClearFriends
// @ 0x006A5C10 / 0x006A5650 / 0x006A55C0. // @ 0x006A5C10 / 0x006A5650 / 0x006A55C0.
public static byte[] BuildAddFriend(uint sequence, string name) => public static byte[] BuildAddFriend(uint sequence, string name) =>

View file

@ -2681,6 +2681,13 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildEmote(seq, message)); SendGameAction(ClientCommandRequests.BuildEmote(seq, message));
} }
public void SendSoulEmote(string message)
{
ArgumentNullException.ThrowIfNull(message);
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSoulEmote(seq, message));
}
/// <summary> /// <summary>
/// Send retail SetSingleCharacterOption (0x0005) — toggles one character /// Send retail SetSingleCharacterOption (0x0005) — toggles one character
/// option. For the six <c>ListenTo*Chat</c> ids this is the message that /// option. For the six <c>ListenTo*Chat</c> ids this is the message that

View file

@ -16,6 +16,8 @@ public sealed class ChatCommandTargetState : IDisposable
private readonly object _gate = new(); private readonly object _gate = new();
private string? _lastIncomingTellSender; private string? _lastIncomingTellSender;
private string? _lastOutgoingTellTarget; private string? _lastOutgoingTellTarget;
private string? _lastMonarchSender;
private string? _lastPatronSender;
private bool _disposed; private bool _disposed;
public ChatCommandTargetState(ChatLog chat) public ChatCommandTargetState(ChatLog chat)
@ -42,6 +44,26 @@ public sealed class ChatCommandTargetState : IDisposable
} }
} }
/// <summary>Most recent sender of an incoming retail <c>@m</c> broadcast.</summary>
public string? LastMonarchSender
{
get
{
lock (_gate)
return _lastMonarchSender;
}
}
/// <summary>Most recent sender of an incoming retail <c>@p</c> broadcast.</summary>
public string? LastPatronSender
{
get
{
lock (_gate)
return _lastPatronSender;
}
}
public bool IsDisposed public bool IsDisposed
{ {
get get
@ -61,6 +83,8 @@ public sealed class ChatCommandTargetState : IDisposable
{ {
_lastIncomingTellSender = null; _lastIncomingTellSender = null;
_lastOutgoingTellTarget = null; _lastOutgoingTellTarget = null;
_lastMonarchSender = null;
_lastPatronSender = null;
} }
} }
@ -77,17 +101,34 @@ public sealed class ChatCommandTargetState : IDisposable
private void OnEntryAppended(ChatEntry entry) private void OnEntryAppended(ChatEntry entry)
{ {
if (entry.Kind != ChatKind.Tell || string.IsNullOrEmpty(entry.Sender)) if (string.IsNullOrEmpty(entry.Sender))
return; return;
lock (_gate) lock (_gate)
{ {
if (_disposed) if (_disposed)
return; return;
if (entry.Kind == ChatKind.Tell)
{
if (entry.SenderGuid != 0u) if (entry.SenderGuid != 0u)
_lastIncomingTellSender = entry.Sender; _lastIncomingTellSender = entry.Sender;
else else
_lastOutgoingTellTarget = entry.Sender; _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;
} }
} }
} }

View file

@ -33,18 +33,19 @@ public static class InventoryFailureMessages
string itemName, string itemName,
uint weenieError) uint weenieError)
{ {
// ServerSaysAttemptFailed's verb switch. acdream has no latched kind // ServerSaysAttemptFailed's complete verb switch. The enum values are
// for retail's IR_MOVE ("moved") or IR_WIELD ("wielded") today — // named by operation rather than retail's numeric IR_* values, but the
// wields ride AutoWieldController without the single-request gate — // wording and NAME_PLURAL/NAME_APPROPRIATE choice are verbatim.
// so those rows are absent rather than guessed onto a wrong kind.
string? verb = kind switch string? verb = kind switch
{ {
InventoryRequestKind.Merge => "merged", InventoryRequestKind.Merge => "merged",
InventoryRequestKind.SplitToContainer => "split", InventoryRequestKind.SplitToContainer => "split",
InventoryRequestKind.SplitToWorld => "split", InventoryRequestKind.SplitToWorld => "split",
InventoryRequestKind.Move => "moved",
InventoryRequestKind.Pickup => "picked up", InventoryRequestKind.Pickup => "picked up",
InventoryRequestKind.PutInContainer => "put in the container", InventoryRequestKind.PutInContainer => "put in the container",
InventoryRequestKind.DropToWorld => "dropped", InventoryRequestKind.DropToWorld => "dropped",
InventoryRequestKind.Wield => "wielded",
InventoryRequestKind.Give => "given", InventoryRequestKind.Give => "given",
_ => null, _ => null,
}; };

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using AcDream.Core.Content; using AcDream.Core.Content;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Input; namespace AcDream.Core.Input;
@ -124,7 +125,23 @@ public sealed record RetailActionMapRow(
/// <summary>The complete read result: every user-bindable ActionMap row, plus the raw /// <summary>The complete read result: every user-bindable ActionMap row, plus the raw
/// row count read (for conformance pinning against the installed dats).</summary> /// row count read (for conformance pinning against the installed dats).</summary>
public sealed record RetailActionMapSnapshot(IReadOnlyList<RetailActionMapRow> Rows); public sealed record RetailActionMapSnapshot(
IReadOnlyList<RetailActionMapRow> Rows,
IReadOnlyDictionary<uint, IReadOnlySet<uint>>? ConflictingInputMaps = null)
{
/// <summary>
/// Retail <c>ICIDM::FindConflictingInputMaps</c> policy. A context always
/// conflicts with itself; cross-context conflicts exist only when the
/// DAT <c>ActionMap.ConflictingMaps</c> table names the other context.
/// Contexts absent from that table therefore do not conflict across maps.
/// </summary>
public bool InputMapsConflict(uint leftInputMapId, uint rightInputMapId) =>
leftInputMapId == rightInputMapId
|| (ConflictingInputMaps?.TryGetValue(
leftInputMapId,
out IReadOnlySet<uint>? conflicts) == true
&& conflicts.Contains(rightInputMapId));
}
/// <summary> /// <summary>
/// Retail's 19 named <c>InputMapID -&gt; ID_InputMap_*</c> string-table keys /// Retail's 19 named <c>InputMapID -&gt; ID_InputMap_*</c> string-table keys
@ -220,7 +237,16 @@ public static class RetailActionMapReader
} }
} }
return new RetailActionMapSnapshot(rows); var conflictingInputMaps = new Dictionary<uint, IReadOnlySet<uint>>();
foreach (var entry in actionMap.ConflictingMaps)
{
InputsConflictsValue value = entry.Value;
uint inputMapId = value.InputMap != 0u ? value.InputMap : entry.Key;
conflictingInputMaps[inputMapId] =
new HashSet<uint>(value.ConflictingInputMaps);
}
return new RetailActionMapSnapshot(rows, conflictingInputMaps);
} }
private static void CollectDefaults( private static void CollectDefaults(

View file

@ -24,14 +24,31 @@ public readonly record struct ExternalContainerTransition(
/// </summary> /// </summary>
public sealed class ExternalContainerState public sealed class ExternalContainerState
{ {
private readonly HashSet<uint> _openedCorpses = [];
public uint RequestedContainerId { get; private set; } public uint RequestedContainerId { get; private set; }
public uint CurrentContainerId { get; private set; } public uint CurrentContainerId { get; private set; }
public int OpenedCorpseCount => _openedCorpses.Count;
public event Action<ExternalContainerTransition>? Changed; public event Action<ExternalContainerTransition>? Changed;
public bool RequestOpen(uint containerId) /// <summary>
/// Sets retail's requested ground object. When that object is a corpse,
/// this is also the exact <c>SetGroundObject</c> edge at which retail calls
/// <c>ACCWeenieObject::SetCorpseOpened @ 0x0058E670</c>.
/// </summary>
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; return false;
uint previous = CurrentContainerId; uint previous = CurrentContainerId;
@ -54,6 +71,19 @@ public sealed class ExternalContainerState
return true; return true;
} }
/// <summary>
/// Retail <c>ACCWeenieObject::HasCorpseBeenOpened @ 0x0058DB70</c>.
/// The set is session-scoped and an object's delete edge removes its id.
/// </summary>
public bool HasCorpseBeenOpened(uint objectId)
=> objectId != 0u && _openedCorpses.Contains(objectId);
/// <summary>
/// Retail <c>ACCWeenieObject::SetCorpseDeleted @ 0x0058E6C0</c>.
/// </summary>
public bool SetCorpseDeleted(uint objectId)
=> objectId != 0u && _openedCorpses.Remove(objectId);
public bool ApplyViewContents(uint containerId) public bool ApplyViewContents(uint containerId)
{ {
if (containerId == 0u || containerId != RequestedContainerId) if (containerId == 0u || containerId != RequestedContainerId)
@ -98,9 +128,12 @@ public sealed class ExternalContainerState
public bool Reset() public bool Reset()
{ {
uint previous = CurrentContainerId; uint previous = CurrentContainerId;
bool changed = previous != 0u || RequestedContainerId != 0u; bool changed = previous != 0u
|| RequestedContainerId != 0u
|| _openedCorpses.Count != 0;
CurrentContainerId = 0u; CurrentContainerId = 0u;
RequestedContainerId = 0u; RequestedContainerId = 0u;
_openedCorpses.Clear();
var transition = new ExternalContainerTransition( var transition = new ExternalContainerTransition(
ExternalContainerTransitionKind.Reset, ExternalContainerTransitionKind.Reset,

View file

@ -0,0 +1,158 @@
namespace AcDream.Core.Items;
/// <summary>
/// Side-effect-free container-placement result shared by drag hover and
/// release. It ports the observable rules from
/// <c>ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0</c>,
/// <c>AttemptToPlaceInContainer_IsContainerLegal @ 0x005879B0</c>, and
/// <c>WillItemFitInContainer @ 0x00587D60</c> that can be answered from the
/// client's public object projection.
/// </summary>
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>();
uint current = candidateId;
while (current != 0u && visited.Add(current))
{
if (current == possibleAncestorId)
return true;
current = objects.Get(current)?.ContainerId ?? 0u;
}
return false;
}
}

View file

@ -6,8 +6,10 @@ public enum InventoryRequestKind
PutInContainer, PutInContainer,
SplitToContainer, SplitToContainer,
Merge, Merge,
Move,
DropToWorld, DropToWorld,
SplitToWorld, SplitToWorld,
Wield,
Give, Give,
} }

View file

@ -20,13 +20,22 @@ public enum PublicWeenieFlags : uint
Attackable = 0x00000010, Attackable = 0x00000010,
/// <summary>PWD bit 5 — <c>ACCWeenieObject::IsPK @0x0058C8B0</c>.</summary> /// <summary>PWD bit 5 — <c>ACCWeenieObject::IsPK @0x0058C8B0</c>.</summary>
PlayerKiller = 0x00000020, PlayerKiller = 0x00000020,
HiddenAdmin = 0x00000040,
UiHidden = 0x00000080,
Book = 0x00000100,
Vendor = 0x00000200, Vendor = 0x00000200,
PlayerKillerSwitch = 0x00000400, PlayerKillerSwitch = 0x00000400,
NonPlayerKillerSwitch = 0x00000800, NonPlayerKillerSwitch = 0x00000800,
Door = 0x00001000, Door = 0x00001000,
Corpse = 0x00002000, Corpse = 0x00002000,
Lifestone = 0x00004000,
Food = 0x00008000,
Healer = 0x00010000, Healer = 0x00010000,
Lockpick = 0x00020000, Lockpick = 0x00020000,
Portal = 0x00040000,
Admin = 0x00100000,
FreePlayerKiller = 0x00200000,
ImmuneCellRestrictions = 0x00400000,
RequiresPackSlot = 0x00800000, RequiresPackSlot = 0x00800000,
/// <summary> /// <summary>
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c>, the "unsellable" bit /// F4 (Slice 6b/6c review): <c>BF_RETAINED</c>, the "unsellable" bit
@ -37,6 +46,8 @@ public enum PublicWeenieFlags : uint
Retained = 0x01000000, Retained = 0x01000000,
/// <summary>PWD bit 0x19 (25) — <c>ACCWeenieObject::IsPKLite @0x0058C8A0</c>.</summary> /// <summary>PWD bit 0x19 (25) — <c>ACCWeenieObject::IsPKLite @0x0058C8A0</c>.</summary>
PlayerKillerLite = 0x02000000, PlayerKillerLite = 0x02000000,
IncludesSecondHeader = 0x04000000,
Bindstone = 0x08000000,
VolatileRare = 0x10000000, VolatileRare = 0x10000000,
WieldOnUse = 0x20000000, WieldOnUse = 0x20000000,
WieldLeft = 0x40000000, WieldLeft = 0x40000000,
@ -79,7 +90,8 @@ public readonly record struct ItemPolicyObject(
int TradeState, int TradeState,
int StackSize, int StackSize,
int MaxSplitSize, int MaxSplitSize,
bool IsIn3DView) bool IsIn3DView,
string Name = "item")
{ {
public bool IsPlayer => (Flags & PublicWeenieFlags.Player) != 0; public bool IsPlayer => (Flags & PublicWeenieFlags.Player) != 0;
} }
@ -249,11 +261,11 @@ public static class ItemInteractionPolicy
} }
if (source.TradeState == 1) 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 if (source.CurrentLocation == EquipMask.None
&& ItemUseability.LeastLimitedSourceUse(source.Useability) == ItemUseability.Wielded) && 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)) if (ItemUseability.IsTargeted(source.Useability))
{ {
@ -261,9 +273,9 @@ public static class ItemInteractionPolicy
return Consumed(new ItemPolicyAction(ItemPolicyActionKind.EnterTargetMode, source.Id)); return Consumed(new ItemPolicyAction(ItemPolicyActionKind.EnterTargetMode, source.Id));
if (input.SelectedTarget is not { } target) if (input.SelectedTarget is not { } target)
return Reject("Select a target for this item first."); return Reject($"Select your target before using the {NameOf(source)}");
if (!IsTargetCompatible(source, target, input.PlayerId)) if (TargetCompatibilityFailure(source, target, input.PlayerId) is { } failure)
return Reject("That is not a valid target for this item."); return Reject(failure);
var actions = new List<ItemPolicyAction> var actions = new List<ItemPolicyAction>
{ {
@ -305,13 +317,13 @@ public static class ItemInteractionPolicy
if (source.Id == input.PlayerId) if (source.Id == input.PlayerId)
return new ItemUsePolicyDecision(false, Array.Empty<ItemPolicyAction>()); return new ItemUsePolicyDecision(false, Array.Empty<ItemPolicyAction>());
if ((source.Flags & PublicWeenieFlags.Door) != 0) 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 if ((source.Flags & PublicWeenieFlags.Attackable) != 0
&& input.InNonCombatMode) && 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 if ((source.Flags & PublicWeenieFlags.Attackable) == 0
|| input.InNonCombatMode) || input.InNonCombatMode)
return Reject("That object cannot be used."); return Reject($"The {NameOf(source)} cannot be used");
return new ItemUsePolicyDecision(false, Array.Empty<ItemPolicyAction>()); return new ItemUsePolicyDecision(false, Array.Empty<ItemPolicyAction>());
} }
@ -319,9 +331,18 @@ public static class ItemInteractionPolicy
in ItemPolicyObject source, in ItemPolicyObject source,
in ItemPolicyObject target, in ItemPolicyObject target,
uint playerId) uint playerId)
=> TargetCompatibilityFailure(source, target, playerId) is null;
private static string? TargetCompatibilityFailure(
in ItemPolicyObject source,
in ItemPolicyObject target,
uint playerId)
{ {
if (source.TradeState == 1) 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); uint flags = ItemUseability.TargetFlags(source.Useability);
if (!target.OwnedByPlayer) if (!target.OwnedByPlayer)
@ -330,18 +351,20 @@ public static class ItemInteractionPolicy
if ((least & ItemUseability.Contained) != 0) if ((least & ItemUseability.Contained) != 0)
{ {
if (!(target.Id == playerId && (flags & ItemUseability.Self) != 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) 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) 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( public static ItemPlacementPolicyDecision DecidePlacement(
@ -353,9 +376,10 @@ public static class ItemInteractionPolicy
if (input.TargetId == input.PlayerId) if (input.TargetId == input.PlayerId)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInBackpack, input.Item.Id)); return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInBackpack, input.Item.Id));
if (!input.Item.OwnedByPlayer) 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) 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) if (input.TargetId == 0)
return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false); return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false);
@ -370,7 +394,7 @@ public static class ItemInteractionPolicy
if (input.SplitSize >= input.Item.MaxSplitSize) if (input.SplitSize >= input.Item.MaxSplitSize)
return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.SellToVendor, return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.SellToVendor,
input.Item.Id, target.Id, input.SplitSize)); 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) if (input.DragOnPlayerOpensSecureTrade && target.IsPlayer)
@ -384,16 +408,17 @@ public static class ItemInteractionPolicy
if (target.IsContainer) if (target.IsContainer)
{ {
if ((target.Flags & PublicWeenieFlags.Openable) == 0) 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) 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, return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInContainer,
input.Item.Id, target.Id, input.SplitSize)); input.Item.Id, target.Id, input.SplitSize));
} }
if (input.AllowGroundFallback) if (input.AllowGroundFallback)
return PlaceOnGround(input); 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<ItemPolicyAction> BuildUsingItemActions( private static IReadOnlyList<ItemPolicyAction> BuildUsingItemActions(
@ -436,15 +461,18 @@ public static class ItemInteractionPolicy
in ItemPlacementPolicyInput input) in ItemPlacementPolicyInput input)
{ {
if (!input.PlayerOnGround) 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) if (input.SplitSize < input.Item.MaxSplitSize)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.SplitToWorld, return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.SplitToWorld,
input.Item.Id, Amount: input.SplitSize)); input.Item.Id, Amount: input.SplitSize));
if (!input.Item.IsIn3DView) if (!input.Item.IsIn3DView)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.DropToWorld, input.Item.Id)); 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) private static ItemUsePolicyDecision Consumed(params ItemPolicyAction[] actions)
=> new(true, actions); => new(true, actions);

View file

@ -127,6 +127,27 @@ public sealed class VendorStagingList
return true; return true;
} }
/// <summary>
/// Replaces retail's temporary pre-split sell-row identity with the
/// server-created split stack while preserving the row's position and
/// selected quantity. <c>VendorSellUI::ItemAttributesChanged</c>
/// performs the same in-place substitution after matching the new
/// object's class id and stack size.
/// </summary>
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;
}
/// <summary>Port of the unconditional <c>PackableList&lt;ItemProfile&gt;::Flush</c> calls /// <summary>Port of the unconditional <c>PackableList&lt;ItemProfile&gt;::Flush</c> calls
/// ("Clear List" buttons, and the optimistic post-send clear both Buy All and Sell All /// ("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).</summary> /// perform immediately after their wire send — see the batched-send call sites).</summary>

View file

@ -2269,8 +2269,9 @@ public sealed class MotionInterpreter : IMotionDoneSink
if (PhysicsObj is null) if (PhysicsObj is null)
return false; return false;
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact) const TransientStateFlags groundedMask =
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable); TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
bool grounded = (PhysicsObj.TransientState & groundedMask) == groundedMask;
if (!grounded) if (!grounded)
return false; return false;

View file

@ -74,6 +74,32 @@ public readonly record struct RawMotionAction(
/// </summary> /// </summary>
public sealed class RawMotionState public sealed class RawMotionState
{ {
public RawMotionState()
{
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Retail <c>current_holdkey</c> (ctor default HoldKey_None).</summary> /// <summary>Retail <c>current_holdkey</c> (ctor default HoldKey_None).</summary>
public HoldKey CurrentHoldKey { get; set; } = HoldKey.None; public HoldKey CurrentHoldKey { get; set; } = HoldKey.None;
/// <summary>Retail <c>current_style</c> (ctor default 0x8000003D, NonCombat).</summary> /// <summary>Retail <c>current_style</c> (ctor default 0x8000003D, NonCombat).</summary>

View file

@ -21,7 +21,10 @@ public sealed record LiveChatCommandBindings(
Action<string, string> SendTell, Action<string, string> SendTell,
Action<uint, string> SendChannel, Action<uint, string> SendChannel,
Action<uint, uint, uint, uint, string, uint> SendTurbineChat, Action<uint, uint, uint, uint, string, uint> SendTurbineChat,
Action<string>? Log = null); Action<string>? Log = null,
Func<string, RetailChatPose?>? ResolvePose = null,
Action<uint>? ExecuteMotion = null,
Action<string>? SendSoulEmote = null);
/// <summary> /// <summary>
/// One generation's active binding for the four chat-core records. The route /// One generation's active binding for the four chat-core records. The route
@ -153,7 +156,7 @@ public sealed class LiveChatCommandRoute
switch (command.Channel) switch (command.Channel)
{ {
case ChatChannelKind.Say: case ChatChannelKind.Say:
SendIfActive(() => bindings.SendTalk(command.Text)); RoutePublicChat(bindings, command.Text);
return; return;
case ChatChannelKind.Tell: case ChatChannelKind.Tell:
@ -191,6 +194,25 @@ public sealed class LiveChatCommandRoute
RouteLegacyChannel(bindings, command.Channel, command.Text); 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( private void RouteTurbineChat(
LiveChatCommandBindings bindings, LiveChatCommandBindings bindings,
ChatChannelKindLite kind, ChatChannelKindLite kind,

View file

@ -0,0 +1,76 @@
namespace AcDream.Runtime.Chat;
/// <summary>One DAT-backed <c>ChatPoseTable</c> resolution.</summary>
public readonly record struct RetailChatPose(
uint MotionCommand,
string SelfText,
string OthersText);
/// <summary>
/// Ports <c>ClientCommunicationSystem::PublicChat @ 0x005810F0</c> and
/// <c>RemoveTextBetween @ 0x00580FD0</c>. Valid pose tokens are consumed;
/// unknown or unmatched delimiters remain ordinary speech.
/// </summary>
public static class RetailPublicChatParser
{
public static string ExtractPoses(
string text,
Func<string, RetailChatPose?>? resolve,
Action<RetailChatPose>? 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();
}
}

View file

@ -10,7 +10,8 @@ public readonly record struct RuntimeCombatAttackSnapshot(
float PowerBarLevel, float PowerBarLevel,
bool BuildInProgress, bool BuildInProgress,
bool RequestInProgress, bool RequestInProgress,
float RequestedPower); float RequestedPower,
bool RepeatAttackInProgress = false);
public readonly record struct RuntimeSpellCastSnapshot( public readonly record struct RuntimeSpellCastSnapshot(
long Revision, long Revision,

View file

@ -67,6 +67,8 @@ public enum RuntimeMovementCommand
Sit, Sit,
Crouch, Crouch,
Sleep, Sleep,
StopCompletely,
FinishJump,
} }
public enum RuntimeChatChannel public enum RuntimeChatChannel
@ -160,6 +162,10 @@ public interface IRuntimeMovementCommands
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
RuntimeMovementCommand command); RuntimeMovementCommand command);
RuntimeCommandResult ExecuteMotion(
RuntimeGenerationToken expectedGeneration,
uint motionCommand);
RuntimeCommandResult SetIntent( RuntimeCommandResult SetIntent(
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
in Gameplay.MovementInput input); in Gameplay.MovementInput input);

View file

@ -86,6 +86,10 @@ public readonly record struct RuntimeMovementSnapshot(
public interface IRuntimeMovementView public interface IRuntimeMovementView
{ {
RuntimeMovementSnapshot Snapshot { get; } RuntimeMovementSnapshot Snapshot { get; }
bool IsStandingStill { get; }
Gameplay.JumpChargeSnapshot JumpCharge { get; }
} }
public enum RuntimePortalKind public enum RuntimePortalKind

View file

@ -231,6 +231,9 @@ public sealed class LocalPlayerOutboundController
public static RawMotionState BuildRawMotionState(MovementResult movement) public static RawMotionState BuildRawMotionState(MovementResult movement)
{ {
if (movement.RawMotionStateOverride is { } rawMotionState)
return new RawMotionState(rawMotionState);
HoldKey axisHoldKey = movement.IsRunning ? HoldKey.Run : HoldKey.None; HoldKey axisHoldKey = movement.IsRunning ? HoldKey.Run : HoldKey.None;
return new RawMotionState return new RawMotionState
{ {

View file

@ -110,7 +110,11 @@ public readonly record struct MovementResult(
// MovementManager's complete RawMotionState into MoveToStatePack. An // MovementManager's complete RawMotionState into MoveToStatePack. An
// absent style bit unpacks as NonCombat, so the canonical raw style must // absent style bit unpacks as NonCombat, so the canonical raw style must
// travel with every input-boundary snapshot sent to ACE. // 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);
/// <summary> /// <summary>
/// Portal-space state for the player movement controller. /// Portal-space state for the player movement controller.
@ -530,6 +534,25 @@ public sealed class PlayerMovementController
/// </summary> /// </summary>
public JumpChargeSnapshot JumpCharge public JumpChargeSnapshot JumpCharge
=> new(_jumpCharging, _jumpCharging ? _jumpExtent : 0f); => new(_jumpCharging, _jumpCharging ? _jumpExtent : 0f);
/// <summary>
/// Retail <c>CommandInterpreter::IsStandingStill</c>: the exact motion-
/// interpreter predicate consumed by Escape before it reaches selection
/// or the Gameplay Options fallback.
/// </summary>
internal bool IsStandingStill => _motion.IsStandingStill();
/// <summary>
/// Retail <c>ClientCombatSystem::FinishJump</c> (0x0056A9B0): end an
/// in-progress jump power build without executing the jump and clear the
/// standing-long-jump arm on the motion interpreter.
/// </summary>
internal void FinishJump()
{
_jumpCharging = false;
_jumpExtent = 0f;
_motion.StandingLongJump = false;
}
// Matching v11.4186 x86 resolves GetPowerBarLevel's collapsed x87 // Matching v11.4186 x86 resolves GetPowerBarLevel's collapsed x87
// operands: ATTACK_POWERUP_TIME=1.0 s, DUAL_WIELD_POWERUP_TIME=0.8 s. // 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 // 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 private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame
_positionManagerDeltaScratch = new(); _positionManagerDeltaScratch = new();
private bool _externalMovementEventPending; private bool _externalMovementEventPending;
private RawMotionState? _externalRawMotionStatePending;
private uint _localActionStamp;
// ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ── // ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ──
// The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase, // The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase,
@ -1441,6 +1466,34 @@ public sealed class PlayerMovementController
return true; return true;
} }
/// <summary>
/// Retail <c>ACCmdInterp::SetMotion</c> (<c>0x0058B310</c>) with
/// start=true: submit one raw command through the local physics-object
/// boundary and publish the resulting movement edge on the next turn.
/// </summary>
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) public void SetCharacterSkills(int runSkill, int jumpSkill)
{ {
EnsureConfigurationMutable(); EnsureConfigurationMutable();
@ -2452,6 +2505,9 @@ public sealed class PlayerMovementController
bool externallyRequestedMovementEvent = bool externallyRequestedMovementEvent =
_externalMovementEventPending; _externalMovementEventPending;
_externalMovementEventPending = false; _externalMovementEventPending = false;
RawMotionState? externalRawMotionState =
_externalRawMotionStatePending;
_externalRawMotionStatePending = null;
bool motionEdgeFired = false; bool motionEdgeFired = false;
bool movementEventRequested = bool movementEventRequested =
externallyRequestedMovementEvent; externallyRequestedMovementEvent;
@ -3189,7 +3245,8 @@ public sealed class PlayerMovementController
SidestepUsesRunHold: _activeInputSidestepUsesRunHold SidestepUsesRunHold: _activeInputSidestepUsesRunHold
&& outSidestepCmd.HasValue, && outSidestepCmd.HasValue,
IsMouseLookMovementEvent: mouseMovementEventDue, IsMouseLookMovementEvent: mouseMovementEventDue,
CurrentStyle: _motion.RawState.CurrentStyle); CurrentStyle: _motion.RawState.CurrentStyle,
RawMotionStateOverride: externalRawMotionState);
} }
/// <summary> /// <summary>

View file

@ -253,7 +253,8 @@ public sealed class RuntimeActionState : IDisposable
owner.CombatAttack.PowerBarLevel, owner.CombatAttack.PowerBarLevel,
owner.CombatAttack.BuildInProgress, owner.CombatAttack.BuildInProgress,
owner.CombatAttack.AttackRequestInProgress, owner.CombatAttack.AttackRequestInProgress,
owner.CombatAttack.RequestedAttackPower), owner.CombatAttack.RequestedAttackPower,
owner.CombatAttack.RepeatAttackInProgress),
new RuntimeSpellCastSnapshot( new RuntimeSpellCastSnapshot(
Interlocked.Read(ref owner._magicIntentRevision), Interlocked.Read(ref owner._magicIntentRevision),
owner.SpellCast.LastRequestedSpellId ?? 0u, owner.SpellCast.LastRequestedSpellId ?? 0u,

View file

@ -152,6 +152,7 @@ public sealed class RuntimeCombatAttackState : IDisposable
public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium; public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium;
public float DesiredPower { get; private set; } = InitialDesiredPower; public float DesiredPower { get; private set; } = InitialDesiredPower;
public bool AttackRequestInProgress => _attackRequestInProgress; public bool AttackRequestInProgress => _attackRequestInProgress;
public bool RepeatAttackInProgress => _repeatAttacking;
public float RequestedAttackPower => _requestedAttackPower; public float RequestedAttackPower => _requestedAttackPower;
public bool BuildInProgress => _buildInProgress; public bool BuildInProgress => _buildInProgress;
public bool IsDisposed => _disposed; public bool IsDisposed => _disposed;

View file

@ -16,6 +16,7 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
int ShortcutSubscriberCount, int ShortcutSubscriberCount,
long ShortcutDispatchFailureCount, long ShortcutDispatchFailureCount,
long TransactionDispatchFailureCount, long TransactionDispatchFailureCount,
int OpenedCorpseCount,
// Slice 5.3: the sole open vendor shop id, 0 when no session is open. // Slice 5.3: the sole open vendor shop id, 0 when no session is open.
uint VendorId, uint VendorId,
// Slice 6.1: guids VendorShopItemMaterializer currently owns in // Slice 6.1: guids VendorShopItemMaterializer currently owns in
@ -36,6 +37,7 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
&& ItemManaCount == 0 && ItemManaCount == 0
&& ShortcutCount == 0 && ShortcutCount == 0
&& ShortcutSubscriberCount == 0 && ShortcutSubscriberCount == 0
&& OpenedCorpseCount == 0
&& VendorId == 0u && VendorId == 0u
&& MaterializedVendorItemCount == 0; && MaterializedVendorItemCount == 0;
} }
@ -55,6 +57,7 @@ public sealed class RuntimeInventoryState : IDisposable
_entityObjects = entityObjects _entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects)); ?? throw new ArgumentNullException(nameof(entityObjects));
ExternalContainers = new ExternalContainerState(); ExternalContainers = new ExternalContainerState();
_entityObjects.Objects.ObjectRemoved += OnObjectRemoved;
ItemMana = new ItemManaState(); ItemMana = new ItemManaState();
Shortcuts = new ShortcutStore(); Shortcuts = new ShortcutStore();
Transactions = new InventoryTransactionState(_entityObjects.Objects); Transactions = new InventoryTransactionState(_entityObjects.Objects);
@ -98,6 +101,7 @@ public sealed class RuntimeInventoryState : IDisposable
Shortcuts.SubscriberCount, Shortcuts.SubscriberCount,
Shortcuts.DispatchFailureCount, Shortcuts.DispatchFailureCount,
Transactions.DispatchFailureCount, Transactions.DispatchFailureCount,
ExternalContainers.OpenedCorpseCount,
Vendor.VendorId, Vendor.VendorId,
VendorItems.OwnedCount); VendorItems.OwnedCount);
@ -170,6 +174,7 @@ public sealed class RuntimeInventoryState : IDisposable
List<Exception>? failures = null; List<Exception>? failures = null;
try try
{ {
_entityObjects.Objects.ObjectRemoved -= OnObjectRemoved;
Try(() => ExternalContainers.Reset(), ref failures); Try(() => ExternalContainers.Reset(), ref failures);
// Vendor.Reset() must run BEFORE VendorItems.Dispose() — // Vendor.Reset() must run BEFORE VendorItems.Dispose() —
// Reset() fires Changed synchronously, which is what drives the // 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) private sealed class InventoryStateView(RuntimeInventoryState owner)
: IRuntimeInventoryStateView : IRuntimeInventoryStateView
{ {

View file

@ -172,6 +172,8 @@ public sealed class RuntimeLocalPlayerMovementState
public long Revision => Interlocked.Read(ref _revision); public long Revision => Interlocked.Read(ref _revision);
public ulong ControllerOwnershipEpoch { get; private set; } public ulong ControllerOwnershipEpoch { get; private set; }
public IRuntimeMovementView View => this; public IRuntimeMovementView View => this;
public bool IsStandingStill => _controller?.IsStandingStill ?? true;
public JumpChargeSnapshot JumpCharge => _controller?.JumpCharge ?? default;
internal RuntimeLocalPlayerPhysicsPublicationState PhysicsPublication => internal RuntimeLocalPlayerPhysicsPublicationState PhysicsPublication =>
_physicsPublication ?? throw new InvalidOperationException( _physicsPublication ?? throw new InvalidOperationException(
@ -246,6 +248,16 @@ public sealed class RuntimeLocalPlayerMovementState
CancelAutoRun(); CancelAutoRun();
ClearCommandInput(); ClearCommandInput();
return true; 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.Ready:
case RuntimeMovementCommand.Sit: case RuntimeMovementCommand.Sit:
case RuntimeMovementCommand.Crouch: case RuntimeMovementCommand.Crouch:
@ -270,6 +282,17 @@ public sealed class RuntimeLocalPlayerMovementState
} }
} }
/// <summary>
/// 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.
/// </summary>
public bool ExecuteMotion(uint motionCommand)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _controller?.RequestCommandMotion(motionCommand) == true;
}
public bool CancelAutoRun() public bool CancelAutoRun()
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);

View file

@ -403,6 +403,25 @@ public sealed class DirectGameRuntimeCommandAdapter
status); 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( public RuntimeCommandResult SetIntent(
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
in MovementInput input) in MovementInput input)

View file

@ -8,10 +8,9 @@ namespace AcDream.UI.Abstractions.Input;
/// debug bindings that have no retail equivalent. /// debug bindings that have no retail equivalent.
/// ///
/// <para> /// <para>
/// K.1a defined the enum and K.1c flipped the bindings table to the full /// The installed Sept-2013 ActionMap's 306 user-bindable rows each have one
/// retail preset. Runtime controllers subscribe by subsystem; actions whose /// distinct enum identity and one live subsystem consumer. Low, non-bindable
/// owning panel has not landed yet (for example <c>UseSpellSlot_*</c>) may /// MasterInputMap commands remain separate infrastructure actions.
/// intentionally remain undispatched.
/// </para> /// </para>
/// </summary> /// </summary>
public enum InputAction public enum InputAction
@ -92,7 +91,10 @@ public enum InputAction
// ── UICommands ──────────────────────────────────────── // ── UICommands ────────────────────────────────────────
/// <summary>Use the selected item / interact (retail R).</summary> /// <summary>Use the selected item / interact (retail R).</summary>
UseSelected, UseSelected,
/// <summary>Cancel the topmost UI / clear selection / open log-out menu.</summary> /// <summary>
/// Retail Escape priority: cancel focused UI/targeting/movement, clear
/// selection, then toggle the Gameplay Options page.
/// </summary>
EscapeKey, EscapeKey,
/// <summary>Log out of the game (retail Shift+Esc).</summary> /// <summary>Log out of the game (retail Shift+Esc).</summary>
LOGOUT, LOGOUT,
@ -169,7 +171,7 @@ public enum InputAction
// ── Combat ──────────────────────────────────────────── // ── Combat ────────────────────────────────────────────
/// <summary>Toggle combat-stance on/off (retail Grave / `).</summary> /// <summary>Toggle combat-stance on/off (retail Grave / `).</summary>
CombatToggleCombat, CombatToggleCombat,
// Mode-dependent (dormant in K — Phase L lights them up) // Mode-dependent retail combat actions.
CombatDecreaseAttackPower, CombatDecreaseAttackPower,
CombatIncreaseAttackPower, CombatIncreaseAttackPower,
CombatLowAttack, CombatLowAttack,
@ -267,7 +269,8 @@ public enum InputAction
AcdreamToggleAudioMute, AcdreamToggleAudioMute,
/// <summary>F (existing) toggles between fly camera and orbit/chase mode.</summary> /// <summary>F (existing) toggles between fly camera and orbit/chase mode.</summary>
AcdreamToggleFlyMode, AcdreamToggleFlyMode,
/// <summary>Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c).</summary> /// <summary>Legacy acdream player-mode toggle. Intentionally unbound;
/// retail Tab is <see cref="InputAction.ToggleChatEntry"/>.</summary>
AcdreamTogglePlayerMode, AcdreamTogglePlayerMode,
/// <summary>Hold-RMB chase-camera orbit (debug-only, not user-rebindable). /// <summary>Hold-RMB chase-camera orbit (debug-only, not user-rebindable).
/// Camera orbits around the player while held; never drives character yaw.</summary> /// Camera orbits around the player while held; never drives character yaw.</summary>
@ -285,4 +288,201 @@ public enum InputAction
CameraRaise, CameraRaise,
/// <summary>Camera lower (held key, integrates Pitch= adjSpeed·dt·0.02). Default unbound.</summary> /// <summary>Camera lower (held key, integrates Pitch= adjSpeed·dt·0.02). Default unbound.</summary>
CameraLower, 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,
} }

View file

@ -22,12 +22,9 @@ namespace AcDream.UI.Abstractions.Input;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// K.1a wiring: <c>GameWindow</c> constructs a dispatcher alongside the /// The production gameplay router is the sole gameplay subscriber; retained
/// existing <c>IsKeyPressed</c> + event-handler paths. Nothing /// UI, selection, camera, combat, movement, and commands all receive semantic
/// subscribes to <see cref="Fired"/> yet except a diagnostic console /// actions through this stream.
/// logger — the dispatcher is observable but doesn't drive any
/// behavior. K.1b cuts the existing handlers over to the dispatcher's
/// action stream.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class InputDispatcher : IDisposable public sealed class InputDispatcher : IDisposable
@ -38,6 +35,7 @@ public sealed class InputDispatcher : IDisposable
private KeyBindings _bindings; private KeyBindings _bindings;
private readonly Stack<InputScope> _scopes = new(); private readonly Stack<InputScope> _scopes = new();
private InputScope? _combatScope; private InputScope? _combatScope;
private bool _cameraAlternateScope;
private readonly HashSet<KeyChord> _heldHoldChords = new(); private readonly HashSet<KeyChord> _heldHoldChords = new();
private readonly HashSet<InputAction> _automationHeldActions = new(); private readonly HashSet<InputAction> _automationHeldActions = new();
private readonly Dictionary<MouseButton, float> _mouseClickTravel = new(); private readonly Dictionary<MouseButton, float> _mouseClickTravel = new();
@ -55,16 +53,28 @@ public sealed class InputDispatcher : IDisposable
private const long DoubleClickThresholdMs = 500; private const long DoubleClickThresholdMs = 500;
private const float ClickDragThresholdPixels = 3f; private const float ClickDragThresholdPixels = 3f;
/// <summary>K.3 modal-rebind hook: when non-null, the next non-modifier /// <summary>K.3 modal-rebind hook: when non-null, the next complete key or
/// chord is reported via this callback INSTEAD of firing actions. Esc /// mouse chord is reported via this callback INSTEAD of firing actions.
/// cancels (callback receives <c>default(KeyChord)</c>).</summary> /// A modifier key is deferred until release so it can be captured alone or
/// used as a prefix. Esc cancels (callback receives
/// <c>default(KeyChord)</c>).</summary>
private Action<KeyChord>? _captureCallback; private Action<KeyChord>? _captureCallback;
private Key? _captureModifierCandidate;
private KeyChord? _currentPhysicalChord;
/// <summary>Fires every time a binding matches a press, release, hold, /// <summary>Fires every time a binding matches a press, release, hold,
/// complete click, or double-click. /// complete click, or double-click.
/// Multicast — every subscriber gets every event in subscription order.</summary> /// Multicast — every subscriber gets every event in subscription order.</summary>
public event Action<InputAction, ActivationType>? Fired; public event Action<InputAction, ActivationType>? Fired;
/// <summary>
/// The keyboard chord whose native key-down callback is synchronously
/// publishing <see cref="Fired"/>, or <see langword="null"/> outside that
/// callback. This lets retained UI suppress the raw tail of the same key
/// after a semantic action has just moved keyboard focus.
/// </summary>
public KeyChord? CurrentPhysicalChord => _currentPhysicalChord;
private InputDispatcher( private InputDispatcher(
IKeyboardSource keyboard, IKeyboardSource keyboard,
IMouseSource mouse, IMouseSource mouse,
@ -158,9 +168,11 @@ public sealed class InputDispatcher : IDisposable
{ {
Interlocked.Exchange(ref _active, 0); Interlocked.Exchange(ref _active, 0);
_captureCallback = null; _captureCallback = null;
_captureModifierCandidate = null;
_heldHoldChords.Clear(); _heldHoldChords.Clear();
_automationHeldActions.Clear(); _automationHeldActions.Clear();
_mouseClickTravel.Clear(); _mouseClickTravel.Clear();
_cameraAlternateScope = false;
} }
public void Dispose() public void Dispose()
@ -226,10 +238,25 @@ public sealed class InputDispatcher : IDisposable
} }
/// <summary>Topmost scope on the stack — what the dispatcher looks up first.</summary> /// <summary>Topmost scope on the stack — what the dispatcher looks up first.</summary>
public InputScope ActiveScope => _scopes.Peek() == InputScope.Game && _combatScope is { } combat public InputScope ActiveScope => _cameraAlternateScope
? InputScope.Camera
: _scopes.Peek() == InputScope.Game && _combatScope is { } combat
? combat ? combat
: _scopes.Peek(); : _scopes.Peek();
/// <summary>
/// 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.
/// </summary>
public void SetCameraAlternateScope(bool active)
{
if (_cameraAlternateScope == active) return;
ReleaseHeldHoldBindings();
_cameraAlternateScope = active;
}
/// <summary>Set the mode-dependent combat layer that shadows normal game chords.</summary> /// <summary>Set the mode-dependent combat layer that shadows normal game chords.</summary>
public void SetCombatScope(InputScope? scope) public void SetCombatScope(InputScope? scope)
{ {
@ -243,34 +270,72 @@ public sealed class InputDispatcher : IDisposable
private Binding? FindActive(KeyChord chord, ActivationType activation) private Binding? FindActive(KeyChord chord, ActivationType activation)
{ {
IReadOnlyList<Binding> bindings = FindActiveBindings(chord, activation);
return bindings.Count == 0 ? null : bindings[0];
}
/// <summary>
/// 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.
/// </summary>
private IReadOnlyList<Binding> 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) foreach (InputScope scope in _scopes)
{ {
if (scope == InputScope.Game && _combatScope is { } combat if (scope == InputScope.Game && _combatScope is { } combat)
&& _bindings.Find(chord, activation, combat) is { } combatBinding) {
return combatBinding; Binding[] combatBindings = FindInScope(combat, chord, activation);
if (_bindings.Find(chord, activation, scope) is { } binding) if (combatBindings.Length != 0)
return binding; return combatBindings;
} }
return null;
Binding[] bindings = FindInScope(scope, chord, activation);
if (bindings.Length != 0)
return bindings;
} }
return Array.Empty<Binding>();
}
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();
/// <summary>True iff a <see cref="BeginCapture"/> is in progress.</summary> /// <summary>True iff a <see cref="BeginCapture"/> is in progress.</summary>
public bool IsCapturing => _captureCallback is not null; public bool IsCapturing => _captureCallback is not null;
/// <summary> /// <summary>
/// Enter modal capture mode. The next non-modifier chord pressed /// Enter modal capture mode. The next keyboard key or mouse button
/// (with whatever modifiers are held at that moment) is reported /// (with whatever modifiers are held at that moment) is reported via
/// via <paramref name="onCaptured"/> and the dispatcher does NOT /// <paramref name="onCaptured"/> and the dispatcher does NOT fire normal
/// fire normal action events for that chord. Esc cancels — /// actions for that chord. Shift/Ctrl/Alt/Win are deferred until key-up:
/// <paramref name="onCaptured"/> receives a sentinel /// pressing another key first makes them a prefix; releasing the modifier
/// <c>default(KeyChord)</c>. Modifier-only key transitions /// first captures the modifier-only binding. Esc cancels and reports
/// (Shift / Ctrl / Alt / Win held alone) are NOT captured; only a /// <c>default(KeyChord)</c>.
/// non-modifier key down completes capture, so the user can dial
/// in modifier combinations before pressing the trigger key.
/// </summary> /// </summary>
public void BeginCapture(Action<KeyChord> onCaptured) public void BeginCapture(Action<KeyChord> onCaptured)
{ {
_captureCallback = onCaptured ?? throw new ArgumentNullException(nameof(onCaptured)); _captureCallback = onCaptured ?? throw new ArgumentNullException(nameof(onCaptured));
_captureModifierCandidate = null;
} }
/// <summary> /// <summary>
@ -283,6 +348,7 @@ public sealed class InputDispatcher : IDisposable
var cb = _captureCallback; var cb = _captureCallback;
if (cb is null) return; if (cb is null) return;
_captureCallback = null; _captureCallback = null;
_captureModifierCandidate = null;
cb(default); cb(default);
} }
@ -440,8 +506,7 @@ public sealed class InputDispatcher : IDisposable
if (_heldHoldChords.Count == 0) return; if (_heldHoldChords.Count == 0) return;
var releases = new List<Binding>(_heldHoldChords.Count); var releases = new List<Binding>(_heldHoldChords.Count);
foreach (KeyChord chord in _heldHoldChords) foreach (KeyChord chord in _heldHoldChords)
if (FindActive(chord, ActivationType.Hold) is { } binding) releases.AddRange(FindActiveBindings(chord, ActivationType.Hold));
releases.Add(binding);
_heldHoldChords.Clear(); _heldHoldChords.Clear();
foreach (Binding binding in releases) foreach (Binding binding in releases)
Fired?.Invoke(binding.Action, ActivationType.Release); Fired?.Invoke(binding.Action, ActivationType.Release);
@ -469,9 +534,8 @@ public sealed class InputDispatcher : IDisposable
// chord; never dispatch a stale snapshot entry afterward. // chord; never dispatch a stale snapshot entry afterward.
if (!_heldHoldChords.Contains(chord)) if (!_heldHoldChords.Contains(chord))
continue; continue;
var hold = FindActive(chord, ActivationType.Hold); foreach (Binding hold in FindActiveBindings(chord, ActivationType.Hold))
if (hold is not null) Fired?.Invoke(hold.Action, ActivationType.Hold);
Fired?.Invoke(hold.Value.Action, ActivationType.Hold);
} }
} }
@ -480,50 +544,62 @@ public sealed class InputDispatcher : IDisposable
if (Volatile.Read(ref _active) == 0) return; if (Volatile.Read(ref _active) == 0) return;
// K.3 modal capture (used by Settings panel's "Rebind" UX) takes // K.3 modal capture (used by Settings panel's "Rebind" UX) takes
// precedence over both WantCaptureKeyboard gating AND normal // precedence over both WantCaptureKeyboard gating AND normal
// binding lookup. Esc cancels capture; modifier-only keys don't // binding lookup. Esc cancels capture. A modifier key is deferred
// complete it (so the user can dial in Shift/Ctrl/Alt before // until its key-up so it can either become the primary binding by
// pressing the trigger key); every other key completes capture // itself (retail's walk-mode default) or remain a prefix when the
// with the current modifier state. // user presses a non-modifier key before releasing it.
if (_captureCallback is not null) if (_captureCallback is not null)
{ {
if (key == Key.Escape) if (key == Key.Escape)
{ {
var cb = _captureCallback; var cb = _captureCallback;
_captureCallback = null; _captureCallback = null;
_captureModifierCandidate = null;
cb(default); cb(default);
return; 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 captured = new KeyChord(key, mods, Device: 0);
var cb2 = _captureCallback; var cb2 = _captureCallback;
_captureCallback = null; _captureCallback = null;
_captureModifierCandidate = null;
cb2(captured); cb2(captured);
return; // SUPPRESS the action — don't run binding lookup below return; // SUPPRESS the action — don't run binding lookup below
} }
if (_mouse.WantCaptureKeyboard) return; if (_mouse.WantCaptureKeyboard) return;
var chord = new KeyChord(key, mods, Device: 0); var chord = KeyboardChord(key, mods);
_currentPhysicalChord = chord;
try
{
foreach (Binding press in FindActiveBindings(chord, ActivationType.Press))
Fired?.Invoke(press.Action, ActivationType.Press);
var press = FindActive(chord, ActivationType.Press); foreach (Binding click in FindActiveBindings(chord, ActivationType.Click))
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press); Fired?.Invoke(click.Action, ActivationType.Click);
var click = FindActive(chord, ActivationType.Click); IReadOnlyList<Binding> holds = FindActiveBindings(chord, ActivationType.Hold);
if (click is not null) Fired?.Invoke(click.Value.Action, ActivationType.Click); if (holds.Count != 0)
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
{ {
// Emit a Press transition so subscribers can latch state, then // Emit a Press transition so subscribers can latch state, then
// record the chord so Tick() will re-fire Hold every frame. // record the chord so Tick() will re-fire Hold every frame.
Fired?.Invoke(hold.Value.Action, ActivationType.Press); foreach (Binding hold in holds)
Fired?.Invoke(hold.Action, ActivationType.Press);
_heldHoldChords.Add(chord); _heldHoldChords.Add(chord);
} }
} }
finally
{
_currentPhysicalChord = null;
}
}
/// <summary>True for Shift/Ctrl/Alt/Win left+right variants — keys /// <summary>True for Shift/Ctrl/Alt/Win left+right variants.</summary>
/// that don't complete a capture by themselves. The user holds them
/// to dial in modifier combinations before pressing the trigger key.</summary>
private static bool IsModifierKey(Key key) => key switch private static bool IsModifierKey(Key key) => key switch
{ {
Key.ShiftLeft or Key.ShiftRight => true, Key.ShiftLeft or Key.ShiftRight => true,
@ -536,13 +612,24 @@ public sealed class InputDispatcher : IDisposable
private void OnKeyUp(Key key, ModifierMask mods) private void OnKeyUp(Key key, ModifierMask mods)
{ {
if (Volatile.Read(ref _active) == 0) return; if (Volatile.Read(ref _active) == 0) return;
if (_captureCallback is not null)
{
if (_captureModifierCandidate == key)
{
Action<KeyChord> callback = _captureCallback;
_captureCallback = null;
_captureModifierCandidate = null;
callback(KeyboardChord(key, mods));
}
return;
}
// Release fires regardless of WantCaptureKeyboard so we don't // Release fires regardless of WantCaptureKeyboard so we don't
// strand a Hold subscriber in the "held" state if the UI captured // strand a Hold subscriber in the "held" state if the UI captured
// mid-press. // mid-press.
var chord = new KeyChord(key, mods, Device: 0); var chord = KeyboardChord(key, mods);
var release = FindActive(chord, ActivationType.Release); foreach (Binding release in FindActiveBindings(chord, ActivationType.Release))
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release); Fired?.Invoke(release.Action, ActivationType.Release);
// Any matching Hold binding gets a Release transition. Walk the // Any matching Hold binding gets a Release transition. Walk the
// tracked set looking for a chord with a matching Key (ignoring // 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) foreach (var held in toRemove)
{ {
_heldHoldChords.Remove(held); _heldHoldChords.Remove(held);
var hold = FindActive(held, ActivationType.Hold); foreach (Binding hold in FindActiveBindings(held, ActivationType.Hold))
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release); Fired?.Invoke(hold.Action, ActivationType.Release);
} }
} }
@ -565,16 +652,33 @@ public sealed class InputDispatcher : IDisposable
{ {
if (Volatile.Read(ref _active) == 0) return; if (Volatile.Read(ref _active) == 0) return;
_mouseClickTravel.Remove(button); _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<KeyChord> callback = _captureCallback;
_captureCallback = null;
_captureModifierCandidate = null;
callback(captured);
return;
}
if (_mouse.WantCaptureMouse) return; if (_mouse.WantCaptureMouse) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var press = FindActive(chord, ActivationType.Press); foreach (Binding press in FindActiveBindings(chord, ActivationType.Press))
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press); Fired?.Invoke(press.Action, ActivationType.Press);
var hold = FindActive(chord, ActivationType.Hold); IReadOnlyList<Binding> holds = FindActiveBindings(chord, ActivationType.Hold);
if (hold is not null) 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); _heldHoldChords.Add(chord);
} }
@ -589,8 +693,8 @@ public sealed class InputDispatcher : IDisposable
if (_lastMouseDownButton == button if (_lastMouseDownButton == button
&& nowMs - _lastMouseDownTickMs <= DoubleClickThresholdMs) && nowMs - _lastMouseDownTickMs <= DoubleClickThresholdMs)
{ {
var dbl = FindActive(chord, ActivationType.DoubleClick); foreach (Binding dbl in FindActiveBindings(chord, ActivationType.DoubleClick))
if (dbl is not null) Fired?.Invoke(dbl.Value.Action, ActivationType.DoubleClick); Fired?.Invoke(dbl.Action, ActivationType.DoubleClick);
_lastMouseDownButton = null; // consumed; require fresh pair for next _lastMouseDownButton = null; // consumed; require fresh pair for next
} }
else else
@ -606,8 +710,8 @@ public sealed class InputDispatcher : IDisposable
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
bool wasClickCandidate = _mouseClickTravel.Remove(button, out float travel); bool wasClickCandidate = _mouseClickTravel.Remove(button, out float travel);
var release = FindActive(chord, ActivationType.Release); foreach (Binding release in FindActiveBindings(chord, ActivationType.Release))
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release); Fired?.Invoke(release.Action, ActivationType.Release);
var keyForLookup = MouseButtonToKey(button); var keyForLookup = MouseButtonToKey(button);
var toRemove = new List<KeyChord>(); var toRemove = new List<KeyChord>();
@ -619,15 +723,16 @@ public sealed class InputDispatcher : IDisposable
foreach (var held in toRemove) foreach (var held in toRemove)
{ {
_heldHoldChords.Remove(held); _heldHoldChords.Remove(held);
var hold = FindActive(held, ActivationType.Hold); foreach (Binding hold in FindActiveBindings(held, ActivationType.Hold))
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release); Fired?.Invoke(hold.Action, ActivationType.Release);
} }
if (wasClickCandidate if (wasClickCandidate
&& !_mouse.WantCaptureMouse && !_mouse.WantCaptureMouse
&& travel <= ClickDragThresholdPixels && travel <= ClickDragThresholdPixels
&& FindActive(chord, ActivationType.Click) is { } click) && FindActiveBindings(chord, ActivationType.Click) is { Count: > 0 } clicks)
{ {
foreach (Binding click in clicks)
Fired?.Invoke(click.Action, ActivationType.Click); Fired?.Invoke(click.Action, ActivationType.Click);
} }
} }
@ -683,6 +788,25 @@ public sealed class InputDispatcher : IDisposable
_ => (Key)(-1000 - (int)button), _ => (Key)(-1000 - (int)button),
}; };
/// <summary>
/// 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.
/// </summary>
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<Exception> DetachSources() private List<Exception> DetachSources()
{ {
var failures = new List<Exception>(); var failures = new List<Exception>();

View file

@ -8,11 +8,8 @@ namespace AcDream.UI.Abstractions.Input;
/// sits at the bottom of the stack and catches global chords like /// sits at the bottom of the stack and catches global chords like
/// Esc / F1 that should fire regardless of focus. /// Esc / F1 that should fire regardless of focus.
/// ///
/// <para> /// <para>Combat scope follows the live retail combat mode; modal/edit/chat
/// K.1a defines the enum but only pushes <see cref="Always"/> + /// scopes are pushed above it as their authored surfaces activate.</para>
/// <see cref="Game"/> by default. Combat scopes light up in Phase L
/// when <c>CombatState.CurrentMode</c> tracking lands.
/// </para>
/// </summary> /// </summary>
public enum InputScope public enum InputScope
{ {
@ -30,13 +27,13 @@ public enum InputScope
/// <summary>A modal dialog is open and capturing input.</summary> /// <summary>A modal dialog is open and capturing input.</summary>
Dialog, Dialog,
/// <summary>Combat with melee weapon equipped — Insert/PgUp/Delete/End/PgDn /// <summary>Combat with melee weapon equipped — Insert/PgUp/Delete/End/PgDn
/// remap to power + attack-level. Dormant until Phase L.</summary> /// remap to power + attack-level.</summary>
MeleeCombat, MeleeCombat,
/// <summary>Combat with missile weapon equipped — Insert/PgUp/Delete/End/PgDn /// <summary>Combat with missile weapon equipped — Insert/PgUp/Delete/End/PgDn
/// remap to accuracy + aim-level. Dormant until Phase L.</summary> /// remap to accuracy + aim-level.</summary>
MissileCombat, MissileCombat,
/// <summary>Magic mode — 1-9 cast <c>UseSpellSlot</c>; Insert/PgUp etc. /// <summary>Magic mode — 1-9 cast <c>UseSpellSlot</c>; Insert/PgUp etc.
/// page through spell tabs. Dormant until Phase L.</summary> /// page through spell tabs.</summary>
MagicCombat, MagicCombat,
/// <summary>Camera alternate mode (F2 / Numpad-/) — arrow keys rotate /// <summary>Camera alternate mode (F2 / Numpad-/) — arrow keys rotate
/// the camera instead of the character.</summary> /// the camera instead of the character.</summary>

View file

@ -10,9 +10,9 @@ namespace AcDream.UI.Abstractions.Input;
/// <summary> /// <summary>
/// Mutable collection of <see cref="Binding"/>s. Owns lookup by chord /// Mutable collection of <see cref="Binding"/>s. Owns lookup by chord
/// (for the dispatcher) and lookup by action (for the Settings UI). /// (for the dispatcher) and lookup by action (for the Settings UI).
/// Insertion-order preserved — first-match-wins on lookup, so a user /// Insertion order is preserved. Direct <see cref="Find(KeyChord, ActivationType)"/>
/// can add a custom binding ahead of a default and have it take effect /// queries return the first match; <see cref="InputDispatcher"/> emits every
/// without removing the default. /// distinct action in the highest-priority active retail input map.
/// ///
/// <para> /// <para>
/// K.1c: <see cref="RetailDefaults"/> now returns the full retail-faithful /// K.1c: <see cref="RetailDefaults"/> now returns the full retail-faithful
@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
/// </summary> /// </summary>
public sealed class KeyBindings public sealed class KeyBindings
{ {
private const int CurrentSchemaVersion = 5; private const int CurrentSchemaVersion = 7;
private readonly List<Binding> _bindings = new(); private readonly List<Binding> _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.C, ModifierMask.None), InputAction.MovementStrafeRight));
b.Add(new(new KeyChord(Key.D, ModifierMask.Alt), InputAction.MovementStrafeRight)); b.Add(new(new KeyChord(Key.D, ModifierMask.Alt), InputAction.MovementStrafeRight));
b.Add(new(new KeyChord(Key.Right, 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 // Retail authors exactly bare DIK_LSHIFT. InputDispatcher normalizes
// press and unlatch on release. K-fix1 (2026-04-26): the chord // Silk's self-reported Shift modifier bit at the physical boundary.
// modifier MUST be Shift, not None — when LShift/RShift is the b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.None), InputAction.MovementWalkMode, ActivationType.Hold));
// 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));
b.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementRunLock)); 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.S, ModifierMask.None), InputAction.MovementStop));
b.Add(new(new KeyChord(Key.Y, ModifierMask.None), InputAction.Ready)); 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)); b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump));
// ── ItemSelectionCommands ────────────────────────────── // ── 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.T, ModifierMask.None), InputAction.SelectionSplitStack));
b.Add(new(new KeyChord(Key.P, ModifierMask.None), InputAction.SelectionPreviousSelection)); b.Add(new(new KeyChord(Key.P, ModifierMask.None), InputAction.SelectionPreviousSelection));
b.Add(new(new KeyChord(Key.Backspace, ModifierMask.None), InputAction.SelectionClosestCompassItem)); 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)); b.Add(new(new KeyChord(Key.Escape, ModifierMask.Shift), InputAction.LOGOUT));
// ── QuickslotCommands ────────────────────────────────── // ── QuickslotCommands ──────────────────────────────────
// Retail gmToolbarUI::ListenToGlobalMessage @ 0x004BE4E0 receives // Retail's MasterInputMap binds both bare N and Ctrl+N to the SAME
// distinct action-id ranges: bare 1..9 USE slots 0..8, while // UseQuickSlot_N action ids (0x10000042..4A). The separate Select
// Ctrl+1..9 SELECT those slots. The keymap repeats the display name // Quickslot action ids (0x1000004E..56) have no default chords.
// UseQuickSlot_N for both bindings, so our semantic action layer must
// preserve the differing intent explicitly.
for (int i = 1; i <= 9; i++) for (int i = 1; i <= 9; i++)
{ {
var k = (Key)((int)Key.Number0 + i); // Number1..Number9 var k = (Key)((int)Key.Number0 + i); // Number1..Number9
var useAction = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1); 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.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++) for (int i = 5; i <= 9; i++)
{ {
var k = (Key)((int)Key.Number0 + 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.Tab, ModifierMask.None), InputAction.ToggleChatEntry));
b.Add(new(new KeyChord(Key.Enter, ModifierMask.None), InputAction.EnterChatMode)); 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)); b.Add(new(new KeyChord(Key.GraveAccent, ModifierMask.None), InputAction.CombatToggleCombat));
// Melee mode (active when MeleeCombat scope pushed). // Melee mode (active when MeleeCombat scope pushed).
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower, Scope: InputScope.MeleeCombat)); 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.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.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold, InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, 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 // Missile + Magic + Spell-tab — same chords; resolved by the live
// runtime per InputDispatcher's stack lookup. Add the bindings; // combat scope through InputDispatcher's stack lookup.
// subscribers arrive in Phase L when CombatState.CurrentMode is
// wired.
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy, Scope: InputScope.MissileCombat)); 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.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.Delete, ModifierMask.None), InputAction.CombatAimLow, ActivationType.Hold, InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium, Scope: 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, Scope: 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.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.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell, 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)); b.Add(new(new KeyChord(Key.K, ModifierMask.None), InputAction.PointState));
// ── Camera ───────────────────────────────────────────── // ── Camera ─────────────────────────────────────────────
b.Add(new(new KeyChord(Key.KeypadDivide, 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)); b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.CameraActivateAlternateMode, ActivationType.Hold));
// CameraInstantMouseLook (MMB hold) — encoded as a mouse chord // CameraInstantMouseLook (MMB hold) — encoded as a mouse chord
// via the K.1a Device=1 convention. K.2 lights up the actual // via the K.1a Device=1 convention. K.2 lights up the actual
// camera+yaw drive logic. // camera+yaw drive logic.
@ -304,16 +300,22 @@ public sealed class KeyBindings
InputAction.CameraInstantMouseLook, InputAction.CameraInstantMouseLook,
ActivationType.Hold)); ActivationType.Hold));
// Numpad cluster. // Numpad cluster.
b.Add(new(new KeyChord(Key.Keypad4, ModifierMask.None), InputAction.CameraRotateLeft)); b.Add(new(new KeyChord(Key.Keypad4, ModifierMask.None), InputAction.CameraRotateLeft, ActivationType.Hold));
b.Add(new(new KeyChord(Key.Keypad6, ModifierMask.None), InputAction.CameraRotateRight)); b.Add(new(new KeyChord(Key.Keypad6, ModifierMask.None), InputAction.CameraRotateRight, ActivationType.Hold));
b.Add(new(new KeyChord(Key.Keypad8, ModifierMask.None), InputAction.CameraRotateUp)); b.Add(new(new KeyChord(Key.Keypad8, ModifierMask.None), InputAction.CameraRotateUp, ActivationType.Hold));
b.Add(new(new KeyChord(Key.Keypad2, ModifierMask.None), InputAction.CameraRotateDown)); b.Add(new(new KeyChord(Key.Keypad2, ModifierMask.None), InputAction.CameraRotateDown, ActivationType.Hold));
b.Add(new(new KeyChord(Key.KeypadSubtract, ModifierMask.None), InputAction.CameraMoveToward)); b.Add(new(new KeyChord(Key.KeypadSubtract, ModifierMask.None), InputAction.CameraMoveToward, ActivationType.Hold));
b.Add(new(new KeyChord(Key.KeypadAdd, ModifierMask.None), InputAction.CameraMoveAway)); 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.Keypad0, ModifierMask.None), InputAction.CameraViewDefault));
b.Add(new(new KeyChord(Key.KeypadDecimal, ModifierMask.None), InputAction.CameraViewFirstPerson)); 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.Keypad5, ModifierMask.None), InputAction.CameraViewLookDown));
b.Add(new(new KeyChord(Key.KeypadEnter, ModifierMask.None), InputAction.CameraViewMapMode)); 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 ──────────────────────────────────── // ── Mouse selection ────────────────────────────────────
// Retail keymap: SelectLeft = LMB, SelectRight = RMB, SelectMid = MMB, // Retail keymap: SelectLeft = LMB, SelectRight = RMB, SelectMid = MMB,
@ -413,6 +415,7 @@ public sealed class KeyBindings
var defaults = RetailDefaults(); var defaults = RetailDefaults();
var loaded = new KeyBindings(); var loaded = new KeyBindings();
var explicitlyStoredActions = new HashSet<InputAction>();
if (root.TryGetProperty("actions", out var actionsEl) if (root.TryGetProperty("actions", out var actionsEl)
&& actionsEl.ValueKind == JsonValueKind.Object) && actionsEl.ValueKind == JsonValueKind.Object)
@ -422,6 +425,7 @@ public sealed class KeyBindings
if (!Enum.TryParse<InputAction>(actionProp.Name, out var action)) if (!Enum.TryParse<InputAction>(actionProp.Name, out var action))
continue; // unknown action → skip continue; // unknown action → skip
if (actionProp.Value.ValueKind != JsonValueKind.Array) continue; if (actionProp.Value.ValueKind != JsonValueKind.Array) continue;
explicitlyStoredActions.Add(action);
foreach (var bindingEl in actionProp.Value.EnumerateArray()) foreach (var bindingEl in actionProp.Value.EnumerateArray())
{ {
if (!bindingEl.TryGetProperty("key", out var keyEl)) continue; if (!bindingEl.TryGetProperty("key", out var keyEl)) continue;
@ -444,7 +448,11 @@ public sealed class KeyBindings
device = (byte)dEl.GetInt32(); device = (byte)dEl.GetInt32();
} }
var chord = new KeyChord(silkKey, mods, device); 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 = MigrateCombatAttackActivation(version, action, activation);
activation = MigrateSelectRightActivation(version, action, activation); activation = MigrateSelectRightActivation(version, action, activation);
InputScope scope = defaults.ForAction(action) InputScope scope = defaults.ForAction(action)
@ -468,7 +476,7 @@ public sealed class KeyBindings
// newly-added actions if the user file is older. // newly-added actions if the user file is older.
foreach (var actionInDefaults in Enum.GetValues<InputAction>()) foreach (var actionInDefaults in Enum.GetValues<InputAction>())
{ {
if (!loaded.ForAction(actionInDefaults).Any() if (!explicitlyStoredActions.Contains(actionInDefaults)
&& defaults.ForAction(actionInDefaults).Any()) && defaults.ForAction(actionInDefaults).Any())
{ {
foreach (var def in defaults.ForAction(actionInDefaults)) foreach (var def in defaults.ForAction(actionInDefaults))
@ -496,6 +504,12 @@ public sealed class KeyBindings
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var actions = new SortedDictionary<string, List<object>>(StringComparer.Ordinal); var actions = new SortedDictionary<string, List<object>>(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<object>());
foreach (var binding in _bindings) foreach (var binding in _bindings)
{ {
if (!actions.TryGetValue(binding.Action.ToString(), out var list)) if (!actions.TryGetValue(binding.Action.ToString(), out var list))
@ -528,30 +542,31 @@ public sealed class KeyBindings
} }
/// <summary> /// <summary>
/// Schema v1 repeated <c>UseQuickSlot_N</c> for bare and Ctrl chords, /// Schema v2-v5 incorrectly rewrote retail's Ctrl+1..9
/// losing retail's use-vs-select distinction. Migrate only the exact old /// <c>UseQuickSlot_N</c> defaults to <c>SelectQuickSlot_N</c>. The 2013
/// default Ctrl+matching-number shape; arbitrary user rebindings remain /// MasterInputMap and <c>gmToolbarUI::ListenToGlobalMessage</c> both prove
/// attached to the action the user chose. /// Ctrl+N sends the same use action as bare N. Repair only that exact old
/// generated-default shape; arbitrary SelectQuickSlot rebindings remain.
/// </summary> /// </summary>
private static InputAction MigrateLegacyQuickSlotIntent( private static InputAction MigrateQuickSlotIntent(
int version, int version,
InputAction action, InputAction action,
KeyChord chord, KeyChord chord,
ActivationType activation) ActivationType activation)
{ {
if (version >= 2 if (version >= 6
|| activation != ActivationType.Press || activation != ActivationType.Press
|| chord.Device != 0 || chord.Device != 0
|| chord.Modifiers != ModifierMask.Ctrl) || chord.Modifiers != ModifierMask.Ctrl)
return action; return action;
int offset = (int)action - (int)InputAction.UseQuickSlot_1; int offset = (int)action - (int)InputAction.SelectQuickSlot_1;
if ((uint)offset >= 9u) if ((uint)offset >= 9u)
return action; return action;
var expectedKey = (Key)((int)Key.Number1 + offset); var expectedKey = (Key)((int)Key.Number1 + offset);
return chord.Key == expectedKey return chord.Key == expectedKey
? (InputAction)((int)InputAction.SelectQuickSlot_1 + offset) ? (InputAction)((int)InputAction.UseQuickSlot_1 + offset)
: action; : action;
} }

View file

@ -1,72 +1,138 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace AcDream.UI.Abstractions.Input; namespace AcDream.UI.Abstractions.Input;
/// <summary> /// <summary>
/// Campaign OP slice OP8: maps a retail DAT ActionMap row — the /// Campaign OP slice OP8: maps a retail DAT ActionMap row — the
/// <c>(InputMap id, Action id)</c> pair <c>AcDream.Core.Input.RetailActionMapRow</c> /// <c>(InputMap id, Action id)</c> pair <c>AcDream.Core.Input.RetailActionMapRow</c>
/// carries — to acdream's own <see cref="InputAction"/>, when one exists. /// carries — to acdream's own <see cref="InputAction"/>.
/// ///
/// <para> /// <para>
/// <b>Why this table exists.</b> The DAT ActionMap singleton (empirically dumped /// The DAT ActionMap singleton carries exactly 306 user-bindable rows. Campaign KB
/// 2026-08-11, see <c>AcDream.Core.Input.RetailActionMap</c>'s class doc) carries 306 /// gives every row one distinct live identity. Identity is the full
/// user-bindable rows. <see cref="InputAction"/> — the enum every OTHER acdream input /// <c>(InputMapId, ActionId)</c> pair: retail legitimately reuses action ids between
/// path (live dispatch, <c>KeyBindings</c>, <c>InputDispatcher</c>) already keys on — /// CameraControls and CameraAlternateControls, and collapsing those rows would make
/// has roughly half that many members, because it was authored around "what acdream /// one GUI rebind silently overwrite the other.
/// 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 <c>PlayerOption</c>/
/// <c>CharacterOptions</c> preference bits OP1's <c>CharacterOptionTable</c> 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).
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Every mapping below was verified two ways</b> before being added: (1) the DAT's /// <b>Every mapping below was verified two ways</b>: (1) the DAT's
/// resolved English label/tooltip unambiguously names the SAME action as the /// resolved English label/tooltip unambiguously names the SAME action as the
/// <see cref="InputAction"/> member's own XML doc, AND (2) where the retail default /// <see cref="InputAction"/> member's own XML doc, AND (2) where the retail default
/// key(s) for that DAT row are non-empty, they match /// key(s) for that DAT row are non-empty, they match
/// <see cref="KeyBindings.RetailDefaults"/>'s existing chord(s) for the candidate /// <see cref="KeyBindings.RetailDefaults"/>'s existing chord(s) for the candidate
/// <see cref="InputAction"/> (byte-verified 2026-08-11 against the installed dats — /// <see cref="InputAction"/>. The installed-DAT conformance test requires complete,
/// see <c>RetailActionMapReaderTests.LiveDatTests</c> and this slice's /// injective 306/306 coverage, so a future DAT drift cannot quietly recreate the
/// <c>RetailActionIdentityRoundTripTests</c>). A DAT row that could not be verified /// former dim/store-only tier.
/// BOTH ways is left OUT of this table on purpose — it renders on the Configure
/// Keyboard screen as a real, bindable, persisted row (see
/// <c>KeyboardConfigController</c>), 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."
/// </para>
///
/// <para>
/// <b>Known gaps deliberately left unmapped</b> (register row, OP8):
/// Spell Slot 10/11/12 (ctx <c>0x10000005</c>, DAT actions <c>0x6E/0x6F/0x70</c> —
/// <see cref="InputAction"/> only defines <c>UseSpellSlot_1..9</c>); Quickslot
/// 10/11/12/13 (ctx <c>0x1000000C</c>, DAT actions <c>0x1000004B/4C/4D/10000132</c> —
/// <see cref="InputAction"/>'s <c>UseQuickSlot_*</c> 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 <c>0x10000008</c>, all 48); 82 of 87 Emote rows (ctx
/// <c>0x10000006</c>); all 10 CameraAlternateControls rows (ctx <c>0x6</c> — the M2
/// de-alias carve-out, see the mapping table's own comment); and roughly half of the
/// UI-class rows (ctx <c>0x10000007</c>/<c>0x10000009</c> — panels acdream has no
/// toggle for, e.g. Vitae, Link Status, House, Map, Character Info, the
/// positive/negative Magic panels).
/// </para> /// </para>
/// </summary> /// </summary>
public static class RetailActionIdentityTable public static class RetailActionIdentityTable
{ {
/// <summary>(InputMap id, Action id) → the acdream <see cref="InputAction"/> that /// <summary>(InputMap id, Action id) → the acdream <see cref="InputAction"/> that
/// owns live dispatch for it. A DAT row whose key is absent has no acdream /// owns live dispatch for it.</summary>
/// consumer yet.</summary>
public static readonly IReadOnlyDictionary<(uint InputMapId, uint ActionId), InputAction> Map = public static readonly IReadOnlyDictionary<(uint InputMapId, uint ActionId), InputAction> Map =
BuildTable(); BuildTable();
public static bool TryResolve(uint inputMapId, uint actionId, out InputAction action) => public static bool TryResolve(uint inputMapId, uint actionId, out InputAction action) =>
Map.TryGetValue((inputMapId, actionId), out action); Map.TryGetValue((inputMapId, actionId), out action);
/// <summary>Inverse identity used by family routers and conformance checks.</summary>
public static readonly IReadOnlyDictionary<InputAction, (uint InputMapId, uint ActionId)> 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);
/// <summary>Live dispatch scope implied by the retail InputMap context.</summary>
public static InputScope ScopeForInputMap(uint inputMapId) => inputMapId switch
{
0x00000006u => InputScope.Camera,
0x10000003u => InputScope.MeleeCombat,
0x10000004u => InputScope.MissileCombat,
0x10000005u => InputScope.MagicCombat,
_ => InputScope.Game,
};
/// <summary>
/// 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 <see cref="KeyBindings.RetailDefaults"/>.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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() private static Dictionary<(uint, uint), InputAction> BuildTable()
{ {
var t = new Dictionary<(uint, uint), InputAction>(); var t = new Dictionary<(uint, uint), InputAction>();
@ -89,24 +155,9 @@ public static class RetailActionIdentityTable
M(0x4, 0x10000097, InputAction.Sleeping); M(0x4, 0x10000097, InputAction.Sleeping);
// ── CameraControls (ctx 0x5) — 12/12. ────────────────────────── // ── CameraControls (ctx 0x5) — 12/12. ──────────────────────────
// M2 REWORK (2026-08-11 review): CameraControls (ctx 0x5, the // The primary and alternate maps deliberately use distinct actions.
// Numpad-default scheme RetailDefaults() actually carries) and // Retail reuses the ten low action ids, but they are separate rows and
// CameraAlternateControls (ctx 0x6, the arrow-key alternate scheme // separate rebind targets; collapsing them aliases GUI state.
// 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.
M(0x5, 0x33, InputAction.CameraMoveToward); M(0x5, 0x33, InputAction.CameraMoveToward);
M(0x5, 0x34, InputAction.CameraMoveAway); M(0x5, 0x34, InputAction.CameraMoveAway);
M(0x5, 0x35, InputAction.CameraRotateLeft); M(0x5, 0x35, InputAction.CameraRotateLeft);
@ -120,6 +171,18 @@ public static class RetailActionIdentityTable
M(0x5, 0x3D, InputAction.CameraInstantMouseLook); M(0x5, 0x3D, InputAction.CameraInstantMouseLook);
M(0x5, 0x3E, InputAction.CameraActivateAlternateMode); 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. ───────────────────────────── // ── Combat (ctx 0x10000002) — 1/1. ─────────────────────────────
M(0x10000002, 0x1000005A, InputAction.CombatToggleCombat); M(0x10000002, 0x1000005A, InputAction.CombatToggleCombat);
@ -137,8 +200,7 @@ public static class RetailActionIdentityTable
M(0x10000004, 0x100000F2, InputAction.CombatAimMedium); M(0x10000004, 0x100000F2, InputAction.CombatAimMedium);
M(0x10000004, 0x100000F3, InputAction.CombatAimHigh); M(0x10000004, 0x100000F3, InputAction.CombatAimHigh);
// ── MagicCombat (ctx 0x10000005) — 18/21 (Spell Slot 10/11/12 have // ── MagicCombat (ctx 0x10000005) — 21/21. ──────────────────────
// no InputAction — register row). ────────────────────────────
M(0x10000005, 0x10000060, InputAction.CombatCastCurrentSpell); M(0x10000005, 0x10000060, InputAction.CombatCastCurrentSpell);
M(0x10000005, 0x10000061, InputAction.CombatPrevSpell); M(0x10000005, 0x10000061, InputAction.CombatPrevSpell);
M(0x10000005, 0x10000062, InputAction.CombatNextSpell); M(0x10000005, 0x10000062, InputAction.CombatNextSpell);
@ -153,21 +215,111 @@ public static class RetailActionIdentityTable
M(0x10000005, 0x1000006B, InputAction.UseSpellSlot_7); M(0x10000005, 0x1000006B, InputAction.UseSpellSlot_7);
M(0x10000005, 0x1000006C, InputAction.UseSpellSlot_8); M(0x10000005, 0x1000006C, InputAction.UseSpellSlot_8);
M(0x10000005, 0x1000006D, InputAction.UseSpellSlot_9); 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, 0x10000102, InputAction.CombatFirstSpell);
M(0x10000005, 0x10000103, InputAction.CombatLastSpell); M(0x10000005, 0x10000103, InputAction.CombatLastSpell);
M(0x10000005, 0x10000104, InputAction.CombatFirstSpellTab); M(0x10000005, 0x10000104, InputAction.CombatFirstSpellTab);
M(0x10000005, 0x10000105, InputAction.CombatLastSpellTab); M(0x10000005, 0x10000105, InputAction.CombatLastSpellTab);
// ── Emotes (ctx 0x10000006) — 5/87 (the only 5 acdream dispatches // ── Emotes (ctx 0x10000006) — 87/87. ────────────────────────
// an animation for; also the only 5 with retail default keys). ── InputAction[] emotes =
M(0x10000006, 0x100000A2, InputAction.Cheer); {
M(0x10000006, 0x100000A7, InputAction.Cry); InputAction.EmoteAfkState,
M(0x10000006, 0x100000B2, InputAction.Laugh); InputAction.EmoteAkimbo,
M(0x10000006, 0x100000BE, InputAction.PointState); InputAction.EmoteAToyotState,
M(0x10000006, 0x100000E5, InputAction.Wave); 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, 0x1000002D, InputAction.SelectionSplitStack);
M(0x10000007, 0x1000002E, InputAction.SelectionPreviousSelection); M(0x10000007, 0x1000002E, InputAction.SelectionPreviousSelection);
M(0x10000007, 0x1000002F, InputAction.SelectionClosestCompassItem); M(0x10000007, 0x1000002F, InputAction.SelectionClosestCompassItem);
@ -185,20 +337,44 @@ public static class RetailActionIdentityTable
M(0x10000007, 0x1000003B, InputAction.SelectionNextPlayer); M(0x10000007, 0x1000003B, InputAction.SelectionNextPlayer);
M(0x10000007, 0x1000003C, InputAction.SelectionPreviousFellow); M(0x10000007, 0x1000003C, InputAction.SelectionPreviousFellow);
M(0x10000007, 0x1000003D, InputAction.SelectionNextFellow); 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, 0x55, InputAction.CaptureScreenshot);
M(0x10000009, 0x7B, InputAction.ToggleHelp); M(0x10000009, 0x7B, InputAction.ToggleHelp);
M(0x10000009, 0x7C, InputAction.TogglePluginManager); 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, 0x1000000E, InputAction.ToggleAllegiancePanel);
M(0x10000009, 0x1000000F, InputAction.ToggleFellowshipPanel); M(0x10000009, 0x1000000F, InputAction.ToggleFellowshipPanel);
M(0x10000009, 0x10000010, InputAction.ToggleSpellManagementPanel);
M(0x10000009, 0x10000011, InputAction.ToggleSpellbookPanel); M(0x10000009, 0x10000011, InputAction.ToggleSpellbookPanel);
M(0x10000009, 0x10000012, InputAction.ToggleSpellComponentsPanel); M(0x10000009, 0x10000012, InputAction.ToggleSpellComponentsPanel);
M(0x10000009, 0x10000013, InputAction.ToggleCharacterDetailPanel);
M(0x10000009, 0x10000014, InputAction.ToggleAttributesPanel); M(0x10000009, 0x10000014, InputAction.ToggleAttributesPanel);
M(0x10000009, 0x10000015, InputAction.ToggleSkillsPanel); M(0x10000009, 0x10000015, InputAction.ToggleSkillsPanel);
M(0x10000009, 0x10000016, InputAction.ToggleWorldPanel); M(0x10000009, 0x10000016, InputAction.ToggleWorldPanel);
M(0x10000009, 0x10000017, InputAction.ToggleMapPage);
M(0x10000009, 0x10000018, InputAction.ToggleHousePage);
M(0x10000009, 0x1000001A, InputAction.ToggleOptionsPanel); M(0x10000009, 0x1000001A, InputAction.ToggleOptionsPanel);
M(0x10000009, 0x10000019, InputAction.ToggleInventoryPanel); 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, 0x10000114, InputAction.ToggleFloatingChatWindow1);
M(0x10000009, 0x10000115, InputAction.ToggleFloatingChatWindow2); M(0x10000009, 0x10000115, InputAction.ToggleFloatingChatWindow2);
M(0x10000009, 0x10000116, InputAction.ToggleFloatingChatWindow3); M(0x10000009, 0x10000116, InputAction.ToggleFloatingChatWindow3);
@ -206,19 +382,24 @@ public static class RetailActionIdentityTable
M(0x10000009, 0x10000025, InputAction.UseSelected); M(0x10000009, 0x10000025, InputAction.UseSelected);
M(0x10000009, 0x10000026, InputAction.LOGOUT); M(0x10000009, 0x10000026, InputAction.LOGOUT);
M(0x10000009, 0x1000002B, InputAction.SelectionExamine); M(0x10000009, 0x1000002B, InputAction.SelectionExamine);
// 0x1000001F ("Show/Hide Keyboard Configuration") deliberately left M(0x10000009, 0x10000118, InputAction.ToggleFriendsPage);
// unmapped: it is the retail action that opens THIS screen M(0x10000009, 0x1000011A, InputAction.ToggleCharacterTitlesPage);
// (research doc §4.3/lane A §7 — wired directly by M(0x10000009, 0x10000127, InputAction.ToggleQuestDetailPanel);
// KeyboardConfigController's mount, not through InputAction). M(0x10000009, 0x10000128, InputAction.ToggleQuestJournalPage);
M(0x10000009, 0x10000129, InputAction.ToggleJournalPageList);
// ── ChatCommands (ctx 0x1000000A) — 1/6. ─────────────────────── 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, 0x10000023, InputAction.EnterChatMode);
M(0x1000000A, 0x10000028, InputAction.ChatStartCommand);
M(0x1000000A, 0x10000119, InputAction.ChatTellToSelected);
// ── ToggleChatEntry (ctx 0x1000000D) — 1/1. ──────────────────── // ── ToggleChatEntry (ctx 0x1000000D) — 1/1. ────────────────────
M(0x1000000D, 0x10000024, InputAction.ToggleChatEntry); M(0x1000000D, 0x10000024, InputAction.ToggleChatEntry);
// ── QuickslotCommands (ctx 0x1000000C) — 24/28 (Quickslot // ── QuickslotCommands (ctx 0x1000000C) — 28/28. ────────────────
// 10/11/12/13 have no InputAction — pre-existing enum gap). ──
M(0x1000000C, 0x10000042, InputAction.UseQuickSlot_1); M(0x1000000C, 0x10000042, InputAction.UseQuickSlot_1);
M(0x1000000C, 0x10000043, InputAction.UseQuickSlot_2); M(0x1000000C, 0x10000043, InputAction.UseQuickSlot_2);
M(0x1000000C, 0x10000044, InputAction.UseQuickSlot_3); M(0x1000000C, 0x10000044, InputAction.UseQuickSlot_3);
@ -228,6 +409,9 @@ public static class RetailActionIdentityTable
M(0x1000000C, 0x10000048, InputAction.UseQuickSlot_7); M(0x1000000C, 0x10000048, InputAction.UseQuickSlot_7);
M(0x1000000C, 0x10000049, InputAction.UseQuickSlot_8); M(0x1000000C, 0x10000049, InputAction.UseQuickSlot_8);
M(0x1000000C, 0x1000004A, InputAction.UseQuickSlot_9); 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, 0x1000004E, InputAction.SelectQuickSlot_1);
M(0x1000000C, 0x1000004F, InputAction.SelectQuickSlot_2); M(0x1000000C, 0x1000004F, InputAction.SelectQuickSlot_2);
M(0x1000000C, 0x10000050, InputAction.SelectQuickSlot_3); M(0x1000000C, 0x10000050, InputAction.SelectQuickSlot_3);
@ -238,16 +422,62 @@ public static class RetailActionIdentityTable
M(0x1000000C, 0x10000055, InputAction.SelectQuickSlot_8); M(0x1000000C, 0x10000055, InputAction.SelectQuickSlot_8);
M(0x1000000C, 0x10000056, InputAction.SelectQuickSlot_9); M(0x1000000C, 0x10000056, InputAction.SelectQuickSlot_9);
M(0x1000000C, 0x1000010D, InputAction.CreateShortcut); M(0x1000000C, 0x1000010D, InputAction.CreateShortcut);
// 0x10000132 ("Quickslot 13") has no InputAction — same pre-existing M(0x1000000C, 0x10000132, InputAction.UseQuickSlot_13);
// UseQuickSlot_10..13 enum gap as the bare-numeral block above. Unmapped.
M(0x1000000C, 0x10000133, InputAction.UseQuickSlot_14); M(0x1000000C, 0x10000133, InputAction.UseQuickSlot_14);
M(0x1000000C, 0x10000134, InputAction.UseQuickSlot_15); M(0x1000000C, 0x10000134, InputAction.UseQuickSlot_15);
M(0x1000000C, 0x10000135, InputAction.UseQuickSlot_16); M(0x1000000C, 0x10000135, InputAction.UseQuickSlot_16);
M(0x1000000C, 0x10000136, InputAction.UseQuickSlot_17); M(0x1000000C, 0x10000136, InputAction.UseQuickSlot_17);
M(0x1000000C, 0x10000137, InputAction.UseQuickSlot_18); M(0x1000000C, 0x10000137, InputAction.UseQuickSlot_18);
// CharacterSettings (ctx 0x10000008) is intentionally EMPTY here — // ── CharacterSettings (ctx 0x10000008) — 48/48. ────────────────
// see class doc "Known gaps deliberately left unmapped". 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; return t;
} }

View file

@ -13,17 +13,17 @@ namespace AcDream.UI.Abstractions.Input;
/// <see cref="Key"/> enum. /// <see cref="Key"/> enum.
/// ///
/// <para> /// <para>
/// The scan-code table covers exactly the 84 distinct DIK codes that appear across /// The scan-code table covers the 84 distinct DIK codes that appear across the
/// the DAT's 306 user-bindable ActionMap rows' default bindings (2026-08-11 probe — /// DAT's 306 user-bindable ActionMap rows' default bindings (2026-08-11 probe),
/// see <c>RetailActionMap.cs</c>'s class doc), cross-checked against /// plus the remaining keyboard controls accepted by retail's plain-text keymap
/// interchange. The default set was cross-checked against
/// <c>tools/dump-keymap/Program.cs</c>'s own <c>Dik(uint)</c> transcription (itself /// <c>tools/dump-keymap/Program.cs</c>'s own <c>Dik(uint)</c> transcription (itself
/// verified against <c>acclient_2013_pseudo_c.txt</c>'s /// verified against <c>acclient_2013_pseudo_c.txt</c>'s
/// <c>ControlNameMapper::AddKeySemantic</c> calls) and against /// <c>ControlNameMapper::AddKeySemantic</c> calls) and against
/// <see cref="KeyBindings.RetailDefaults"/>'s existing chords, which already encode /// <see cref="KeyBindings.RetailDefaults"/>'s existing chords, which already encode
/// the same standard US-layout DirectInput scan codes by construction (both were /// the same standard US-layout DirectInput scan codes by construction (both were
/// authored from the same <c>retail-default.keymap.txt</c>). Codes outside this set /// authored from the same <c>retail-default.keymap.txt</c>). Unsupported joystick
/// (rare/debug/joystick bindings never seen with a non-empty default in the shipped /// controls intentionally return null rather than guess.
/// DAT) intentionally return null rather than guess.
/// </para> /// </para>
/// </summary> /// </summary>
public static class RetailScanCodeMap public static class RetailScanCodeMap
@ -94,6 +94,7 @@ public static class RetailScanCodeMap
0x1A => Key.LeftBracket, 0x1A => Key.LeftBracket,
0x1B => Key.RightBracket, 0x1B => Key.RightBracket,
0x1C => Key.Enter, 0x1C => Key.Enter,
0x1D => Key.ControlLeft,
0x1E => Key.A, 0x1E => Key.A,
0x1F => Key.S, 0x1F => Key.S,
0x20 => Key.D, 0x20 => Key.D,
@ -120,7 +121,9 @@ public static class RetailScanCodeMap
0x35 => Key.Slash, 0x35 => Key.Slash,
0x36 => Key.ShiftRight, 0x36 => Key.ShiftRight,
0x37 => Key.KeypadMultiply, 0x37 => Key.KeypadMultiply,
0x38 => Key.AltLeft,
0x39 => Key.Space, 0x39 => Key.Space,
0x3A => Key.CapsLock,
0x3B => Key.F1, 0x3B => Key.F1,
0x3C => Key.F2, 0x3C => Key.F2,
0x3D => Key.F3, 0x3D => Key.F3,
@ -148,9 +151,15 @@ public static class RetailScanCodeMap
0x53 => Key.KeypadDecimal, 0x53 => Key.KeypadDecimal,
0x57 => Key.F11, 0x57 => Key.F11,
0x58 => Key.F12, 0x58 => Key.F12,
0x64 => Key.F13,
0x65 => Key.F14,
0x66 => Key.F15,
0x9C => Key.KeypadEnter, 0x9C => Key.KeypadEnter,
0x9D => Key.ControlRight, 0x9D => Key.ControlRight,
0xB5 => Key.KeypadDivide, 0xB5 => Key.KeypadDivide,
0xB7 => Key.PrintScreen,
0xB8 => Key.AltRight,
0xC5 => Key.Pause,
0xC7 => Key.Home, 0xC7 => Key.Home,
0xC8 => Key.Up, 0xC8 => Key.Up,
0xC9 => Key.PageUp, 0xC9 => Key.PageUp,
@ -161,7 +170,163 @@ public static class RetailScanCodeMap
0xD1 => Key.PageDown, 0xD1 => Key.PageDown,
0xD2 => Key.Insert, 0xD2 => Key.Insert,
0xD3 => Key.Delete, 0xD3 => Key.Delete,
0xDB => Key.SuperLeft,
0xDC => Key.SuperRight,
0xDD => Key.Menu,
_ => null, _ => null,
}; };
} }
/// <summary>
/// Retail's plain-text <c>.keymap</c> control semantic to the same device /
/// scan-code pair consumed by <see cref="ToSilkKey"/>. The legacy file
/// uses a few historical aliases (<c>UPARROW</c>, <c>PGUP</c>,
/// <c>NUMPADSTAR</c>, ...), so parsing accepts both those spellings and the
/// canonical DirectInput spellings emitted by <see cref="TryToFileControl"/>.
/// </summary>
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;
}
/// <summary>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).</summary>
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,
};
} }

View file

@ -6,15 +6,13 @@ using System.Text.Json;
namespace AcDream.UI.Abstractions.Input; namespace AcDream.UI.Abstractions.Input;
/// <summary> /// <summary>
/// Campaign OP slice OP8: persisted bindings for DAT ActionMap rows that /// Forward-compatible persisted bindings for an ActionMap row introduced by a
/// <c>RetailActionIdentityTable</c> has no <see cref="InputAction"/> for — /// future DAT revision. Campaign KB maps every one of the 306 rows in the
/// mostly Emotes and CharacterSettings hotkeys (see that table's class doc for /// supported Sept-2013 EoR DAT, so this sibling file has no production entries
/// the full accounting). These rows still render, bind, conflict-check, and /// there; it only keeps an unknown future row visible and round-trippable
/// persist on the Configure Keyboard screen exactly like a mapped row; they /// instead of crashing an older client. The compatibility sibling file stays
/// just have no live gameplay consumer to dispatch through yet, so they live in /// beside <c>keybinds.json</c>; installed-retail rows use the canonical
/// their own small store rather than <see cref="KeyBindings"/>'s /// <c>*.keymap</c> profile instead.
/// <see cref="InputAction"/>-keyed schema. Sibling file next to
/// <c>keybinds.json</c> (D4 — no <c>.keymap</c> file interchange).
/// </summary> /// </summary>
public sealed class RetailUnmappedKeyBindings public sealed class RetailUnmappedKeyBindings
{ {

View file

@ -59,6 +59,12 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
public string? LastOutgoingTellTarget => public string? LastOutgoingTellTarget =>
_commandTargets.LastOutgoingTellTarget; _commandTargets.LastOutgoingTellTarget;
public string? LastMonarchSender =>
_commandTargets.LastMonarchSender;
public string? LastPatronSender =>
_commandTargets.LastPatronSender;
/// <summary> /// <summary>
/// Optional callback exposing the live framerate. Wired by /// Optional callback exposing the live framerate. Wired by
/// <c>GameWindow</c> at construction so the client-side /// <c>GameWindow</c> at construction so the client-side

View file

@ -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] [Fact]
public void ScreenshotCapture_ReportsNoWorkAndFailedCapture() public void ScreenshotCapture_ReportsNoWorkAndFailedCapture()
{ {

View file

@ -1,6 +1,7 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.Core.Rendering;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
using Silk.NET.Input; using Silk.NET.Input;
@ -175,31 +176,63 @@ public sealed class CameraPointerInputControllerTests
Assert.Equal(flyBefore * 1.2f, fixture.Owner.ActiveSensitivity, 5); 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<RawSurface> surfaces) private static Fixture Create(IReadOnlyList<RawSurface> surfaces)
{ {
var camera = new CameraController(new OrbitCamera(), new FlyCamera()); var camera = new CameraController(new OrbitCamera(), new FlyCamera());
var capture = new Capture(); var capture = new Capture();
var mouse = new Mouse(); var mouse = new Mouse();
var cursor = new Cursor(); var cursor = new Cursor();
var mode = new LocalPlayerModeState();
var chase = new ChaseCameraInputState();
var owner = new CameraPointerInputController( var owner = new CameraPointerInputController(
surfaces, surfaces,
cursor, cursor,
new HostQuiescenceGate(), new HostQuiescenceGate(),
capture, capture,
new LocalPlayerModeState(), mode,
camera, camera,
new ChaseCameraInputState(), chase,
mouse, mouse,
new PointerPositionState(), new PointerPositionState(),
new Clock()); new Clock());
return new Fixture(owner, camera, capture, cursor); return new Fixture(owner, camera, capture, cursor, mode, chase);
} }
private sealed record Fixture( private sealed record Fixture(
CameraPointerInputController Owner, CameraPointerInputController Owner,
CameraController Camera, CameraController Camera,
Capture Capture, Capture Capture,
Cursor Cursor); Cursor Cursor,
LocalPlayerModeState Mode,
ChaseCameraInputState Chase);
private sealed class RawSurface : IRawPointerSurface private sealed class RawSurface : IRawPointerSurface
{ {
@ -282,6 +315,7 @@ public sealed class CameraPointerInputControllerTests
{ {
public void Tick() { } public void Tick() { }
public void HandleMovementInput(InputAction action, ActivationType activation) { } public void HandleMovementInput(InputAction action, ActivationType activation) { }
public void AbortAutomaticAttack() { }
public bool HandleInputAction(InputAction action, ActivationType activation) => false; public bool HandleInputAction(InputAction action, ActivationType activation) => false;
} }
} }

View file

@ -1,6 +1,7 @@
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Runtime;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Input; namespace AcDream.App.Tests.Input;
@ -11,9 +12,10 @@ public sealed class GameplayInputActionRouterTests
[InlineData("pointer", "pointer")] [InlineData("pointer", "pointer")]
[InlineData("combat", "pointer,combat")] [InlineData("combat", "pointer,combat")]
[InlineData("retained", "pointer,combat,retained")] [InlineData("retained", "pointer,combat,retained")]
[InlineData("selection", "pointer,combat,retained,selection")] [InlineData("character-option", "pointer,combat,retained,character-option")]
[InlineData("movement", "pointer,combat,retained,selection,movement")] [InlineData("selection", "pointer,combat,retained,character-option,selection")]
[InlineData("command", "pointer,combat,retained,selection,movement,command")] [InlineData("movement", "pointer,combat,retained,character-option,selection,movement")]
[InlineData("command", "pointer,combat,retained,character-option,selection,movement,command")]
public void Press_PreservesFrozenPriorityAndStopsAtConsumer( public void Press_PreservesFrozenPriorityAndStopsAtConsumer(
string consumeAt, string consumeAt,
string expectedCsv) string expectedCsv)
@ -66,7 +68,7 @@ public sealed class GameplayInputActionRouterTests
ActivationType.DoubleClick); ActivationType.DoubleClick);
Assert.Equal( Assert.Equal(
["pointer", "combat", "retained", "selection", "movement", "command"], ["pointer", "combat", "retained", "character-option", "selection", "movement", "command"],
harness.Targets.Calls); harness.Targets.Calls);
} }
@ -81,7 +83,7 @@ public sealed class GameplayInputActionRouterTests
ActivationType.Click); ActivationType.Click);
Assert.Equal( Assert.Equal(
["pointer", "combat", "retained", "selection"], ["pointer", "combat", "retained", "character-option", "selection"],
harness.Targets.Calls); harness.Targets.Calls);
} }
@ -100,6 +102,60 @@ public sealed class GameplayInputActionRouterTests
harness.Actions.Scopes); 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] [Theory]
[InlineData(0, "remove-actions")] [InlineData(0, "remove-actions")]
[InlineData(1, "remove-combat,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 SetCombatScope(InputScope? scope) => Scopes.Add(scope);
public void SetCameraAlternateScope(bool active) =>
calls.Add($"camera-scope:{active}");
public void Raise(InputAction action, ActivationType activation) => public void Raise(InputAction action, ActivationType activation) =>
Callback?.Invoke(action, activation); Callback?.Invoke(action, activation);
} }
@ -326,6 +385,9 @@ public sealed class GameplayInputActionRouterTests
public bool HandleRetainedUiAction(InputAction action) => public bool HandleRetainedUiAction(InputAction action) =>
Record("retained"); Record("retained");
public bool HandleCharacterOptionAction(InputAction action) =>
Record("character-option");
public bool HandleSelectionAction(InputAction action) => public bool HandleSelectionAction(InputAction action) =>
Record("selection"); Record("selection");

View file

@ -23,6 +23,9 @@ public sealed class GameplayInputCommandControllerTests
[InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")] [InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")]
[InlineData(InputAction.ToggleFloatingChatWindow3, "chat-window-3")] [InlineData(InputAction.ToggleFloatingChatWindow3, "chat-window-3")]
[InlineData(InputAction.ToggleFloatingChatWindow4, "chat-window-4")] [InlineData(InputAction.ToggleFloatingChatWindow4, "chat-window-4")]
[InlineData(InputAction.ToggleChatEntry, "focus-chat")]
[InlineData(InputAction.EnterChatMode, "focus-chat")]
[InlineData(InputAction.LOGOUT, "logout")]
public void RecognizedCommand_RoutesToTypedOwner( public void RecognizedCommand_RoutesToTypedOwner(
InputAction action, InputAction action,
string expected) string expected)
@ -35,21 +38,12 @@ public sealed class GameplayInputCommandControllerTests
Assert.Equal([expected], harness.Calls); Assert.Equal([expected], harness.Calls);
} }
// OP9: AcdreamToggleDebugPanel/ToggleChatEntry retired the [Fact]
// IDevToolsGameplayCommands seam they used to forward to — both public void RetiredDebugPanelCommand_IsConsumedWithoutClaimingATypedOwner()
// 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)
{ {
var harness = new Harness(); var harness = new Harness();
bool handled = harness.Controller.Handle(action); bool handled = harness.Controller.Handle(InputAction.AcdreamToggleDebugPanel);
Assert.True(handled); Assert.True(handled);
Assert.Empty(harness.Calls); Assert.Empty(harness.Calls);
@ -80,8 +74,9 @@ public sealed class GameplayInputCommandControllerTests
} }
/// <summary> /// <summary>
/// Escape's priority chain: cancel a target mode, else leave player mode, /// Escape's command-tier priority: cancel a target mode, otherwise toggle
/// else close a window. /// retail's Gameplay Options page. It must never expose the developer/fly
/// camera or close the game window.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The free-fly rung was REMOVED (2026-08-21, user direction: free-fly /// 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. /// rather than silently exiting a camera the player has no way to enter.
/// </remarks> /// </remarks>
[Theory] [Theory]
[InlineData(true, true, true, "cancel-target")] [InlineData(true, "cancel-target")]
[InlineData(false, true, true, "exit-player")] [InlineData(false, "gameplay-options")]
[InlineData(false, false, true, "exit-player")] public void Escape_PreservesRetailTargetThenGameplayOptionsPriority(
[InlineData(false, false, false, "close")]
public void Escape_PreservesTargetPlayerWindowPriority(
bool targetMode, bool targetMode,
bool flyMode,
bool playerMode,
string expected) string expected)
{ {
var harness = new Harness var harness = new Harness
{ {
TargetMode = { IsActive = targetMode }, TargetMode = { IsActive = targetMode },
Camera = { IsFly = flyMode },
Player = { IsPlayer = playerMode },
}; };
bool handled = harness.Controller.Handle(InputAction.EscapeKey); bool handled = harness.Controller.Handle(InputAction.EscapeKey);
@ -121,19 +110,15 @@ public sealed class GameplayInputCommandControllerTests
Diagnostics = new FakeDiagnostics(Calls); Diagnostics = new FakeDiagnostics(Calls);
Player = new FakePlayerMode(Calls); Player = new FakePlayerMode(Calls);
TargetMode = new FakeTargetMode(Calls); TargetMode = new FakeTargetMode(Calls);
Camera = new FakeCamera(Calls);
Combat = new FakeCombat(Calls); Combat = new FakeCombat(Calls);
Runtime = new FakeRuntimeView(); Runtime = new FakeRuntimeView();
Window = new FakeWindow(Calls);
Controller = new GameplayInputCommandController( Controller = new GameplayInputCommandController(
Retained, Retained,
Diagnostics, Diagnostics,
Player, Player,
TargetMode, TargetMode,
Camera,
Runtime, Runtime,
Combat, Combat);
Window);
} }
public List<string> Calls { get; } = []; public List<string> Calls { get; } = [];
@ -141,10 +126,8 @@ public sealed class GameplayInputCommandControllerTests
public FakeDiagnostics Diagnostics { get; } public FakeDiagnostics Diagnostics { get; }
public FakePlayerMode Player { get; } public FakePlayerMode Player { get; }
public FakeTargetMode TargetMode { get; } public FakeTargetMode TargetMode { get; }
public FakeCamera Camera { get; }
public FakeCombat Combat { get; } public FakeCombat Combat { get; }
public FakeRuntimeView Runtime { get; } public FakeRuntimeView Runtime { get; }
public FakeWindow Window { get; }
public GameplayInputCommandController Controller { get; } public GameplayInputCommandController Controller { get; }
} }
@ -157,6 +140,12 @@ public sealed class GameplayInputCommandControllerTests
calls.Add($"chat-window-{windowId}"); calls.Add($"chat-window-{windowId}");
public void ToggleOptionsPanel() => calls.Add("options"); 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<string> calls) private sealed class FakeDiagnostics(List<string> calls)
@ -180,11 +169,8 @@ public sealed class GameplayInputCommandControllerTests
private sealed class FakePlayerMode(List<string> calls) private sealed class FakePlayerMode(List<string> calls)
: IPlayerModeGameplayCommands : IPlayerModeGameplayCommands
{ {
public bool IsPlayer { get; set; }
public bool IsPlayerMode => IsPlayer;
public void ToggleFlyOrChase() => calls.Add("fly-or-chase"); public void ToggleFlyOrChase() => calls.Add("fly-or-chase");
public void TogglePlayerMode() => calls.Add("player-mode"); public void TogglePlayerMode() => calls.Add("player-mode");
public void ExitPlayerMode() => calls.Add("exit-player");
} }
private sealed class FakeTargetMode(List<string> calls) private sealed class FakeTargetMode(List<string> calls)
@ -195,14 +181,6 @@ public sealed class GameplayInputCommandControllerTests
public void CancelTargetMode() => calls.Add("cancel-target"); public void CancelTargetMode() => calls.Add("cancel-target");
} }
private sealed class FakeCamera(List<string> calls)
: IGameplayCameraModeCommands
{
public bool IsFly { get; set; }
public bool IsFlyMode => IsFly;
public void ExitFlyMode() => calls.Add("exit-fly");
}
private sealed class FakeCombat(List<string> calls) : IRuntimeCombatCommands private sealed class FakeCombat(List<string> calls) : IRuntimeCombatCommands
{ {
public RuntimeCommandResult Execute( public RuntimeCommandResult Execute(
@ -248,8 +226,4 @@ public sealed class GameplayInputCommandControllerTests
throw new NotSupportedException(); throw new NotSupportedException();
} }
private sealed class FakeWindow(List<string> calls) : IGameplayWindowCommands
{
public void Close() => calls.Add("close");
}
} }

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