fix(client): restore retail interaction parity
Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
parent
0c699240e0
commit
f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions
283
docs/ISSUES.md
283
docs/ISSUES.md
|
|
@ -24,9 +24,251 @@ What does NOT go here:
|
||||||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
- 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 1–18, panel/chat, and 48 CharacterSettings families reach
|
||||||
|
concrete consumers. Selection includes retail radar/combat/fellow/vendor/
|
||||||
|
environment and session opened-corpse rules. The approved 40 m mouse-wheel
|
||||||
|
chase zoom remains unchanged and regression-pinned.
|
||||||
|
|
||||||
|
Retail's Load File / Save As path is now live as well: the client parses and
|
||||||
|
writes the Sept-2013 PFile `.keymap` grammar under
|
||||||
|
`Documents\Asheron's Call`, remembers the selected profile, presents the
|
||||||
|
authored type-7 file menu and type-5 filename/overwrite dialogs, loads it on
|
||||||
|
startup, and rewrites it on graceful shutdown like retail. `keybinds.json`
|
||||||
|
remains a compatibility mirror for acdream-only commands. AP-202 is retired.
|
||||||
|
|
||||||
|
Automated keyboard-impact evidence is green: App 6,413/6,413, Core
|
||||||
|
4,713/4,713, Runtime 1,849/1,849, and UI.Abstractions 879/879 (13,854
|
||||||
|
tests total). Installed-DAT conformance pins all 306 identities, defaults, the
|
||||||
|
authored Configure Keyboard mount, and active Load/Save controls. Only the
|
||||||
|
connected gate below remains.
|
||||||
|
|
||||||
|
**First owner-round findings fixed 2026-08-26:** modifier-only capture now
|
||||||
|
normalizes LeftShift and consistently raises the retail overwrite prompt when
|
||||||
|
Move Forward conflicts with Toggle Walk/Run. Regular Enter enters chat without
|
||||||
|
its raw event immediately submitting the new field; keypad Enter no longer
|
||||||
|
falls through to the raw chat-focus shortcut. Melee height keys now preserve
|
||||||
|
the Press→held charge→Release transaction instead of treating the first Hold
|
||||||
|
tick as release. Map mode transforms both retail's target direction and viewer
|
||||||
|
offset through the target frame, placing the eye high overhead rather than low
|
||||||
|
behind the character; the approved mouse-wheel zoom range is unchanged.
|
||||||
|
Shift+Escape's same-process relog portal stall is tracked and fixed as #450.
|
||||||
|
Focused App coverage plus the standard Release lane pass.
|
||||||
|
|
||||||
|
**Acceptance:** a connected retail side-by-side covers representative movement,
|
||||||
|
combat, panel, modifier, and mouse mappings; every rebound action executes,
|
||||||
|
conflicts match retail, cancellation changes nothing, and applied bindings
|
||||||
|
survive a fresh client launch.
|
||||||
|
|
||||||
|
## #445 — Stack split errors in inventory; vendor drag ignores selected quantity
|
||||||
|
|
||||||
|
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.
|
||||||
|
**Component:** inventory stack splitting / vendor sell staging / shared split
|
||||||
|
quantity.
|
||||||
|
|
||||||
|
Two live paths fail after selecting a partial quantity with the stack slider:
|
||||||
|
|
||||||
|
1. Splitting a stack within the inventory produces an error instead of moving
|
||||||
|
the selected quantity into the destination slot.
|
||||||
|
2. With a stack of 10 and the slider set to 2, dragging the stack into the
|
||||||
|
vendor window stages all 10 rather than the selected 2.
|
||||||
|
|
||||||
|
**Expected:** the selected quantity is the single shared value consumed by
|
||||||
|
inventory split operations and by the vendor drop path; the source retains
|
||||||
|
the remainder. Capture the exact inventory error text/code during the fix
|
||||||
|
gate.
|
||||||
|
|
||||||
|
**Investigation seam:** trace `StackSplitQuantityState` from selection/slider
|
||||||
|
changes through the inventory `SendStackableSplitToContainer` request. The
|
||||||
|
vendor path currently documents and implements full-stack sell staging in
|
||||||
|
`VendorUiController.EvaluateSellAcceptability`; compare that claim against
|
||||||
|
named retail and a retail client gate before changing it, then make the
|
||||||
|
observed behavior and documentation agree. This is distinct from #313, which
|
||||||
|
only tracks selection transfer to the newly created split result.
|
||||||
|
|
||||||
|
**2026-08-26 fix:** named retail's enclosing
|
||||||
|
`VendorSellUI::AcceptDragObject @ 0x004C4F00` disproved the old full-stack-only
|
||||||
|
comment. A partial vendor drop now sends the exact slider quantity through the
|
||||||
|
canonical inventory transaction owner, stages the source as retail's temporary
|
||||||
|
row, and replaces that row in place when the server-created stack with matching
|
||||||
|
WCID/quantity arrives. A matching failure removes the placeholder. Ordinary
|
||||||
|
inventory splitting now uses that same owner and computes empty main-pack
|
||||||
|
placement from visible loose items, excluding side bags that live in retail's
|
||||||
|
separate selector list. Exact quantity, request lifetime, replacement order,
|
||||||
|
and side-bag placement are regression-tested. Owner check is in
|
||||||
|
`docs/research/2026-08-26-combined-client-parity-gate.md`.
|
||||||
|
|
||||||
|
## #444 — Vendor alternate-currency balance stays stale after a successful purchase
|
||||||
|
|
||||||
|
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.
|
||||||
|
**Component:** vendor UI / alternate-currency purchase refresh.
|
||||||
|
|
||||||
|
At a vendor that accepts an alternate currency (observed with Colosseum
|
||||||
|
Coins), the purchase succeeds and the server removes the currency, but the
|
||||||
|
vendor window continues to show the pre-purchase holding. Example: the purse
|
||||||
|
line says "You have 10 Colosseum Coins" before the purchase and still says 10
|
||||||
|
afterward. The displayed holding should update immediately after the
|
||||||
|
authoritative purchase/inventory update.
|
||||||
|
|
||||||
|
**Likely seam:** `VendorUiController.BuildPurseText` and `BuildCostText` read
|
||||||
|
the vendor-open snapshot `VendorShopProfile.AlternateCurrencyAmount`
|
||||||
|
directly. `OnObjectMoneyChanged` repaints the text, but the repainted value is
|
||||||
|
still that latched profile amount rather than the live alternate-currency
|
||||||
|
holding (or retail's `trade_num - m_last_sale` equivalent). Add a connected
|
||||||
|
regression for purchase success followed by the refreshed purse and item-cost
|
||||||
|
text; cover both the Buying tab and Items tab.
|
||||||
|
|
||||||
|
**2026-08-26 fix:** alternate-currency displays and Buy All affordability now
|
||||||
|
prefer the authoritative sum of player-owned currency stacks. On a successful
|
||||||
|
Buy/Buy All dispatch, retail's `m_last_sale` subtraction updates the Items and
|
||||||
|
Buying/Selling purse text immediately; the next matching currency add/update/
|
||||||
|
move/remove clears that optimistic subtraction and repaints from canonical
|
||||||
|
inventory. The vendor snapshot remains only the pre-observation fallback.
|
||||||
|
Automated coverage pins the immediate 10→8 display and the subsequent
|
||||||
|
authoritative 8→8 reconciliation. Owner check is in
|
||||||
|
`docs/research/2026-08-26-combined-client-parity-gate.md`.
|
||||||
|
|
||||||
## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing")
|
## #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
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
131
docs/research/2026-08-26-combined-client-parity-gate.md
Normal file
131
docs/research/2026-08-26-combined-client-parity-gate.md
Normal 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 A–C and E–F passed, including the mid-drag cursor-icon
|
||||||
|
re-test added during the round. Issues #444–#450 are owner-accepted. Section D
|
||||||
|
failed: the private paperdoll remained missing, so #443 stays open as the only
|
||||||
|
surviving defect. Tested executable SHA-256:
|
||||||
|
`173989F3C85C05C0746D628CDF9C6194F6A5E3EFD4597806BF83417483C37B42`.
|
||||||
|
|
||||||
|
Keep the client log for the whole run and take a screenshot for any visual or
|
||||||
|
text mismatch. For every refused item action, record the cursor color, exact
|
||||||
|
SpewBox line, and whether the item visibly moved before the refusal.
|
||||||
|
|
||||||
|
## A. Keyboard, camera, combat input, and relog — #446/#450
|
||||||
|
|
||||||
|
1. In Configure Keyboard, bind Move Forward to bare Shift while Toggle
|
||||||
|
Walk/Run already owns it. Verify the retail conflict dialog appears and the
|
||||||
|
chosen resolution is honored. Repeat with a known allowed shared chord.
|
||||||
|
2. Apply a changed binding, close/reopen Options, then restart the client.
|
||||||
|
Verify it survives. Test Revert, Defaults, Cancel, and Load/Save `.keymap`.
|
||||||
|
3. Press regular Enter: chat input must focus. Press keypad Enter outside chat:
|
||||||
|
it must perform only its configured camera action and must not focus chat.
|
||||||
|
4. Press bare Escape through the retail ladder: cancel target/focus/selection
|
||||||
|
first, then toggle Gameplay Options. It must never enter a developer orbit
|
||||||
|
or bird's-eye mode. Preserve the approved mouse-wheel zoom range.
|
||||||
|
5. Hold End, Page Down, or Delete in melee mode. The bar must charge while the
|
||||||
|
key is held and attack only on release, using the selected height.
|
||||||
|
6. Shift+Escape to character selection, immediately re-enter, and verify the
|
||||||
|
destination loads and exits portal space. Repeat twice in one process.
|
||||||
|
|
||||||
|
## B. Chat and combat text — #447/#448
|
||||||
|
|
||||||
|
1. Run `@acecommands`. Every non-empty server line must be visible; an
|
||||||
|
oversized response must retain the newest complete lines, not blank the
|
||||||
|
transcript.
|
||||||
|
2. Run `@acehelp acecommands` and send ordinary chat afterward. Verify normal
|
||||||
|
text, filtering, and scrolling remain intact.
|
||||||
|
3. Land ordinary and critical melee hits. Outgoing lines must match retail
|
||||||
|
wording and punctuation and contain no acdream-added percentage.
|
||||||
|
|
||||||
|
## C. Selection, use, containers, movement, and splitting — #445/#449
|
||||||
|
|
||||||
|
Use a normal item, unusable item, wearable, weapon, two mergeable stacks, a
|
||||||
|
Pyreal stack, one side pack, a full main pack, a full side pack, an open
|
||||||
|
external container, and a creature/player target.
|
||||||
|
|
||||||
|
1. Single-click and right-click inventory, side-pack, external-container, and
|
||||||
|
paperdoll items. Verify one global selection, stable highlight, status text,
|
||||||
|
and right-click examination.
|
||||||
|
2. Select an owned Pyreal stack. The toolbar must read
|
||||||
|
`<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.
|
||||||
43
docs/research/2026-08-26-issues-444-445-447-test-script.md
Normal file
43
docs/research/2026-08-26-issues-444-445-447-test-script.md
Normal 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.
|
||||||
745
docs/research/2026-08-26-retail-inventory-interaction-audit.md
Normal file
745
docs/research/2026-08-26-retail-inventory-interaction-audit.md
Normal 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 1–4 below are implemented and automated-test covered. Gameplay
|
||||||
|
refusals now use the `ClientLocal` SpewBox route; move/wield failure kinds are
|
||||||
|
complete; full move/drop/wield are request-first with pending projections;
|
||||||
|
owned-container and vendor-row mouse behavior follows the named retail
|
||||||
|
handlers; hover and release share one side-effect-free legality policy; and
|
||||||
|
local item-policy wording is composed from retail's exact literals.
|
||||||
|
|
||||||
|
The last toolbar uncertainty is also resolved. Raw retail bytes at
|
||||||
|
`gmToolbarUI::HandleSelectionChanged @ 0x004BF4EF` push format literal
|
||||||
|
`0x007B4748`, which decodes to `%d %hs (of %d)`. The owned Pyreal-stack branch
|
||||||
|
now renders that exact stack/name/total shape. The final AutoWield fallback was
|
||||||
|
also corrected: retail does not print the invented “That slot is already in
|
||||||
|
use”; with automatic unblocking enabled it moves the preferred occupied-slot
|
||||||
|
item to the backpack, waits for the authoritative move, then retries the
|
||||||
|
wield. Slice 5 remains deliberately deferred as the single combined connected
|
||||||
|
owner gate.
|
||||||
|
|
||||||
|
## Audited root causes (now fixed)
|
||||||
|
|
||||||
|
The inventory implementation was not missing one isolated rule. Most individual
|
||||||
|
operations existed and used the correct wire messages, but three seams made the
|
||||||
|
whole experience feel intermittent:
|
||||||
|
|
||||||
|
1. **Some retail-local refusal text was routed to a dead production callback.**
|
||||||
|
`ItemInteractionController` and `AutoWieldController` used a `toast` callback
|
||||||
|
for a substantial class of local rejections while `GameWindow` supplied
|
||||||
|
`null`. Retail sends these messages to the `ClientLocal` channel, which is
|
||||||
|
the SpewBox in acdream. The result was a real silent-failure class, not merely
|
||||||
|
different wording.
|
||||||
|
2. **Full moves, world drops, and wield operations mutated canonical inventory
|
||||||
|
state before the server accepted them.** Retail normally leaves the source
|
||||||
|
canonical object in place, adds a waiting/ghost projection at the intended
|
||||||
|
destination, and commits only after the authoritative object update. The
|
||||||
|
old optimistic mutation was reversible, but selection, capacity,
|
||||||
|
paperdoll, vendor, and other observers could see a transient state that never
|
||||||
|
existed on the server. This was the largest structural flakiness risk.
|
||||||
|
3. **Several list-specific mouse behaviors did not match retail.** In
|
||||||
|
particular, staged vendor rows could not be double-clicked or dragged to
|
||||||
|
remove them, staged rows lacked right-click examine, and owned side-pack
|
||||||
|
double-click/open ordering differed from retail.
|
||||||
|
|
||||||
|
The wire builders, global selection/split model, merge-first rule, request gate,
|
||||||
|
most right-click examine paths, normal item double-click use/equip, external
|
||||||
|
container pickup, paperdoll placement validation, and the newly repaired
|
||||||
|
vendor-split/main-pack-capacity paths are broadly aligned with retail.
|
||||||
|
|
||||||
|
The implementation was executed in this order:
|
||||||
|
|
||||||
|
1. Route every local item refusal through `ClientLocal`/SpewBox.
|
||||||
|
2. Replace canonical optimistic movement with retail-style pending projections.
|
||||||
|
3. Close the vendor staged-row and owned-container input differences.
|
||||||
|
4. Deepen hover/drop legality and finish exact status/failure text parity.
|
||||||
|
5. Run one connected interaction matrix across inventory, paperdoll, ground,
|
||||||
|
external containers, and vendors.
|
||||||
|
|
||||||
|
## Method and evidence standard
|
||||||
|
|
||||||
|
This audit used four evidence layers:
|
||||||
|
|
||||||
|
- The September 2013 named retail pseudo-C under
|
||||||
|
`docs/research/named-retail/acclient_2013_pseudo_c.txt`, searched by named
|
||||||
|
class and method before relying on older address-only material.
|
||||||
|
- Existing focused retail notes under `docs/research/`, especially the item,
|
||||||
|
drag, give, world-drop, use/autowear, and vendor investigations.
|
||||||
|
- The current production controllers, Runtime owners, UI input dispatch, wire
|
||||||
|
request builders, and communication routing.
|
||||||
|
- Existing focused tests, used to distinguish implemented intent from behavior
|
||||||
|
that is not currently protected.
|
||||||
|
|
||||||
|
Verdicts in this report mean:
|
||||||
|
|
||||||
|
- **Match:** the important retail behavior and ownership rule are present.
|
||||||
|
- **Partial:** the common path matches, but a retail branch, presentation rule,
|
||||||
|
or failure path is absent.
|
||||||
|
- **Mismatch:** direct retail evidence contradicts the current behavior.
|
||||||
|
- **Risk:** the mechanism differs in a way likely to produce transient or race
|
||||||
|
defects, but this audit does not assert a particular live symptom without a
|
||||||
|
connected reproduction.
|
||||||
|
- **Gate pending:** a code fix exists in the working tree and has automated
|
||||||
|
coverage, but the owner has not yet accepted the live behavior.
|
||||||
|
|
||||||
|
## Retail reference model
|
||||||
|
|
||||||
|
### One selected object and one split quantity
|
||||||
|
|
||||||
|
Retail has a client-global selected object. Clicking an item selects it;
|
||||||
|
right-click first selects it and then examines it; beginning a drag selects it
|
||||||
|
if it was not already selected. The toolbar observes that global selection and
|
||||||
|
shows the name, stack quantity, and split controls.
|
||||||
|
|
||||||
|
The split quantity is also global and applies only when the dragged/requested
|
||||||
|
object is the selected object. An unselected stack always means the full stack.
|
||||||
|
Changing selection resets/reseeds the split amount. Vendor-owned selected
|
||||||
|
stacks use a different initial amount from normal owned stacks.
|
||||||
|
|
||||||
|
Primary anchors:
|
||||||
|
|
||||||
|
- `UIElement_ItemList::ListenToElementMessage` at `0x004E4D50`
|
||||||
|
- `UIElement_ItemList::BeginDrag` at `0x004E32D0`
|
||||||
|
- `gmToolbarUI::HandleSelectionChanged` at `0x004BF380`
|
||||||
|
- `ItemHolder::GetObjectSplitSize` in the named retail pseudo-C
|
||||||
|
|
||||||
|
### Mouse-down establishes intent; click completion performs list action
|
||||||
|
|
||||||
|
For a retail item-list entry, left press first gives target mode a chance to
|
||||||
|
consume the object. Otherwise it selects the object. A container-list entry
|
||||||
|
also opens that child container and updates its open indicator in this same
|
||||||
|
item-list message path.
|
||||||
|
|
||||||
|
Right press selects and examines. Double-click invokes generic `UseObject` for
|
||||||
|
ordinary list items, but the generic double-use path is suppressed for an
|
||||||
|
owned `containerList` entry. The ground/external root is explicitly allowed.
|
||||||
|
|
||||||
|
This distinction matters: a side pack is opened as a container, not opened and
|
||||||
|
then generically used as an ordinary item on the second click.
|
||||||
|
|
||||||
|
### Dragging is a request with pending presentation
|
||||||
|
|
||||||
|
Beginning a physical-item drag produces a source waiting/ghost state. Vendor,
|
||||||
|
salvage, and shortcut lists are special list types and do not use the same
|
||||||
|
physical-source waiting ghost.
|
||||||
|
|
||||||
|
Hover is advisory and silent. Release reruns legality with feedback enabled.
|
||||||
|
For a normal container move, retail retains the canonical source ownership and
|
||||||
|
adds a pending destination projection. The server's authoritative object update
|
||||||
|
commits the move. Rejection removes the pending projection and prints the local
|
||||||
|
failure. This same general principle appears in world placement and split-to-
|
||||||
|
world handling.
|
||||||
|
|
||||||
|
Primary anchors:
|
||||||
|
|
||||||
|
- `UIElement_ItemList::BeginDrag` at `0x004E32D0`
|
||||||
|
- `UIElement_ItemList::DragOver` at `0x004E3400`
|
||||||
|
- `UIElement_ItemList::AcceptDragObject` at `0x004E4250`
|
||||||
|
- `UIElement_ItemList::HandleDropRelease` at `0x004E4790`
|
||||||
|
- `ItemHolder::AttemptToPlaceInContainer_IsItemLegal` at `0x005870C0`
|
||||||
|
- `ItemHolder::AttemptToPlaceInContainer_IsContainerLegal` at `0x005879B0`
|
||||||
|
- `ItemHolder::WillItemFitInContainer` at `0x00587D60`
|
||||||
|
- `ItemHolder::IsDragIntoContainerAttemptLegal` at `0x00587E90`
|
||||||
|
|
||||||
|
### Drop target dispatch is ordered
|
||||||
|
|
||||||
|
Retail's three-dimensional drop/give dispatcher follows this practical order:
|
||||||
|
|
||||||
|
1. Require an owned, movable source that is not currently in trade.
|
||||||
|
2. Dropping on self means the main backpack.
|
||||||
|
3. Target zero means ground placement or split-to-world.
|
||||||
|
4. Try stack merge before treating the target as a container.
|
||||||
|
5. A player target opens/routes through secure trade.
|
||||||
|
6. A creature target uses give-item behavior.
|
||||||
|
7. A container target must be open, unlocked, and legal.
|
||||||
|
8. Vendor lists use their own staging rules.
|
||||||
|
9. Otherwise resolve as a ground placement or refuse it.
|
||||||
|
|
||||||
|
`AttemptMerge` uses the selected split amount, clamps to target capacity, sends
|
||||||
|
the merge request, and selects the target stack. Give-item is request-only; it
|
||||||
|
does not optimistically remove the source from canonical inventory.
|
||||||
|
|
||||||
|
Primary anchors:
|
||||||
|
|
||||||
|
- `ItemHolder::AttemptMerge` at `0x005878F0`
|
||||||
|
- `ItemHolder::AttemptPlaceIn3D` at `0x00588600`
|
||||||
|
- `docs/research/2026-07-13-retail-give-item-pseudocode.md`
|
||||||
|
- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md`
|
||||||
|
|
||||||
|
### Use and equip
|
||||||
|
|
||||||
|
Generic double-click use passes through `ItemHolder::DetermineUseResult` and
|
||||||
|
`ItemHolder::UseObject`, with a short use throttle. The item is classified as
|
||||||
|
direct-use, targeted-use, pickup, equip/autowear, trade, salvage, or game use.
|
||||||
|
Retail locally refuses invalid states and prints a `ClientLocal` message.
|
||||||
|
|
||||||
|
Paperdoll 3D clicks and discrete equipment-slot lists share the same global
|
||||||
|
selection/examine model. Dropping on a paperdoll location validates the exact
|
||||||
|
location, then chooses auto-wear or auto-wield behavior. Clothing overlap can
|
||||||
|
be rejected locally; weapon replacement has different rules.
|
||||||
|
|
||||||
|
Primary anchors:
|
||||||
|
|
||||||
|
- `ItemHolder::DetermineUseResult` at `0x00588460`
|
||||||
|
- `ItemHolder::UseObject` at `0x00588A80`
|
||||||
|
- `CPlayerSystem::UsingItem` at `0x00562F70`
|
||||||
|
- `gmPaperDollUI::ListenToElementMessage` at `0x004A5C30`
|
||||||
|
- `gmPaperDollUI::AcceptDragObject` at `0x004A3B10`
|
||||||
|
- `gmPaperDollUI::AcceptPaperDollDragObject` at `0x004A4A70`
|
||||||
|
- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md`
|
||||||
|
|
||||||
|
### Vendor rows are active item lists
|
||||||
|
|
||||||
|
Direct named-retail evidence establishes these behaviors:
|
||||||
|
|
||||||
|
- Double-clicking a vendor browse row buys one item.
|
||||||
|
- Double-clicking a staged buying row removes it and prints
|
||||||
|
“Removing %s from shopping list” through `ClientLocal`.
|
||||||
|
- Double-clicking a staged selling row removes it, clears its sell state, and
|
||||||
|
prints the same form of message.
|
||||||
|
- Dragging an already-staged selling row removes it from the staged list.
|
||||||
|
- If a partial split is selected while dragging a staged selling row, retail
|
||||||
|
refuses to split that row, prints “You cannot split items from this panel”,
|
||||||
|
and resets the split control to the stack maximum.
|
||||||
|
- A new partial-stack drag into the Selling list sends a split request, creates
|
||||||
|
a temporary staged row, and replaces that row when the new matching object
|
||||||
|
arrives.
|
||||||
|
- Hover rejection is silent; release rejection prints to `ClientLocal`.
|
||||||
|
|
||||||
|
Primary anchors:
|
||||||
|
|
||||||
|
- `gmVendorUI::HandleMousePresses` at `0x004C40D0`
|
||||||
|
- `gmVendorUI::RecvNotice_ItemListBeginDrag` at `0x004C4380`
|
||||||
|
- `VendorSellUI::DragItemAcceptable` at `0x004C20C0`
|
||||||
|
- `VendorSellUI::AcceptDragObject` at `0x004C4F00`
|
||||||
|
- `VendorSellUI::ItemAttributesChanged` at `0x004C3FD0`
|
||||||
|
|
||||||
|
This corrects an older project research conclusion: browse-row double-click
|
||||||
|
buy is retail behavior. It is not an acdream modernization.
|
||||||
|
|
||||||
|
### Feedback destination
|
||||||
|
|
||||||
|
Retail item-policy and request-failure messages are sent on the local client
|
||||||
|
text channel. In acdream, `RuntimeCommunicationState.AddText` maps
|
||||||
|
`ClientLocal` (`0x1A`) to the SpewBox only: it does not add the line to the
|
||||||
|
chat transcript and does not apply a chat timestamp.
|
||||||
|
|
||||||
|
Hover failures are normally silent. Release/action failures are not. Server
|
||||||
|
request failures are composed by `ACCWeenieObject::ServerSaysAttemptFailed`
|
||||||
|
at `0x0058EAE0`, including move and wield failures.
|
||||||
|
|
||||||
|
## Current acdream ownership and routing
|
||||||
|
|
||||||
|
The relevant production flow is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UiRoot / UiItemSlot
|
||||||
|
-> InventoryController | ExternalContainerController | PaperdollController
|
||||||
|
| VendorUiController | SelectedObjectController
|
||||||
|
-> ItemInteractionController / AutoWieldController
|
||||||
|
-> RuntimeInventoryState + RuntimeActionState transactions
|
||||||
|
-> ClientObjectTable (canonical object ownership)
|
||||||
|
-> outbound request builder
|
||||||
|
-> authoritative object update / request failure
|
||||||
|
-> RuntimeCommunicationState.ClientLocal -> SpewBox
|
||||||
|
```
|
||||||
|
|
||||||
|
Important owners:
|
||||||
|
|
||||||
|
- `SelectionState` is the sole selected-object owner shared by inventory,
|
||||||
|
paperdoll, vendor, world selection, and toolbar status.
|
||||||
|
- `RuntimeInventoryState` owns external-container state, item-use transaction
|
||||||
|
state, shared busy/request state, split/pending placement state, and borrows
|
||||||
|
the canonical `ClientObjectTable`.
|
||||||
|
- `SelectedObjectController` projects selection into the authored toolbar and
|
||||||
|
owns the split-slider presentation.
|
||||||
|
- `ItemInteractionController` classifies use/drop/give/move operations and
|
||||||
|
sends requests.
|
||||||
|
- `InventoryController`, `ExternalContainerController`, `PaperdollController`,
|
||||||
|
and `VendorUiController` own their list-specific input and projections.
|
||||||
|
|
||||||
|
This ownership shape aligns with the architecture document. The central issue
|
||||||
|
is not duplicate state; it is which state is mutated before acknowledgement.
|
||||||
|
|
||||||
|
## Behavior matrix
|
||||||
|
|
||||||
|
| Surface/action | Retail | Current acdream | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Inventory left press | Target-mode consume, otherwise select | `PrimaryItemPressed` does the same | Match |
|
||||||
|
| Ordinary item single click | Select; no generic use | Mouse-down selects | Match |
|
||||||
|
| Ordinary item double-click | Generic use/equip | `DoubleClicked = ActivateItem` | Match |
|
||||||
|
| Owned side-pack single press | Select and open in the item-list handler | Selects and opens on mouse-down | Match, implemented |
|
||||||
|
| Owned side-pack double-click | Open behavior; generic item double-use suppressed | Opens once; generic activation is suppressed | Match, implemented |
|
||||||
|
| Inventory right-click | Select, then examine | Select and examine | Match |
|
||||||
|
| Drag lift | Select if needed; source ghost | Selects and ghosts | Match |
|
||||||
|
| Drag hover | Silent, legality-aware green/red | Silent and shares the release legality decision | Match, implemented |
|
||||||
|
| Full internal move | Request plus pending destination projection; canonical source waits for server | Request-first pending projection; authoritative update commits | Match, implemented |
|
||||||
|
| Merge stacks | Merge before container placement; selected split amount; select target | Same broad behavior | Match |
|
||||||
|
| Partial move to container | Split request; wait for authoritative object | Request-only | Match |
|
||||||
|
| Drop to ground | Request/pending presentation; source remains canonical until response | Request-first; canonical source waits for response | Match, implemented |
|
||||||
|
| Split to ground | Global pending split; select arriving matching object; timeout | Request/pending path exists | Broad match |
|
||||||
|
| Pick up from ground | Pending destination projection; authoritative commit | Pending destination path | Match |
|
||||||
|
| Open external container | Root/nested list-specific behavior | Root double-click, nested open behavior | Broad match |
|
||||||
|
| Move to external container | Request-only, open/unlocked legality, server commit | Request-only with shared hover/release legality | Match, implemented |
|
||||||
|
| Give to creature | Request-only; selected split amount | Request-only | Match |
|
||||||
|
| Give/drop to player | Secure-trade routing | Secure-trade routing exists | Broad match |
|
||||||
|
| Paperdoll click/right-click | Global select/examine | Global select/examine | Match |
|
||||||
|
| Paperdoll drag equip | Exact location validation; auto-wear/wield | Same broad split | Broad match |
|
||||||
|
| Full wield | Authoritative request model | Request-first; canonical ownership waits for response | Match, implemented |
|
||||||
|
| Invalid item use/equip | ClientLocal text in SpewBox | Shared `ReportClientLocal` route | Match, implemented |
|
||||||
|
| Selected status | Normal name or `{quantity} name`; owned coin is `%d %hs (of %d)` | Both branches implemented | Match, implemented |
|
||||||
|
| Split applicability | Only selected stack uses global quantity | Same | Match |
|
||||||
|
| Vendor browse single/right | Select; right-click examine | Select and right-click examine | Match |
|
||||||
|
| Vendor browse double | Buy one | Buy one | Match |
|
||||||
|
| Drag inventory to Selling | Stage full or selected partial quantity | Present; partial temp-row replacement present | Match, #445 gate pending |
|
||||||
|
| Vendor hover refusal | Silent | Silent | Match |
|
||||||
|
| Vendor release refusal | ClientLocal/SpewBox | System message/SpewBox path | Match |
|
||||||
|
| Staged Buying double-click | Remove one + SpewBox line | Same | Match, implemented |
|
||||||
|
| Staged Selling double-click | Remove row, clear state + SpewBox line | Same | Match, implemented |
|
||||||
|
| Staged Selling drag | Remove row; partial selection warns and resets split | Same, exact refusal + reset | Match, implemented |
|
||||||
|
| Staged row right-click | Generic select/examine item-list behavior | Select and examine on every vendor list role | Match, implemented |
|
||||||
|
| Main-pack capacity | Items and carried containers counted separately | Separate loose-item count now present | Match, #449 gate pending |
|
||||||
|
| Server move/wield failure text | Exact ClientLocal move/wield compositions | Both request kinds and compositions present | Match, implemented |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### F1 — local inventory refusals can be completely silent
|
||||||
|
|
||||||
|
**Resolution:** CLOSED IN CODE — one `ReportClientLocal` route now selects
|
||||||
|
interface text, system text, or the test fallback in that order.
|
||||||
|
|
||||||
|
**Priority:** P0
|
||||||
|
|
||||||
|
**Confidence:** Confirmed by production composition
|
||||||
|
|
||||||
|
`ItemInteractionController` uses two different presentation routes:
|
||||||
|
|
||||||
|
- `_systemMessage` / `_interfaceText`, which are wired to
|
||||||
|
`RuntimeCommunicationState.AddText(..., ClientLocal)` and reach SpewBox.
|
||||||
|
- `_toast`, used by many local policy refusals.
|
||||||
|
|
||||||
|
`InteractionRetainedUiComposition` forwards its `toast` dependency, but
|
||||||
|
`GameWindow` currently sets the production composition toast to `null` after
|
||||||
|
the developer-toast surface was removed. Consequently, the local rejection
|
||||||
|
still aborts the action, but the user receives no explanation.
|
||||||
|
|
||||||
|
Affected classes include invalid item use, missing use target, trade/wield
|
||||||
|
requirements, locked or unsuitable targets, invalid move/give/drop states,
|
||||||
|
midair/drop refusal, and paperdoll slot-in-use refusal. Exact membership should
|
||||||
|
be frozen in a focused message-routing test before changing it.
|
||||||
|
|
||||||
|
Retail evidence is unambiguous: these are local client text messages and belong
|
||||||
|
in SpewBox, not a transient developer toast.
|
||||||
|
|
||||||
|
**Future fix:** remove the semantic split for gameplay failure text. Give item
|
||||||
|
controllers one `ClientLocal` sink and reserve any visual toast mechanism for
|
||||||
|
non-retail developer/launcher notifications.
|
||||||
|
|
||||||
|
### F2 — optimistic canonical moves expose impossible intermediate state
|
||||||
|
|
||||||
|
**Resolution:** CLOSED IN CODE — full move, world drop, and wield dispatch
|
||||||
|
requests without mutating canonical ownership; pending source/destination
|
||||||
|
presentation converges on confirmation, failure, and reset.
|
||||||
|
|
||||||
|
**Priority:** P0 architectural correction
|
||||||
|
|
||||||
|
**Confidence:** Confirmed mechanism divergence; symptom linkage requires gates
|
||||||
|
|
||||||
|
The full-stack internal move and world-drop paths use optimistic operations
|
||||||
|
against the canonical object table. Full wield uses the same pattern. Failure
|
||||||
|
rollback exists, but all borrowers can observe the speculative state:
|
||||||
|
|
||||||
|
- selection and toolbar status;
|
||||||
|
- loose-item and carried-container capacity;
|
||||||
|
- paperdoll slots;
|
||||||
|
- vendor sell eligibility/staging;
|
||||||
|
- external-container views;
|
||||||
|
- plugins and Runtime views.
|
||||||
|
|
||||||
|
Retail instead keeps source canonical ownership stable and uses waiting/ghost
|
||||||
|
presentation at the intended destination until the server update arrives.
|
||||||
|
|
||||||
|
This does not prove that every reported intermittent inventory symptom comes
|
||||||
|
from this seam. It does explain why otherwise-correct controllers can disagree
|
||||||
|
briefly and why a rejection/late response/re-entrant action can make the UI feel
|
||||||
|
flaky.
|
||||||
|
|
||||||
|
**Future fix:** model full move/drop/wield like the existing request-only split,
|
||||||
|
give, ground-pickup, and external-container paths. Store a generation-scoped
|
||||||
|
pending placement intent and presentation ghost, send the request, and let the
|
||||||
|
authoritative update commit canonical ownership. On failure/timeout/reset,
|
||||||
|
remove only the pending presentation.
|
||||||
|
|
||||||
|
### F3 — vendor staged-row removal behavior is missing
|
||||||
|
|
||||||
|
**Resolution:** CLOSED IN CODE — staged rows implement the retail
|
||||||
|
double-click, right-click, drag-lift, message, and split-reset branches.
|
||||||
|
|
||||||
|
**Priority:** P1
|
||||||
|
|
||||||
|
**Confidence:** Confirmed by direct named-retail functions
|
||||||
|
|
||||||
|
Current staged Buying and Selling rows only bind selection. They have no
|
||||||
|
double-click removal. Selling rows also disable drag source behavior.
|
||||||
|
|
||||||
|
Retail supports:
|
||||||
|
|
||||||
|
- double-click staged Buying to remove;
|
||||||
|
- double-click staged Selling to remove and clear sell state;
|
||||||
|
- drag staged Selling to remove;
|
||||||
|
- a precise ClientLocal removal line;
|
||||||
|
- a partial-split refusal/reset when dragging from the staged Selling list.
|
||||||
|
|
||||||
|
**Future fix:** add list-role-specific actions rather than routing these rows
|
||||||
|
through generic item activation. Protect each action with unit tests that also
|
||||||
|
assert selection, sell-state cleanup, totals, and exact SpewBox routing.
|
||||||
|
|
||||||
|
### F4 — owned side-pack click/double-click sequencing differs
|
||||||
|
|
||||||
|
**Resolution:** CLOSED IN CODE — carried containers open on press and the
|
||||||
|
generic double-use route is suppressed for that list role.
|
||||||
|
|
||||||
|
**Priority:** P1
|
||||||
|
|
||||||
|
**Confidence:** Confirmed structural mismatch
|
||||||
|
|
||||||
|
Retail opens a carried child container in the item-list press handler and
|
||||||
|
suppresses generic double-click use for a `containerList` item. acdream selects
|
||||||
|
on mouse-down, opens on completed click, and binds generic activation to double
|
||||||
|
click for every inventory cell. `UiRoot` emits the second click before the
|
||||||
|
double-click event, so a double-click can both open and activate the pack.
|
||||||
|
|
||||||
|
This is a plausible source of redundant requests and awkward drag/open
|
||||||
|
interactions. It should be fixed by explicit item-list role, not by a global
|
||||||
|
double-click timing change, because ordinary items and the external-container
|
||||||
|
root intentionally retain double-click use/open behavior.
|
||||||
|
|
||||||
|
### F5 — hover acceptance is less strict than release/server legality
|
||||||
|
|
||||||
|
**Resolution:** CLOSED IN CODE — `InventoryContainerPlacementPolicy` is the
|
||||||
|
shared silent-hover/speaking-release decision for owned and external lists.
|
||||||
|
|
||||||
|
**Priority:** P1/P2
|
||||||
|
|
||||||
|
**Confidence:** Confirmed code difference
|
||||||
|
|
||||||
|
Inventory-grid hover mostly checks list role, basic object class, and capacity.
|
||||||
|
External-container hover is broader still. Retail's predicates incorporate
|
||||||
|
ownership, trade state, source/destination identity, real carrying-container
|
||||||
|
restrictions, open/locked state, destination capacity type, and other legal
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
The practical symptom is a green cursor followed by a refusal or apparent
|
||||||
|
no-op on release. Hover must remain silent, but its boolean should be produced
|
||||||
|
from the same pure legality decision used at release.
|
||||||
|
|
||||||
|
**Future fix:** extract one side-effect-free placement decision that returns a
|
||||||
|
reason code. Hover consumes only allowed/denied; release converts the same
|
||||||
|
reason to exact ClientLocal text.
|
||||||
|
|
||||||
|
### F6 — selected status lacks retail's owned-coin special case
|
||||||
|
|
||||||
|
**Resolution:** CLOSED IN CODE — the PDB-matched retail executable resolves
|
||||||
|
the literal at `0x007B4748` to `%d %hs (of %d)`; the controller now reads the
|
||||||
|
player's `CoinValue` and uses that exact branch for owned WCID 273 stacks.
|
||||||
|
|
||||||
|
**Priority:** P2
|
||||||
|
|
||||||
|
**Confidence:** Byte-resolved from the PDB-matched retail executable
|
||||||
|
|
||||||
|
Normal current text—name for a singleton and `{stackSize} {name}` for a
|
||||||
|
stack—matches the main retail branch. Retail has an additional owned-coinstack
|
||||||
|
formatting branch that derives a total/value-aware display and name. The
|
||||||
|
current controller always uses the generic stack prefix.
|
||||||
|
|
||||||
|
Binary inspection resolves the apparent vtable-symbol artifact: the raw call
|
||||||
|
site pushes `0x007B4748`, `%d %hs (of %d)`, with stack size, appropriate name,
|
||||||
|
and the player's integer `CoinValue` as its three arguments.
|
||||||
|
|
||||||
|
### F7 — request failure coverage omits move and wield kinds
|
||||||
|
|
||||||
|
**Resolution:** CLOSED IN CODE — both request kinds are represented and route
|
||||||
|
through the item-aware retail failure composer.
|
||||||
|
|
||||||
|
**Priority:** P2
|
||||||
|
|
||||||
|
**Confidence:** Confirmed enum/composer gap
|
||||||
|
|
||||||
|
Retail's `ServerSaysAttemptFailed` includes move and wield result families.
|
||||||
|
The current request-failure model and `InventoryFailureMessages` cover merge,
|
||||||
|
split, pickup, put, drop, and give, but do not represent the retail move/wield
|
||||||
|
families. A server-side failure in those operations therefore cannot produce
|
||||||
|
the exact item-aware retail sentence through the common composer.
|
||||||
|
|
||||||
|
### F8 — current retail-divergence documentation is wrong about vendor double-click
|
||||||
|
|
||||||
|
**Resolution:** CLOSED — the older research is corrected and AP-171 retired.
|
||||||
|
|
||||||
|
**Priority:** Documentation correction before implementation
|
||||||
|
|
||||||
|
**Confidence:** Confirmed by direct named-retail evidence
|
||||||
|
|
||||||
|
Older vendor research and AP-171 characterize double-click browse-row purchase
|
||||||
|
as an acdream enhancement. `gmVendorUI::HandleMousePresses` directly calls
|
||||||
|
`BuySingleItem` on the retail Items-list double-click. Current browse behavior
|
||||||
|
is correct; the documentation is not. Leaving this claim in the register risks
|
||||||
|
a future parity cleanup deleting a retail feature.
|
||||||
|
|
||||||
|
### F9 — #445 and #449 need connected acceptance, not more inference
|
||||||
|
|
||||||
|
**Priority:** Gate now
|
||||||
|
|
||||||
|
**Confidence:** Automated fixes present
|
||||||
|
|
||||||
|
- #445 now uses the selected split quantity for vendor selling, creates a
|
||||||
|
temporary staged row, and replaces it when the authoritative split object
|
||||||
|
arrives.
|
||||||
|
- #449 now counts loose items separately from carried container objects when
|
||||||
|
deciding whether the main backpack is full.
|
||||||
|
|
||||||
|
Both have focused tests in the current working tree. Neither should be marked
|
||||||
|
closed until a live server gate covers success, refusal, repeated action, and
|
||||||
|
selection changes.
|
||||||
|
|
||||||
|
### F10 — paperdoll disappearance is a separate rendering/residency defect
|
||||||
|
|
||||||
|
**Priority:** Keep separate from transaction fixes
|
||||||
|
|
||||||
|
**Confidence:** Existing issue #443
|
||||||
|
|
||||||
|
The intermittent missing paperdoll that heals after a delay is tracked as
|
||||||
|
paperdoll first-open/residency behavior. It can make a correct equip transaction
|
||||||
|
look broken, so it belongs in the combined user gate, but it should not be
|
||||||
|
folded into inventory ownership or input logic without evidence.
|
||||||
|
|
||||||
|
## SpewBox contract
|
||||||
|
|
||||||
|
The following should appear in the SpewBox through `ClientLocal` when the user
|
||||||
|
commits the action and it is refused or changed:
|
||||||
|
|
||||||
|
- invalid use/equip/wield state;
|
||||||
|
- “choose a target” or invalid target;
|
||||||
|
- cannot move/drop/give an item;
|
||||||
|
- locked, closed, full, or otherwise illegal destination;
|
||||||
|
- merge/split/pickup/put/drop/give/move/wield request failure;
|
||||||
|
- vendor item cannot be sold or split in that list;
|
||||||
|
- removal from a vendor shopping/selling list;
|
||||||
|
- automatic removal of conflicting wear items where retail reports it;
|
||||||
|
- midair or other locally cancelled placement when retail reports it.
|
||||||
|
|
||||||
|
The following should be silent:
|
||||||
|
|
||||||
|
- merely hovering a rejected drop target;
|
||||||
|
- moving the pointer away without releasing;
|
||||||
|
- ordinary selection changes;
|
||||||
|
- beginning a legal drag.
|
||||||
|
|
||||||
|
These messages should not be duplicated into the normal chat log and should
|
||||||
|
not gain chat timestamps. That is already how `ClientLocal` behaves in the
|
||||||
|
communication owner.
|
||||||
|
|
||||||
|
## Existing automated coverage
|
||||||
|
|
||||||
|
The repository already has strong narrow coverage in:
|
||||||
|
|
||||||
|
- `InventoryControllerTests`: population, selection, open/right-click,
|
||||||
|
drag/ghost, pending pickup, split, merge, capacity, rollback, and #449.
|
||||||
|
- `ExternalContainerControllerTests`: root/nested behavior, selection,
|
||||||
|
right-click, partial split, and pending gates.
|
||||||
|
- `PaperdollControllerTests`: selection, examine, drag, and wield placement.
|
||||||
|
- `SelectedObjectControllerTests`: name, stack status, slider, and vendor split
|
||||||
|
initialization.
|
||||||
|
- `VendorUiControllerTests`: browse, buy quantities, selection/examine,
|
||||||
|
staging, partial vendor split/failure, rejection feedback, and alternate
|
||||||
|
currency.
|
||||||
|
- `ItemInteractionControllerTests`: use/equip, world drop, give, partial-stack
|
||||||
|
behavior, failures, and transaction lifecycle.
|
||||||
|
- Runtime inventory tests: request ownership, reset, and lifecycle behavior.
|
||||||
|
|
||||||
|
The pre-implementation test suite was strongest at proving controller-local
|
||||||
|
intent. The implementation program below adds the missing transaction and
|
||||||
|
cross-controller coverage.
|
||||||
|
|
||||||
|
## Automated gates added by the implementation
|
||||||
|
|
||||||
|
The implementation adds or updates coverage for the following:
|
||||||
|
|
||||||
|
1. A production-composition test proving every local policy rejection reaches
|
||||||
|
`ClientLocal`/SpewBox and no gameplay failure depends on a toast callback.
|
||||||
|
2. Owned side-pack single/double-click tests proving one open action and no
|
||||||
|
generic use request, including the second-click event order.
|
||||||
|
3. Vendor staged Buying and Selling double-click removal tests with exact
|
||||||
|
selection, totals, state cleanup, and message assertions.
|
||||||
|
4. Vendor staged Selling drag-to-remove and selected-partial split-reset tests.
|
||||||
|
5. Staged vendor-row right-click select/examine tests.
|
||||||
|
6. A table-driven pure legality test shared by hover and release for inventory,
|
||||||
|
external container, ground, player, creature, vendor, self, locked container,
|
||||||
|
full item slots, and full container slots.
|
||||||
|
7. Owned coinstack toolbar-status parity using the byte-resolved exact format.
|
||||||
|
8. Move and wield authoritative failure-composition tests.
|
||||||
|
9. Transaction-observer tests proving canonical ownership does not change
|
||||||
|
before acknowledgement while selection, capacity, vendor, and paperdoll
|
||||||
|
borrow the same state.
|
||||||
|
10. Re-entrant sequences: drag while a request is pending, selection change
|
||||||
|
during split, rejection after container close, late response after session
|
||||||
|
reset, and repeated action after rollback.
|
||||||
|
|
||||||
|
## Executed implementation program
|
||||||
|
|
||||||
|
### Slice 1 — feedback integrity — COMPLETE
|
||||||
|
|
||||||
|
- Replace gameplay `toast` refusal calls with the shared ClientLocal sink.
|
||||||
|
- Add the missing move/wield failure kinds and exact item-aware compositions.
|
||||||
|
- Freeze hover-silent versus release-speaks behavior.
|
||||||
|
- Correct the vendor double-click documentation claim.
|
||||||
|
|
||||||
|
This is small, high-confidence, and immediately turns “nothing happened” into
|
||||||
|
an actionable player explanation.
|
||||||
|
|
||||||
|
### Slice 2 — authoritative placement ownership — COMPLETE
|
||||||
|
|
||||||
|
- Introduce one generation-scoped pending placement record for full move,
|
||||||
|
world drop, and wield.
|
||||||
|
- Preserve canonical source ownership until the authoritative object update.
|
||||||
|
- Project source waiting/ghost and destination pending visuals separately.
|
||||||
|
- Converge success, refusal, timeout, disconnect, and late-response cleanup.
|
||||||
|
- Prove all borrowed observers see either pre-commit or committed state, never
|
||||||
|
a speculative canonical move.
|
||||||
|
|
||||||
|
This is the most important solidity work and should receive dual review because
|
||||||
|
it crosses Runtime ownership and retained presentation.
|
||||||
|
|
||||||
|
### Slice 3 — item-list mouse parity — COMPLETE
|
||||||
|
|
||||||
|
- Make carried-container press/open and double-click suppression explicit.
|
||||||
|
- Add staged vendor double-click removal.
|
||||||
|
- Add staged Selling drag-to-remove and split reset/refusal.
|
||||||
|
- Restore right-click select/examine consistently across vendor list roles.
|
||||||
|
|
||||||
|
### Slice 4 — shared legality and exact presentation — COMPLETE
|
||||||
|
|
||||||
|
- Unify hover/release placement decisions with reason codes.
|
||||||
|
- Add the owned-coinstack toolbar branch after capturing exact retail text.
|
||||||
|
- Reconcile hard-coded local item wording with DAT-backed retail strings.
|
||||||
|
|
||||||
|
### Automated verification — COMPLETE
|
||||||
|
|
||||||
|
- Focused inventory/external-container/paperdoll/vendor/selection/item-use
|
||||||
|
matrix: 328 passed, 0 failed.
|
||||||
|
- Cross-controller retained-UI interaction flow: 10 passed, 0 failed.
|
||||||
|
- Complete Release build: 0 warnings, 0 errors.
|
||||||
|
- Repository hermetic lane (the exact release filter, serial execution):
|
||||||
|
15,755 passed, 0 skipped, 0 failed across 14 test assemblies.
|
||||||
|
|
||||||
|
The repository wrapper's project-consistency preflight explicitly excludes the
|
||||||
|
tracked deployment-only ACE comparison mods under `tools/ace-mods/`. They
|
||||||
|
compile against a separately installed ACE server and intentionally remain
|
||||||
|
outside `AcDream.slnx`; the portable product graph still owns every other
|
||||||
|
project under `src/`, `tests/`, and `tools/`.
|
||||||
|
|
||||||
|
### Slice 5 — connected closure — DEFERRED OWNER GATE
|
||||||
|
|
||||||
|
Run the manual matrix below against ACE using an exact built binary and retain
|
||||||
|
logs/screenshots for failures. Close #445 and #449 only after their rows pass.
|
||||||
|
Keep #443 independent unless the evidence links paperdoll rendering to an
|
||||||
|
inventory acknowledgement.
|
||||||
|
|
||||||
|
## Connected manual matrix
|
||||||
|
|
||||||
|
Use one normal item, one wearable item, one wieldable item, two mergeable
|
||||||
|
stacks, one side pack, a full main backpack, a full side pack, an open chest,
|
||||||
|
a locked/closed container if available, a creature/player target, and a vendor
|
||||||
|
with normal and alternate currency.
|
||||||
|
|
||||||
|
1. Single-click each item/list type; verify selection border and exact status.
|
||||||
|
2. Right-click inventory, side-pack, external-container, paperdoll, browse,
|
||||||
|
Buying, and Selling rows; verify selection and examine.
|
||||||
|
3. Double-click ordinary usable, wearable, wieldable, and unusable items;
|
||||||
|
verify one request and correct SpewBox refusal where applicable.
|
||||||
|
4. Single- and double-click a carried side pack; verify one open action, no
|
||||||
|
redundant generic use, and stable selection.
|
||||||
|
5. Drag a full item between main pack and side pack; observe source/destination
|
||||||
|
before response, after success, and after forced rejection.
|
||||||
|
6. Fill a side pack, reject a move, free one slot, and retry immediately.
|
||||||
|
7. Fill the main pack with loose items while carrying side packs; verify item
|
||||||
|
and container capacities independently (#449).
|
||||||
|
8. Merge full and partial stacks; verify selected split amount, target
|
||||||
|
selection, source remainder, and full-target refusal text.
|
||||||
|
9. Split to an inventory container, external container, creature, ground, and
|
||||||
|
vendor; change selection while the request is pending.
|
||||||
|
10. Drop full and partial stacks to ground; verify ghost/pending behavior,
|
||||||
|
selected arriving object, rejection cleanup, and no duplicate item.
|
||||||
|
11. Pick up from ground into a nearly full destination, then retry after
|
||||||
|
freeing capacity.
|
||||||
|
12. Equip by double-click and by paperdoll drag; test clothing conflict and
|
||||||
|
weapon replacement. Verify source/paperdoll state before acknowledgement.
|
||||||
|
13. Drag full and partial stacks to vendor Selling; verify exact quantities,
|
||||||
|
temp-row replacement, totals, and #445 behavior.
|
||||||
|
14. Double-click staged Buying and Selling rows to remove them; verify SpewBox
|
||||||
|
text and state cleanup.
|
||||||
|
15. Drag a staged Selling row to remove it; repeat with a partial split selected
|
||||||
|
and verify refusal plus slider reset.
|
||||||
|
16. Complete/cancel transactions in normal and alternate currency; verify
|
||||||
|
currency balance refresh (#444) and selection/status stability.
|
||||||
|
17. Repeat representative actions while another inventory request is pending,
|
||||||
|
immediately after rejection, and immediately after reopening a container.
|
||||||
|
18. Log out/portal/re-enter with a pending or recently completed interaction;
|
||||||
|
verify the request ledger and pending projections converge to zero.
|
||||||
|
|
||||||
|
For every refused release/action, record whether the cursor was green/red,
|
||||||
|
whether a SpewBox line appeared, the exact line, and whether canonical item
|
||||||
|
ownership changed before the server response.
|
||||||
|
|
||||||
|
## Evidence index
|
||||||
|
|
||||||
|
Retail research already in the tree:
|
||||||
|
|
||||||
|
- `docs/research/deepdives/r06-items-inventory.md`
|
||||||
|
- `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`
|
||||||
|
- `docs/research/2026-07-13-retail-give-item-pseudocode.md`
|
||||||
|
- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md`
|
||||||
|
- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md`
|
||||||
|
- `docs/research/2026-08-08-slice6-vendor-transactions-research.md`
|
||||||
|
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
||||||
|
|
||||||
|
Primary current implementation surfaces:
|
||||||
|
|
||||||
|
- `src/AcDream.App/UI/UiRoot.cs`
|
||||||
|
- `src/AcDream.App/UI/UiItemSlot.cs`
|
||||||
|
- `src/AcDream.App/UI/ItemInteractionController.cs`
|
||||||
|
- `src/AcDream.App/UI/Layout/InventoryController.cs`
|
||||||
|
- `src/AcDream.App/UI/Layout/ExternalContainerController.cs`
|
||||||
|
- `src/AcDream.App/UI/Layout/PaperdollController.cs`
|
||||||
|
- `src/AcDream.App/UI/Layout/SelectedObjectController.cs`
|
||||||
|
- `src/AcDream.App/UI/Layout/VendorUiController.cs`
|
||||||
|
- `src/AcDream.App/UI/AutoWieldController.cs`
|
||||||
|
- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`
|
||||||
|
- `src/AcDream.App/Rendering/GameWindow.cs`
|
||||||
|
- `src/AcDream.Core/Items/ItemInteractionPolicy.cs`
|
||||||
|
- `src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs`
|
||||||
|
- `src/AcDream.Runtime/Gameplay/RuntimeActionState.cs`
|
||||||
|
|
||||||
|
## Closure statement
|
||||||
|
|
||||||
|
The retail-backed work order is implemented through Slice 4. The code now has
|
||||||
|
one ClientLocal feedback route, request-first authoritative placement,
|
||||||
|
list-role-specific retail mouse behavior, shared placement legality, complete
|
||||||
|
move/wield failure composition, exact local-policy literals, and the exact
|
||||||
|
owned-coinstack status format. Occupied-slot AutoWield now also follows retail's
|
||||||
|
move-confirm-retry transaction instead of emitting an invented refusal. The
|
||||||
|
complete hermetic automated lane is green. No connected acceptance is claimed
|
||||||
|
here; the combined owner gate remains the final closure step, and #443 remains
|
||||||
|
an independent private-viewport residency issue.
|
||||||
115
docs/research/2026-08-26-retail-keyboard-routing-audit.md
Normal file
115
docs/research/2026-08-26-retail-keyboard-routing-audit.md
Normal 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 1–12 |
|
||||||
|
| Emotes | 87 | Exact retail raw-motion table and Runtime `ExecuteMotion` |
|
||||||
|
| Item selection | 26 | Selection controller/query and canonical inventory interaction state |
|
||||||
|
| UI | 42 | Retained panels, screenshot, help/plugin result, logout, and selection commands |
|
||||||
|
| Chat + chat-entry toggle | 7 | Retained chat entry/reply/command routes |
|
||||||
|
| Quickslots | 28 | Toolbar use/select/create routes, including slots 10–18 |
|
||||||
|
| Character settings | 48 | Exact `CharacterOptionId` bit toggle through Runtime |
|
||||||
|
| **Total** | **306** | **306 distinct live identities** |
|
||||||
|
|
||||||
|
The low MasterInputMap entries such as bare Escape and raw mouse event
|
||||||
|
commands are intentionally not Configure Keyboard rows in retail and are not
|
||||||
|
counted among the 306. Unknown rows from a future DAT can still round-trip in
|
||||||
|
the compatibility sibling store, but the installed EoR DAT has no such row and
|
||||||
|
shows no dimmed/store-only keyboard entry.
|
||||||
|
|
||||||
|
## Behavior completed
|
||||||
|
|
||||||
|
- Defaults are an exact installed-DAT transcription, including bare
|
||||||
|
`LeftShift`; device, modifier, activation, and scope all match.
|
||||||
|
- Primary and alternate camera maps remain distinct rebind targets even where
|
||||||
|
retail reuses an action id. The alternate modifier changes the active camera
|
||||||
|
scope without aliasing saved bindings.
|
||||||
|
- Same physical chord may fire each distinct retail action allowed by the
|
||||||
|
ActionMap. In particular, the authored Alt+1..4 chat/UI and quickslot rows
|
||||||
|
multicast instead of one silently replacing the other.
|
||||||
|
- Rebind conflicts use the DAT `ConflictingMaps` table. The shared melee,
|
||||||
|
missile, and magic key cluster remains legal; true conflicts still prompt.
|
||||||
|
- Capture accepts keyboard keys, modifier-only bindings, and mouse buttons.
|
||||||
|
Physical modifier self-bits are normalized, so binding LeftShift does not
|
||||||
|
accidentally become Shift+LeftShift. Mouse button names use retail's
|
||||||
|
`DIMOFS_BUTTON0..7` semantics table. Unsupported joystick and left/right
|
||||||
|
mouse inputs keep the instruction dialog open and re-arm capture.
|
||||||
|
- Setting the chord already present on the same row is a no-op. New chords use
|
||||||
|
retail's dense two-slot insertion rule, and conflicts use priority dialogs
|
||||||
|
with the exact installed-DAT singular/plural and non-bindable text.
|
||||||
|
- Apply/OK, Revert, Defaults, Cancel, explicit unbinding, schema migration,
|
||||||
|
and startup persistence are covered. Revert is enabled only while dirty;
|
||||||
|
OK avoids rewriting an unchanged file.
|
||||||
|
- Load File and Save As use retail's type-7 menu/type-5 text-entry dialogs,
|
||||||
|
PFile bracket-text grammar, filename normalization, overwrite/read-only
|
||||||
|
handling, `Documents\Asheron's Call\*.keymap` directory, selected-profile
|
||||||
|
preference, startup load, and graceful-shutdown rewrite. The portable JSON
|
||||||
|
file remains only as an acdream-host-command compatibility mirror.
|
||||||
|
- Escape follows retail's priority: finish jump charge, release focused UI,
|
||||||
|
stop movement/repeat attack, cancel target mode, clear selection, then
|
||||||
|
toggle the authored Gameplay Options page. It never exits player mode or
|
||||||
|
exposes the orbit/developer camera. Shift+Escape reaches the normal logout
|
||||||
|
gate.
|
||||||
|
- Selection cycling applies retail's containment, cloaking, radar, attackable,
|
||||||
|
fellow, vendor, environment, combat-mode, and opened-corpse rules.
|
||||||
|
Opened-corpse history lives for the session and retires on object deletion.
|
||||||
|
- Screenshot, help, and plugin actions are consumed. Missing separately
|
||||||
|
shipped retail help/plugin surfaces report an honest chat/system result
|
||||||
|
rather than doing nothing.
|
||||||
|
|
||||||
|
## Automated verification
|
||||||
|
|
||||||
|
- App: 6,413/6,413 passed.
|
||||||
|
- Core: 4,713/4,713 passed.
|
||||||
|
- Runtime: 1,849/1,849 passed.
|
||||||
|
- UI.Abstractions: 879/879 passed.
|
||||||
|
- Installed-DAT identity/default conformance and the authored Configure
|
||||||
|
Keyboard mount pin all 306 rows.
|
||||||
|
|
||||||
|
The `.keymap` codec parses the committed real retail file and round-trips all
|
||||||
|
306 user-bindable identities, including low-bit Shift/Ctrl/Alt/Win modifiers,
|
||||||
|
DirectInput controls, fixed Escape/system/edit/pointer maps, and the 48
|
||||||
|
CharacterOption action names. AP-202 is retired. The connected gate could not
|
||||||
|
be run because no local ACE endpoint was listening on UDP port 9000.
|
||||||
|
|
||||||
|
## Connected acceptance gate
|
||||||
|
|
||||||
|
Use the installed EoR DATs and the normal owner-gate pak. In Configure
|
||||||
|
Keyboard, verify that all rows are enabled and that a key, modifier-only chord,
|
||||||
|
and mouse button can each be rebound. Exercise representative movement,
|
||||||
|
camera, melee/missile/magic, emote, selection, panel, chat, quickslot, and
|
||||||
|
character-option actions. Verify conflict prompt, Cancel, Revert, Defaults,
|
||||||
|
Apply, and OK, then restart the process and confirm the applied bindings remain.
|
||||||
|
|
@ -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.BackbufferReader,
|
? d.ScreenshotsDirectory
|
||||||
Path.Combine(artifactDirectory, "screenshots"),
|
: Path.Combine(
|
||||||
ProbeLog,
|
Path.GetDirectoryName(d.KeyBindingsFilePath)!,
|
||||||
d.RenderPackDiagnostics);
|
"screenshots");
|
||||||
}
|
var screenshots = new FrameScreenshotController(
|
||||||
|
d.BackbufferReader,
|
||||||
|
screenshotDirectory,
|
||||||
|
ProbeLog,
|
||||||
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
129
src/AcDream.App/Input/RetailEmoteMotionTable.cs
Normal file
129
src/AcDream.App/Input/RetailEmoteMotionTable.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
601
src/AcDream.App/Input/RetailKeymapFile.cs
Normal file
601
src/AcDream.App/Input/RetailKeymapFile.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
81
src/AcDream.App/Net/DatChatPoseCatalog.cs
Normal file
81
src/AcDream.App/Net/DatChatPoseCatalog.cs
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() =>
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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,10 +123,35 @@ 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);
|
||||||
|
|
||||||
Position = new Vector3(
|
if (_inHead)
|
||||||
playerPosition.X - forwardX * horizontalDist,
|
{
|
||||||
playerPosition.Y - forwardY * horizontalDist,
|
Vector3 forward = new(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f);
|
||||||
_trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
|
Position = new Vector3(
|
||||||
|
playerPosition.X,
|
||||||
|
playerPosition.Y,
|
||||||
|
_trackedZ + EyeHeight) + forward * 0.18f;
|
||||||
|
_lookAt = Position + forward;
|
||||||
|
}
|
||||||
|
else if (_targetDirectionLocal is { } localDirection)
|
||||||
|
{
|
||||||
|
Vector3 pivot = new(playerPosition.X, playerPosition.Y, _trackedZ + EyeHeight);
|
||||||
|
var directedPose = RetailChaseCamera.ComputeTargetDirectionPose(
|
||||||
|
pivot,
|
||||||
|
new Vector3(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f),
|
||||||
|
Distance,
|
||||||
|
Pitch,
|
||||||
|
localDirection);
|
||||||
|
Position = directedPose.eye;
|
||||||
|
Vector3 direction = directedPose.forward;
|
||||||
|
_lookAt = Position + direction;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Position = new Vector3(
|
||||||
|
playerPosition.X - forwardX * horizontalDist,
|
||||||
|
playerPosition.Y - forwardY * horizontalDist,
|
||||||
|
_trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -119,6 +159,8 @@ public sealed class ChaseCamera : ICamera
|
||||||
/// </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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>?)[]
|
||||||
|
|
|
||||||
|
|
@ -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,8 +192,13 @@ 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
|
||||||
pivotWorld, heading, Distance, Pitch, viewerYawOffset);
|
? ComputeInHeadPose(pivotWorld, heading)
|
||||||
|
: _targetDirectionLocal is { } localDirection
|
||||||
|
? ComputeTargetDirectionPose(
|
||||||
|
pivotWorld, heading, Distance, Pitch, localDirection)
|
||||||
|
: ComputeDesiredPose(
|
||||||
|
pivotWorld, heading, Distance, Pitch, viewerYawOffset);
|
||||||
|
|
||||||
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
|
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
|
||||||
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
|
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,7 +1985,8 @@ internal sealed class LocalPlayerTeleportController
|
||||||
if (clearSession)
|
if (clearSession)
|
||||||
_loginPlacementCompleted = false;
|
_loginPlacementCompleted = false;
|
||||||
|
|
||||||
_streaming.ResetRecenter(clearSession);
|
if (!streamingRetirementPrepared)
|
||||||
|
_streaming.ResetRecenter(clearSession);
|
||||||
if (_lifetimeGeneration != generation)
|
if (_lifetimeGeneration != generation)
|
||||||
return generation;
|
return generation;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
_sendPutItemInContainer(blockingItem.ObjectId, player, 0);
|
bool dispatched = DispatchInventoryRequest(
|
||||||
return true;
|
InventoryRequestKind.PutInContainer,
|
||||||
|
blockingItem.ObjectId,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
_sendPutItemInContainer(blockingItem.ObjectId, player, 0);
|
||||||
|
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.
|
||||||
_sendWield(item.ObjectId, (uint)mask);
|
bool dispatched = DispatchInventoryRequest(
|
||||||
return true;
|
InventoryRequestKind.Wield,
|
||||||
|
item.ObjectId,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
_sendWield(item.ObjectId, (uint)mask);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (!dispatched)
|
||||||
|
_pendingSwitch = null;
|
||||||
|
return dispatched;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool DispatchInventoryRequest(
|
||||||
|
InventoryRequestKind kind,
|
||||||
|
uint itemId,
|
||||||
|
Func<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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
return i + 1;
|
{
|
||||||
|
used += cost;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int available = (int)Math.Max(0L, budget - used - 1L);
|
||||||
|
if (available > 0)
|
||||||
|
{
|
||||||
|
string text = detailed[i].Text;
|
||||||
|
int minimumOffset = Math.Max(0, text.Length - available);
|
||||||
|
int offset = FirstCharacterAfterLineBreak(text, minimumOffset);
|
||||||
|
if (offset < text.Length)
|
||||||
|
return new BudgetStart(i, offset);
|
||||||
|
|
||||||
|
// A single newest unbroken message must still remain visible;
|
||||||
|
// dropping it wholesale is what made large @acecommands
|
||||||
|
// replies render as an empty transcript.
|
||||||
|
if (used == 0 && text.Length > 0)
|
||||||
|
return new BudgetStart(i, minimumOffset);
|
||||||
|
}
|
||||||
|
return new BudgetStart(i + 1, 0);
|
||||||
}
|
}
|
||||||
return 0;
|
return new BudgetStart(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FirstCharacterAfterLineBreak(string text, int start)
|
||||||
|
{
|
||||||
|
for (int i = Math.Clamp(start, 0, text.Length); i < text.Length; i++)
|
||||||
|
{
|
||||||
|
if (text[i] is not ('\r' or '\n'))
|
||||||
|
continue;
|
||||||
|
if (text[i] == '\r' && i + 1 < text.Length && text[i + 1] == '\n')
|
||||||
|
i++;
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
return text.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FormattedLine SliceLine(FormattedLine line, int offset)
|
||||||
|
{
|
||||||
|
if (offset <= 0)
|
||||||
|
return line;
|
||||||
|
|
||||||
|
string text = line.Text[offset..];
|
||||||
|
if (line.Spans is not { Count: > 0 } spans)
|
||||||
|
return line with { Text = text };
|
||||||
|
|
||||||
|
var sliced = new List<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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,17 +262,30 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
||||||
InventoryRequestKind kind = amount < fullStack
|
InventoryRequestKind kind = amount < fullStack
|
||||||
? InventoryRequestKind.SplitToContainer
|
? InventoryRequestKind.SplitToContainer
|
||||||
: InventoryRequestKind.PutInContainer;
|
: InventoryRequestKind.PutInContainer;
|
||||||
_itemInteraction.TryDispatchInventoryRequest(
|
if (amount < fullStack)
|
||||||
kind,
|
{
|
||||||
item.ObjectId,
|
_itemInteraction.TryDispatchInventoryRequest(
|
||||||
() =>
|
kind,
|
||||||
{
|
item.ObjectId,
|
||||||
if (amount < fullStack)
|
() =>
|
||||||
|
{
|
||||||
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
|
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
|
||||||
else
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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,7 +435,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
||||||
{
|
{
|
||||||
if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive)
|
if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive)
|
||||||
return true;
|
return true;
|
||||||
Select(guid);
|
if (IsContainer(_objects.Get(guid)) && guid != _state.CurrentContainerId)
|
||||||
|
OpenNestedContainer(guid);
|
||||||
|
else
|
||||||
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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,7 +553,10 @@ 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;
|
||||||
SelectItem(guid);
|
if (_objects.Get(guid) is { } item && IsBag(item))
|
||||||
|
OpenContainer(guid);
|
||||||
|
else
|
||||||
|
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,79 +740,65 @@ 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.
|
||||||
DispatchInventoryRequest(
|
if (_itemInteraction is not null)
|
||||||
InventoryRequestKind.SplitToContainer,
|
{
|
||||||
item,
|
_itemInteraction.TrySplitToContainer(
|
||||||
() =>
|
item,
|
||||||
{
|
container,
|
||||||
if (_sendStackableSplitToContainer is null)
|
(uint)placement,
|
||||||
return false;
|
splitSize);
|
||||||
_sendStackableSplitToContainer(
|
}
|
||||||
item,
|
else
|
||||||
container,
|
{
|
||||||
(uint)placement,
|
DispatchInventoryRequest(
|
||||||
splitSize);
|
InventoryRequestKind.SplitToContainer,
|
||||||
return true;
|
item,
|
||||||
});
|
() =>
|
||||||
|
{
|
||||||
|
if (_sendStackableSplitToContainer is null)
|
||||||
|
return false;
|
||||||
|
_sendStackableSplitToContainer(
|
||||||
|
item,
|
||||||
|
container,
|
||||||
|
(uint)placement,
|
||||||
|
splitSize);
|
||||||
|
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
|
if (_itemInteraction is not null)
|
||||||
// @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680.
|
|
||||||
if (payload.SourceKind == ItemDragSource.Ground)
|
|
||||||
{
|
{
|
||||||
if (_itemInteraction is not null)
|
InventoryRequestKind kind = payload.SourceKind == ItemDragSource.Ground
|
||||||
{
|
? InventoryRequestKind.Pickup
|
||||||
if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
|
: InventoryRequestKind.PutInContainer;
|
||||||
item,
|
if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
|
||||||
container,
|
|
||||||
placement,
|
|
||||||
InventoryRequestKind.Pickup,
|
|
||||||
() =>
|
|
||||||
{
|
|
||||||
if (_sendPutItemInContainer is null)
|
|
||||||
return false;
|
|
||||||
_sendPutItemInContainer(item, container, placement);
|
|
||||||
return true;
|
|
||||||
}))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (_pendingListPlacement is not null)
|
|
||||||
return;
|
|
||||||
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
|
|
||||||
Populate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (_itemInteraction is not null)
|
|
||||||
{
|
|
||||||
DispatchInventoryRequest(
|
|
||||||
InventoryRequestKind.PutInContainer,
|
|
||||||
item,
|
item,
|
||||||
|
container,
|
||||||
|
placement,
|
||||||
|
kind,
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
if (_sendPutItemInContainer is null
|
if (_sendPutItemInContainer is null)
|
||||||
|| !_objects.MoveItemOptimistic(item, container, placement))
|
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
_sendPutItemInContainer(item, container, placement);
|
_sendPutItemInContainer(item, container, placement);
|
||||||
return true;
|
return true;
|
||||||
});
|
}))
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_objects.MoveItemOptimistic(item, container, placement);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_pendingListPlacement is not null)
|
||||||
|
return;
|
||||||
|
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
|
||||||
|
Populate();
|
||||||
_sendPutItemInContainer?.Invoke(item, container, placement);
|
_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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(&conflicts)</c> — BEFORE reassigning;
|
/// retail's <c>OpenOverwriteBindingDialog(&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 = () =>
|
||||||
{
|
{
|
||||||
|
bool changed = controller.Page.Changed;
|
||||||
|
// Retail only rewrites the active keymap when at least one
|
||||||
|
// row differs; SaveCurrentValues still advances the Revert
|
||||||
|
// baseline unconditionally.
|
||||||
|
if (changed)
|
||||||
|
bindings.Save();
|
||||||
controller.Page.Apply();
|
controller.Page.Apply();
|
||||||
bindings.Save();
|
|
||||||
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
122
src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs
Normal file
122
src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,9 +381,11 @@ 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}"
|
? $"{stackSize} {objectName} (of {_coinTotal()})"
|
||||||
: objectName;
|
: stackSize > 1u && !string.IsNullOrEmpty(objectName)
|
||||||
|
? $"{stackSize} {objectName}"
|
||||||
|
: objectName;
|
||||||
|
|
||||||
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
|
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
|
||||||
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
|
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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->trade_num - m_last_sale</c>;
|
/// <c>shopVendorProfile->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;
|
||||||
_vendor.VendorId,
|
if (_itemInteraction.TryBuy(
|
||||||
shopItem.ItemGuid,
|
_vendor.VendorId,
|
||||||
(int)quantity,
|
shopItem.ItemGuid,
|
||||||
_vendor.Profile.AlternateCurrencyWcid);
|
(int)quantity,
|
||||||
|
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 <= m_totalValue</c>).</item>
|
/// (<c>pc:204017</c>: <c>m_transactionValue <= 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 > player.ContainersCapacity - containersUsed</c>).</item>
|
/// <c>containerSlotsNeeded > 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,7 +1841,10 @@ 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>
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
OpenSpellbook(SpellbookWindowPage.Spells);
|
case AcDream.UI.Abstractions.Input.InputAction.CaptureScreenshot:
|
||||||
return true;
|
_bindings.CaptureScreenshot?.Invoke();
|
||||||
}
|
return true;
|
||||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel)
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleHelp:
|
||||||
{
|
// EoR delegates this to the separately shipped ACHelpPlugin.
|
||||||
OpenSpellbook(SpellbookWindowPage.Components);
|
// That binary is not part of acdream; consume the retail action
|
||||||
return true;
|
// and report the unavailable external surface honestly.
|
||||||
}
|
_bindings.Options.DisplaySystemMessage(
|
||||||
// Campaign FA slice FA3: F3/F4 — keyboard-only open paths (lane A
|
"In-game help is unavailable because the retail help plugin is not installed.");
|
||||||
// §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.TogglePluginManager:
|
||||||
// their own tab; the panel participates in the SAME gmPanelUI
|
_bindings.Options.DisplaySystemMessage(
|
||||||
// one-active-panel exclusivity every sibling panel gets from
|
"The retail plugin manager is not available in acdream.");
|
||||||
// RetailPanelUiController.RegisterMainPanel.
|
return true;
|
||||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel)
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleAbuseReportingPanel:
|
||||||
{
|
_bindings.Options.DisplaySystemMessage(OptionsPanelText.ReportAbuseUnavailable);
|
||||||
OpenSocialPanel(showAllegiance: true);
|
return true;
|
||||||
return true;
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleUrgentAssistancePanel:
|
||||||
}
|
_bindings.Options.DisplaySystemMessage(OptionsPanelText.UrgentAssistanceUnavailable);
|
||||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel)
|
return true;
|
||||||
{
|
case AcDream.UI.Abstractions.Input.InputAction.ChatReply:
|
||||||
OpenSocialPanel(showAllegiance: false);
|
_chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastIncomingTellSender);
|
||||||
return true;
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ChatMonarchReply:
|
||||||
|
_chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastMonarchSender);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ChatPatronReply:
|
||||||
|
_chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastPatronSender);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ChatStartCommand:
|
||||||
|
_chatWindowController?.StartCommand();
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ChatTellToSelected:
|
||||||
|
{
|
||||||
|
uint selected = _bindings.Toolbar.Selection.SelectedObjectId ?? 0u;
|
||||||
|
if (selected is >= 0x50000001u and <= 0x6FFFFFFFu)
|
||||||
|
{
|
||||||
|
string? name = _bindings.Toolbar.ResolveName(selected);
|
||||||
|
if (!string.IsNullOrEmpty(name))
|
||||||
|
_chatWindowController?.StartTell(name);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.EnterChatMode:
|
||||||
|
_chatWindowController?.EnterChatMode(
|
||||||
|
_bindings.Keyboard?.Dispatcher?.CurrentPhysicalChord);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry:
|
||||||
|
_chatWindowController?.ToggleChatEntry(
|
||||||
|
_bindings.Keyboard?.Dispatcher?.CurrentPhysicalChord);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterInfoPanel:
|
||||||
|
ToggleWindow(WindowNames.CharacterInformation);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.TogglePositiveMagicPanel:
|
||||||
|
ToggleWindow(WindowNames.PositiveEffects);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleNegativeMagicPanel:
|
||||||
|
ToggleWindow(WindowNames.NegativeEffects);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleLinkStatusPanel:
|
||||||
|
ToggleWindow(WindowNames.LinkStatus);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleVitaePanel:
|
||||||
|
ToggleWindow(WindowNames.Vitae);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleSocialPanel:
|
||||||
|
ToggleWindow(WindowNames.SocialPanel);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel:
|
||||||
|
OpenSocialPanel(SocialPanelPage.Allegiance);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel:
|
||||||
|
OpenSocialPanel(SocialPanelPage.Fellowship);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleFriendsPage:
|
||||||
|
OpenSocialPanel(SocialPanelPage.Friends);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellManagementPanel:
|
||||||
|
ToggleWindow(WindowNames.Spellbook);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel:
|
||||||
|
OpenSpellbook(SpellbookWindowPage.Spells);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel:
|
||||||
|
OpenSpellbook(SpellbookWindowPage.Components);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterDetailPanel:
|
||||||
|
ToggleWindow(WindowNames.Character);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleAttributesPanel:
|
||||||
|
OpenCharacterPanel(CharacterStatController.CharacterStatTab.Attributes);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleSkillsPanel:
|
||||||
|
OpenCharacterPanel(CharacterStatController.CharacterStatTab.Skills);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterTitlesPage:
|
||||||
|
OpenCharacterPanel(CharacterStatController.CharacterStatTab.Titles);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleWorldPanel:
|
||||||
|
ToggleWindow(WindowNames.MapHouse);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleMapPage:
|
||||||
|
OpenWorldPanel(showHouse: false);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleHousePage:
|
||||||
|
OpenWorldPanel(showHouse: true);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel:
|
||||||
|
ToggleWindow(WindowNames.Options);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleGameplayOptionsPage:
|
||||||
|
OpenOptionsPage(OptionsPanelPage.Gameplay);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterSettingsPage:
|
||||||
|
OpenOptionsPage(OptionsPanelPage.Character);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleConfigurationPage:
|
||||||
|
OpenOptionsPage(OptionsPanelPage.Configuration);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleCompass:
|
||||||
|
Host.ToggleWindow(WindowNames.Radar);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleKeyboardConfiguration:
|
||||||
|
ToggleWindow(WindowNames.KeyboardConfig);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleQuestJournalPage:
|
||||||
|
OpenJournalPanel(JournalPanelPage.Notes);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleQuestDetailPanel:
|
||||||
|
// EoR's quest-detail action addresses the quest-management
|
||||||
|
// surface. The current authored Journal host's server-backed
|
||||||
|
// Contracts page is that surface in acdream.
|
||||||
|
OpenJournalPanel(JournalPanelPage.Contracts);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleJournalPageList:
|
||||||
|
OpenJournalPanel(JournalPanelPage.PageList);
|
||||||
|
return true;
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleContractsPage:
|
||||||
|
OpenJournalPanel(JournalPanelPage.Contracts);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ToolbarInputController?.Handle(action) == true;
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,13 +528,16 @@ 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))
|
||||||
{
|
{
|
||||||
ReleaseCapture();
|
if (preserveDetachedDrag && ReferenceEquals(Captured, DragSource))
|
||||||
|
SetCapture(this);
|
||||||
|
else
|
||||||
|
ReleaseCapture();
|
||||||
_dragCandidate = false;
|
_dragCandidate = false;
|
||||||
}
|
}
|
||||||
if (IsWithinSubtree(DefaultTextInput, subtree))
|
if (IsWithinSubtree(DefaultTextInput, subtree))
|
||||||
|
|
@ -527,10 +547,13 @@ public sealed class UiRoot : UiElement
|
||||||
if (IsWithinSubtree(DragSource, subtree))
|
if (IsWithinSubtree(DragSource, subtree))
|
||||||
{
|
{
|
||||||
DragSource?.SetDragSourceActive(false, DragPayload);
|
DragSource?.SetDragSourceActive(false, DragPayload);
|
||||||
DragSource = null;
|
if (!preserveDetachedDrag)
|
||||||
DragPayload = null;
|
{
|
||||||
_dragGhost = null;
|
DragSource = null;
|
||||||
_dragCandidate = false;
|
DragPayload = null;
|
||||||
|
_dragGhost = null;
|
||||||
|
_dragCandidate = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (IsWithinSubtree(_hoverWidget, subtree))
|
if (IsWithinSubtree(_hoverWidget, subtree))
|
||||||
{
|
{
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.SenderGuid != 0u)
|
if (entry.Kind == ChatKind.Tell)
|
||||||
_lastIncomingTellSender = entry.Sender;
|
{
|
||||||
else
|
if (entry.SenderGuid != 0u)
|
||||||
_lastOutgoingTellTarget = entry.Sender;
|
_lastIncomingTellSender = entry.Sender;
|
||||||
|
else
|
||||||
|
_lastOutgoingTellTarget = entry.Sender;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// gmCCommunicationSystem keeps independent reply targets for the
|
||||||
|
// legacy Monarch (0x4000) and Patron (0x2000) broadcasts. The
|
||||||
|
// legacy 0x0147 wire payload has no sender GUID, so its committed
|
||||||
|
// ChatEntry correctly carries zero even for an incoming speaker.
|
||||||
|
// Local channel echoes have an empty Sender and were rejected at
|
||||||
|
// the top of this method; the non-empty name is the discriminator.
|
||||||
|
if (entry.Kind != ChatKind.Channel)
|
||||||
|
return;
|
||||||
|
if (entry.ChannelId == 0x00004000u)
|
||||||
|
_lastMonarchSender = entry.Sender;
|
||||||
|
else if (entry.ChannelId == 0x00002000u)
|
||||||
|
_lastPatronSender = entry.Sender;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 -> ID_InputMap_*</c> string-table keys
|
/// Retail's 19 named <c>InputMapID -> 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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
158
src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs
Normal file
158
src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,10 @@ public enum InventoryRequestKind
|
||||||
PutInContainer,
|
PutInContainer,
|
||||||
SplitToContainer,
|
SplitToContainer,
|
||||||
Merge,
|
Merge,
|
||||||
|
Move,
|
||||||
DropToWorld,
|
DropToWorld,
|
||||||
SplitToWorld,
|
SplitToWorld,
|
||||||
|
Wield,
|
||||||
Give,
|
Give,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<ItemProfile>::Flush</c> calls
|
/// <summary>Port of the unconditional <c>PackableList<ItemProfile>::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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
76
src/AcDream.Runtime/Chat/RetailPublicChatParser.cs
Normal file
76
src/AcDream.Runtime/Chat/RetailPublicChatParser.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,9 +238,24 @@ 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
|
||||||
? combat
|
? InputScope.Camera
|
||||||
: _scopes.Peek();
|
: _scopes.Peek() == InputScope.Game && _combatScope is { } combat
|
||||||
|
? combat
|
||||||
|
: _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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Binding[] bindings = FindInScope(scope, chord, activation);
|
||||||
|
if (bindings.Length != 0)
|
||||||
|
return bindings;
|
||||||
}
|
}
|
||||||
return null;
|
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;
|
||||||
var press = FindActive(chord, ActivationType.Press);
|
try
|
||||||
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
|
|
||||||
|
|
||||||
var click = FindActive(chord, ActivationType.Click);
|
|
||||||
if (click is not null) Fired?.Invoke(click.Value.Action, ActivationType.Click);
|
|
||||||
|
|
||||||
var hold = FindActive(chord, ActivationType.Hold);
|
|
||||||
if (hold is not null)
|
|
||||||
{
|
{
|
||||||
// Emit a Press transition so subscribers can latch state, then
|
foreach (Binding press in FindActiveBindings(chord, ActivationType.Press))
|
||||||
// record the chord so Tick() will re-fire Hold every frame.
|
Fired?.Invoke(press.Action, ActivationType.Press);
|
||||||
Fired?.Invoke(hold.Value.Action, ActivationType.Press);
|
|
||||||
_heldHoldChords.Add(chord);
|
foreach (Binding click in FindActiveBindings(chord, ActivationType.Click))
|
||||||
|
Fired?.Invoke(click.Action, ActivationType.Click);
|
||||||
|
|
||||||
|
IReadOnlyList<Binding> holds = FindActiveBindings(chord, ActivationType.Hold);
|
||||||
|
if (holds.Count != 0)
|
||||||
|
{
|
||||||
|
// Emit a Press transition so subscribers can latch state, then
|
||||||
|
// record the chord so Tick() will re-fire Hold every frame.
|
||||||
|
foreach (Binding hold in holds)
|
||||||
|
Fired?.Invoke(hold.Action, ActivationType.Press);
|
||||||
|
_heldHoldChords.Add(chord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_currentPhysicalChord = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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,16 +723,17 @@ 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)
|
||||||
{
|
{
|
||||||
Fired?.Invoke(click.Action, ActivationType.Click);
|
foreach (Binding click in clicks)
|
||||||
|
Fired?.Invoke(click.Action, ActivationType.Click);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -683,6 +788,25 @@ public sealed class InputDispatcher : IDisposable
|
||||||
_ => (Key)(-1000 - (int)button),
|
_ => (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>();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue