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

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -24,9 +24,251 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #450 — Fast character re-entry after logout can remain in portal space at `lb 0/0`
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.
**Component:** session reset / streaming-origin retirement / login reveal.
After Shift+Escape logout to character selection, immediately entering the
character again could leave the client indefinitely in portal space with no
landblocks admitted. The server accepted the second entry; the client title
remained at `lb 0/0`.
**Root cause/fix:** confirmed logout starts a frame-budgeted retirement of the
old streaming window, but the synchronous session reset ignored its incomplete
result and exposed the fresh Runtime generation. The new world then inherited
the old origin-recenter admission gate. The confirmed-logoff pump now holds the
authored tunnel until old-window retirement converges, then transfers that
completed barrier through the reset callback exactly once. A deterministic
regression proves the character-select handoff cannot execute while retirement
is incomplete and cannot begin a duplicate retirement during reset.
**Acceptance:** Shift+Escape to character selection, immediately re-enter, and
confirm the destination begins admitting landblocks and exits portal space.
Repeat twice in one process.
## #449 — Main backpack remains falsely full after an item slot is freed
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.
**Component:** inventory drag acceptance / main-pack capacity.
With a full backpack, a move into it correctly shows the red reject cursor.
After dropping an item to free a slot, later moves from another pack could
remain rejected.
**Root cause/fix:** main-pack fullness counted every child of the player,
including side bags, even though retail places side bags in a separate
container-selector list governed by `ContainersCapacity`. Capacity fill,
append placement, and drag acceptance now count only visible loose contents.
A regression starts with two loose items plus a side bag at capacity two,
removes one loose item, and proves the next drag changes from Reject to Accept
with a 50% capacity meter.
**Acceptance:** fill the main pack, observe one rejected move, drop one loose
item, then move an item from a side pack into the freed main-pack slot. It must
accept immediately without reopening the inventory window. Run with the
combined gate in `docs/research/2026-08-26-combined-client-parity-gate.md`.
## #448 — Outgoing melee hit messages expose a percentage that retail does not print
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.
**Component:** combat chat / AttackerNotification presentation.
Successful outgoing melee hits currently print a percentage in chat, for
example `You hit ... for ... damage (54.0%).` The owner reports that this is
not retail behavior and that the percentage should not be shown.
**Likely seam:** `CombatChatTranslator.HandleDamageDealt` unconditionally
appends `DamageDealt.DamagePercent`; its tests explicitly pin a template taken
from holtburger rather than the named retail client. Recover the exact
AttackerNotification presentation from the named retail decomp/string tables,
then replace the formatter and its tests. Preserve the wire value in combat
state if it has another legitimate consumer; this issue concerns chat output.
**Acceptance:** ordinary and critical outgoing melee hit lines match retail
wording and punctuation exactly and contain no acdream-added percentage.
## #447`@acecommands` produces blank lines in the chat window
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.
**Component:** ACE server-command responses / chat presentation.
Running `@acecommands` against the test server produces a series of blank
chat lines instead of the command names and descriptions. The command reaches
ACE, but its multiline response loses its visible text before presentation.
**Investigation seam:** capture the authoritative response message type and
raw payload, then trace it through the server-command/interface-text parser,
`RuntimeCommunicationState`, and retained chat markup rendering. Do not work
around the defect by printing the static `docs/reference/ace-commands.md`
copy; the live server response must render correctly.
**Acceptance:** `@acecommands` displays every non-empty server response line
inside retail's retained transcript window with its text intact (newest
complete-line tail when the response itself exceeds the cap), produces no
blank-line spam, and does not regress normal chat or other ACE commands.
**2026-08-26 fix:** ACE sends the complete command listing as one `0xF7E0`
`ServerMessage` containing embedded newlines. The parser and runtime route
already preserved that payload. The retained transcript budget treated the
whole message as one indivisible log entry, however, so an admin-sized reply
larger than retail's `0x2710`-character cap advanced past the only entry and
rendered nothing. `ChatTranscriptRenderer` now clips an oversized boundary
entry at a newline and keeps its newest complete lines, matching retail's
front-truncation behavior. Ordinary multiline replies below the cap render
every authored line. Parser round-trip, normal multiline, oversized response,
filter, tagged-run, and existing chat regression tests pass. Owner check is in
`docs/research/2026-08-26-combined-client-parity-gate.md`.
## #446 — Configure Keyboard bindings need an end-to-end retail-parity pass
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate,
including connected behavior and persistence.
**Component:** input / Configure Keyboard / binding persistence.
The owner reports that keyboard binding still does not work reliably or match
retail. Treat this as an end-to-end product gate rather than another isolated
layout fix: display the authored mappings, capture a replacement key or mouse
button, apply the correct retail conflict rules, make the new action fire,
and preserve it across restart. Escape cancellation, Reset/Defaults, scoped
combat bindings, modifier chords, and mouse bindings must also match retail.
Existing issue #373 is one known concrete defect in this flow: acdream ignores
the DAT `ActionMap.ConflictingMaps` table and can erase valid shared combat
bindings. The fixes recorded under #394-#396 remain pending a complete owner
re-gate and do not establish that binding works end to end.
**2026-08-26 implementation:** deep audit at
`docs/research/2026-08-26-retail-keyboard-routing-audit.md`. Bare Escape no
longer exits player mode or exposes the orbit/developer bird's-eye camera. It
now follows the complete proven retail ladder: finish jump charge, release
focused UI, stop movement/repeat attack, cancel target mode, clear selection,
then toggle the authored Gameplay Options page. Shift+Escape reaches the real
logout gate.
All 306 installed ActionMap rows now have distinct live identities and enabled
Configure Keyboard rows. Exact defaults, contexts, activation, DAT conflict
policy, modifier-only and mouse capture, duplicate-chord multicast, explicit
unbinding, dense two-slot insertion, same-row no-op, unsupported-input retry,
priority conflict/non-bindable dialogs with exact DAT text, dirty-only Revert,
Apply/Defaults/OK/Cancel, schema migration, and persistence are implemented.
The complete camera, selection, missile, magic, 87-emote, screenshot/help/
plugin, quickslot 118, panel/chat, and 48 CharacterSettings families reach
concrete consumers. Selection includes retail radar/combat/fellow/vendor/
environment and session opened-corpse rules. The approved 40 m mouse-wheel
chase zoom remains unchanged and regression-pinned.
Retail's Load File / Save As path is now live as well: the client parses and
writes the Sept-2013 PFile `.keymap` grammar under
`Documents\Asheron's Call`, remembers the selected profile, presents the
authored type-7 file menu and type-5 filename/overwrite dialogs, loads it on
startup, and rewrites it on graceful shutdown like retail. `keybinds.json`
remains a compatibility mirror for acdream-only commands. AP-202 is retired.
Automated keyboard-impact evidence is green: App 6,413/6,413, Core
4,713/4,713, Runtime 1,849/1,849, and UI.Abstractions 879/879 (13,854
tests total). Installed-DAT conformance pins all 306 identities, defaults, the
authored Configure Keyboard mount, and active Load/Save controls. Only the
connected gate below remains.
**First owner-round findings fixed 2026-08-26:** modifier-only capture now
normalizes LeftShift and consistently raises the retail overwrite prompt when
Move Forward conflicts with Toggle Walk/Run. Regular Enter enters chat without
its raw event immediately submitting the new field; keypad Enter no longer
falls through to the raw chat-focus shortcut. Melee height keys now preserve
the Press→held charge→Release transaction instead of treating the first Hold
tick as release. Map mode transforms both retail's target direction and viewer
offset through the target frame, placing the eye high overhead rather than low
behind the character; the approved mouse-wheel zoom range is unchanged.
Shift+Escape's same-process relog portal stall is tracked and fixed as #450.
Focused App coverage plus the standard Release lane pass.
**Acceptance:** a connected retail side-by-side covers representative movement,
combat, panel, modifier, and mouse mappings; every rebound action executes,
conflicts match retail, cancellation changes nothing, and applied bindings
survive a fresh client launch.
## #445 — Stack split errors in inventory; vendor drag ignores selected quantity
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.
**Component:** inventory stack splitting / vendor sell staging / shared split
quantity.
Two live paths fail after selecting a partial quantity with the stack slider:
1. Splitting a stack within the inventory produces an error instead of moving
the selected quantity into the destination slot.
2. With a stack of 10 and the slider set to 2, dragging the stack into the
vendor window stages all 10 rather than the selected 2.
**Expected:** the selected quantity is the single shared value consumed by
inventory split operations and by the vendor drop path; the source retains
the remainder. Capture the exact inventory error text/code during the fix
gate.
**Investigation seam:** trace `StackSplitQuantityState` from selection/slider
changes through the inventory `SendStackableSplitToContainer` request. The
vendor path currently documents and implements full-stack sell staging in
`VendorUiController.EvaluateSellAcceptability`; compare that claim against
named retail and a retail client gate before changing it, then make the
observed behavior and documentation agree. This is distinct from #313, which
only tracks selection transfer to the newly created split result.
**2026-08-26 fix:** named retail's enclosing
`VendorSellUI::AcceptDragObject @ 0x004C4F00` disproved the old full-stack-only
comment. A partial vendor drop now sends the exact slider quantity through the
canonical inventory transaction owner, stages the source as retail's temporary
row, and replaces that row in place when the server-created stack with matching
WCID/quantity arrives. A matching failure removes the placeholder. Ordinary
inventory splitting now uses that same owner and computes empty main-pack
placement from visible loose items, excluding side bags that live in retail's
separate selector list. Exact quantity, request lifetime, replacement order,
and side-bag placement are regression-tested. Owner check is in
`docs/research/2026-08-26-combined-client-parity-gate.md`.
## #444 — Vendor alternate-currency balance stays stale after a successful purchase
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.
**Component:** vendor UI / alternate-currency purchase refresh.
At a vendor that accepts an alternate currency (observed with Colosseum
Coins), the purchase succeeds and the server removes the currency, but the
vendor window continues to show the pre-purchase holding. Example: the purse
line says "You have 10 Colosseum Coins" before the purchase and still says 10
afterward. The displayed holding should update immediately after the
authoritative purchase/inventory update.
**Likely seam:** `VendorUiController.BuildPurseText` and `BuildCostText` read
the vendor-open snapshot `VendorShopProfile.AlternateCurrencyAmount`
directly. `OnObjectMoneyChanged` repaints the text, but the repainted value is
still that latched profile amount rather than the live alternate-currency
holding (or retail's `trade_num - m_last_sale` equivalent). Add a connected
regression for purchase success followed by the refreshed purse and item-cost
text; cover both the Buying tab and Items tab.
**2026-08-26 fix:** alternate-currency displays and Buy All affordability now
prefer the authoritative sum of player-owned currency stacks. On a successful
Buy/Buy All dispatch, retail's `m_last_sale` subtraction updates the Items and
Buying/Selling purse text immediately; the next matching currency add/update/
move/remove clears that optimistic subtraction and repaints from canonical
inventory. The vendor snapshot remains only the pre-observation fallback.
Automated coverage pins the immediate 10→8 display and the subsequent
authoritative 8→8 reconciliation. Owner check is in
`docs/research/2026-08-26-combined-client-parity-gate.md`.
## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing")
**Status:** FIXED / OWNER-ACCEPTED 2026-08-25.
**Status:** FIXED / CONNECTED LIVE RE-GATE PASSED 2026-08-26 — awaiting owner
acceptance. Reopened after the owner again observed a missing paperdoll that
appeared only after waiting. Previously marked FIXED / OWNER-ACCEPTED
2026-08-25. The recurrence exposed two remaining gaps: palette/clothing texture
composites could still be pending when the private pass cleared and published
its target, and Vulkan's two concurrently recorded frames reused that same
offscreen image as both a color attachment and a retained-UI sampled texture.
The 2026-08-26 combined client-parity gate passed every #444#450 row on the
same Release binary while the paperdoll remained missing, confirming #443 is
an isolated private-viewport defect rather than an inventory transaction,
input, relog, chat, combat-text, or vendor failure.
**Component:** private entity viewports (examination clone, inventory
paperdoll — shared `PrivateEntityViewportRenderer`).
**Filed:** 2026-08-25, AS-GF1 gate-fix session. **Narrowed same day at the
@ -59,6 +301,25 @@ and new residency tests pass 30/30; the App hermetic lane passes 6,358/6,358.
The owner then live-verified repeated inventory and monster/player assessment
opens against the local ACE test server: "Good. works."
**2026-08-26 recurrence fix:** the shared renderer now advances and gates the
complete private-entity resource set — mesh upload plus original, palette and
clothing-composite textures — before allocating, clearing, or publishing a
new viewport target. It therefore keeps the previous completed image (or the
authored panel art on first use) until the new doll is actually drawable.
`PaperdollFramePresenter` also builds, redresses and prewarms the inventory
doll while its tab is hidden, so opening the tab no longer starts residency
work from zero. The decisive intermittent fault was the shared render target:
one Vulkan flight slot could clear/write it while the other still sampled it.
`PrivateEntityViewportRenderer` now owns a bounded target, sampler and texture
slot per encountered GPU flight slot, and publishes the current frame's exact
handle. The same correction covers inventory paperdoll, creature appraisal and
character-creation preview viewports. Temporary flight-slot colors proved both
slots render the complete textured doll; all probes were then removed. The
clean Release client passed first open plus two repeated close/reopen cycles on
the local ACE server with no missing frame and no runtime error. Focused App,
Runtime and input tests pass 384/384, including the byte-exact production
SPIR-V oracle; the Release solution builds with zero warnings/errors.
Owner report at the Campaign AS connected gate: the animated 3-D paperdoll
in the examination window (LayoutDesc `0x2100006B` element `0x10000148`)
worked correctly at baseline `974fe88a` (praised the same session) and was
@ -4004,8 +4265,7 @@ switching stays #376.
## #373 — Configure Keyboard: DAT `ActionMap.ConflictingMaps` not consulted — the combat cluster raises false conflict prompts
**Status:** OPEN — filed 2026-08-11 at Campaign OP slice OP8's re-review
round 2 (R1's scope boundary).
**Status:** DONE 2026-08-26 — fixed as the first #446 keyboard-parity slice.
The DAT ActionMap (DID `0x26000000`) carries a `ConflictingMaps` table
retail's `UIOption_ActionKeyMap` consults when deciding whether two rows
@ -4022,17 +4282,22 @@ new action to one) prompts "overwrite N bindings?" where retail prompts
for fewer or none. Accepting the prompt then strips retail-default
bindings that should have survived.
The OP8 round-2 fix already excluded store-only rows (`MappedAction is
null`) from the conflict universe — those cannot collide because they
never reach the InputDispatcher — but retail-mapped cross-context
sharing needs the real table. **Fix:** parse `ConflictingMaps` in
The OP8 round-2 fix originally excluded store-only rows (`MappedAction is
null`) from the conflict universe. Campaign KB later mapped and enabled every
one of the 306 installed rows, eliminating that tier; retail cross-context
sharing still needs the real table. **Fix:** parse `ConflictingMaps` in
`RetailActionMap` (the reader already round-trips the field —
`RetailActionMapReaderTests` constructs it), and make `FindConflicts`
consult it: two rows sharing a chord conflict only if their contexts'
ConflictingMaps entries say so. Conformance-test against the combat
cluster's authored defaults (five keys, multi-row each, zero prompts on
a no-op rebind). The gate script's §OP8 warns the user off treating the
false prompts as new breakage until this lands.
a no-op rebind).
**Fix landed:** `RetailActionMapSnapshot` now owns the copied DAT conflict
sets and `KeyboardConfigController.FindConflicts` consults them before
offering reassignment. Hermetic tests pin permitted cross-combat sharing and
declared cross-map conflicts; an installed-DAT test pins that melee, missile,
and magic are pairwise non-conflicting. Same-context conflicts remain active.
## #372 — Options panel: Character/Chat/Config tabs render BLANK on screen and most Gameplay buttons do nothing (connected-gate failure)

File diff suppressed because one or more lines are too long

View file

@ -94,8 +94,9 @@ DAT-authored values.
register row for the ACE-sourced 2013-unverifiable mapping.
- **D4 — Configure Keyboard is the campaign's rebind screen** (it is the
ONLY rebind screen — D1). Port `gmKeyboardUI`'s shape and DAT ActionMap
data (lane D Option C) but persist to `keybinds.json`; retail `.keymap`
file interchange is a register-row deferral.
data (lane D Option C). **Superseded 2026-08-26 by #446:** named retail
`.keymap` Load File / Save As/startup/shutdown persistence now ships;
`keybinds.json` remains only the host-command compatibility mirror.
- **D5 — dead-endpoint buttons short-circuit to their own retail failure
strings.** Urgent Assistance / Report Abuse open a defunct
`support.turbine.com` URL in retail; acdream skips the browser launch and
@ -360,8 +361,8 @@ modal capture; right-click erases; N-way cross-map conflicts + the
non-user-bindable refusal per lane D §5; Save/Cancel; Reset-to-defaults
reloads the DAT maps. Persistence: `keybinds.json` (D4).
**Register rows:** `.keymap` file interchange not implemented (D4); any
retail column/behaviour consciously narrowed.
**Register rows:** any retail column/behaviour consciously narrowed. The
former D4 `.keymap` deferral was retired by #446 on 2026-08-26.
**Gate:** connected — rebind a movement key, conflict prompt on a taken
chord, persistence across relaunch, reset restores retail defaults.
@ -394,7 +395,8 @@ chord, persistence across relaunch, reset restores retail defaults.
`2026-08-09-chat-retail-window-shell.md` §6.3's register row.
- A pre-world character-select flow (D6 adapts; its register row carries
the future work).
- Retail `.keymap` file read/write (D4 register row).
- None for retail `.keymap` file read/write; #446 implemented it on
2026-08-26 and retired AP-202.
- The `0x21000017` docked `gmPanelUI` host variant — acdream ships the
floating host only (register row in OP3 if the review deems it a
divergence; retail exposes both).

View file

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

View file

@ -5,6 +5,13 @@ Research lane D of the settings-track campaign
questions **Q5** (Configure Keyboard: retail's keymap UI + storage) and
**Q6** (what every Gameplay Options tab button does).
> **2026-08-26 implementation addendum:** the report below describes the
> pre-OP8 state and its design choices at that date. acdream has now shipped
> Option C end to end: all 306 installed-DAT rows, retail conflicts/capture,
> and real named `.keymap` Load File / Save As/startup/shutdown persistence.
> See `docs/research/2026-08-26-retail-keyboard-routing-audit.md`; AP-202 is
> retired.
**Report only.** No repo code was changed. Every retail claim below carries
a named symbol + address from the Sept 2013 EoR PDB-paired build. The
PDB/binary pairing was verified first:

View file

@ -0,0 +1,131 @@
# Combined client parity owner gate
**Date prepared:** 2026-08-26
**Run:** OWNER-COMPLETE 2026-08-26 on one exact Release binary.
**Scope:** #443#450 plus the complete inventory/vendor interaction audit.
**Outcome:** Sections AC and EF passed, including the mid-drag cursor-icon
re-test added during the round. Issues #444#450 are owner-accepted. Section D
failed: the private paperdoll remained missing, so #443 stays open as the only
surviving defect. Tested executable SHA-256:
`173989F3C85C05C0746D628CDF9C6194F6A5E3EFD4597806BF83417483C37B42`.
Keep the client log for the whole run and take a screenshot for any visual or
text mismatch. For every refused item action, record the cursor color, exact
SpewBox line, and whether the item visibly moved before the refusal.
## A. Keyboard, camera, combat input, and relog — #446/#450
1. In Configure Keyboard, bind Move Forward to bare Shift while Toggle
Walk/Run already owns it. Verify the retail conflict dialog appears and the
chosen resolution is honored. Repeat with a known allowed shared chord.
2. Apply a changed binding, close/reopen Options, then restart the client.
Verify it survives. Test Revert, Defaults, Cancel, and Load/Save `.keymap`.
3. Press regular Enter: chat input must focus. Press keypad Enter outside chat:
it must perform only its configured camera action and must not focus chat.
4. Press bare Escape through the retail ladder: cancel target/focus/selection
first, then toggle Gameplay Options. It must never enter a developer orbit
or bird's-eye mode. Preserve the approved mouse-wheel zoom range.
5. Hold End, Page Down, or Delete in melee mode. The bar must charge while the
key is held and attack only on release, using the selected height.
6. Shift+Escape to character selection, immediately re-enter, and verify the
destination loads and exits portal space. Repeat twice in one process.
## B. Chat and combat text — #447/#448
1. Run `@acecommands`. Every non-empty server line must be visible; an
oversized response must retain the newest complete lines, not blank the
transcript.
2. Run `@acehelp acecommands` and send ordinary chat afterward. Verify normal
text, filtering, and scrolling remain intact.
3. Land ordinary and critical melee hits. Outgoing lines must match retail
wording and punctuation and contain no acdream-added percentage.
## C. Selection, use, containers, movement, and splitting — #445/#449
Use a normal item, unusable item, wearable, weapon, two mergeable stacks, a
Pyreal stack, one side pack, a full main pack, a full side pack, an open
external container, and a creature/player target.
1. Single-click and right-click inventory, side-pack, external-container, and
paperdoll items. Verify one global selection, stable highlight, status text,
and right-click examination.
2. Select an owned Pyreal stack. The toolbar must read
`<stack> <appropriate name> (of <total carried Pyreals>)`, with no comma
insertion added by acdream.
3. Single- and double-click a carried side pack. It must open on press, issue
no generic item-use request, and remain stably selected.
4. Double-click usable, unusable, wearable, and wieldable items. Verify one
action. Any local refusal must appear once in SpewBox, not in normal chat.
5. Move a full item between main pack and side pack. Before acknowledgement,
the source must remain canonical with retail's waiting/ghost presentation;
after success it appears only at the destination. Force one rejection and
verify no duplicate, disappearance, or speculative capacity change.
While holding the item under the cursor, move across the inventory and wait
through several ordinary object updates: the cursor icon must remain visible
until release. This re-gates the mid-drag procedural-refresh fix found during
the first 2026-08-26 owner pass.
6. Fill the main pack, observe a rejected move, drop one loose item, then move
an item from a side pack into the freed slot immediately. It must accept
without reopening the inventory window (#449).
7. From a stack of 10, select 2 and split into an empty main-pack slot, an open
side pack, and an external container. Each successful result must be 8+2.
8. Merge full and partial stacks. Verify selected quantity, target selection,
target-cap clamp, source remainder, and exact refusal text for a full target.
9. Drop full and partial stacks to the world, then pick them up. Verify pending
visuals, authoritative commit, failure cleanup, and no duplicate object.
10. Give a full and partial stack to a creature; drag onto another player and
verify secure-trade routing. Hover rejection must stay silent; release
rejection must print the exact ClientLocal reason.
11. Equip once by double-click and once by paperdoll drag. Test a clothing
conflict and weapon replacement. Canonical inventory/paperdoll ownership
must not change before the authoritative response.
## D. Private paperdoll viewport — #443
1. From a fresh process, open inventory and assess one monster and one player.
2. The animated doll must be present on first open, not appear only after a
delay. Close/reopen each view several times and change equipment once.
3. Record #443 independently if the viewport is late or missing even when the
underlying equip/inventory transaction is correct.
## E. Vendor parity and alternate currency — #444/#445
1. On the browse list, single-click selects, right-click examines, and
double-click buys exactly one/current-slider unit through the normal retail
purchase path.
2. Add a stack quantity greater than one to Buying. Double-click its staged
row: remove exactly one unit and print
`Removing <name> from shopping list` once in SpewBox.
3. Stage an owned item in Selling. Right-click examines it; double-click
removes the whole staged entry and prints the same removal form.
4. Drag a staged Selling row: it must unstage. Repeat after selecting only part
of its stack: SpewBox must print
`You cannot split items from this panel` and the slider must reset to max.
5. From an owned stack of 10, select 2 and drag into Selling. Verify the
temporary row resolves to the new authoritative stack of 2, Sell All sells
exactly 2, and 8 remain (#445).
6. At an alternate-currency vendor, note holdings in the Items cost sentence
and Buying/Selling purse lines. Buy once: every visible holding must decrease
immediately and remain correct after authoritative inventory refresh,
tab changes, and vendor reopen (#444).
7. Buy All once with enough currency and once without enough. The first uses
the refreshed balance; the second prints retail's insufficient-funds notice
and sends no purchase.
## F. Re-entrant and lifecycle stress
1. Change selection during a pending split, close an external container during
a pending move, and retry immediately after a refusal.
2. Attempt a second inventory operation while one request is pending. Verify a
clean retail refusal/no-op, never duplicated wire action or stuck busy state.
3. Log out or portal with a recently completed interaction, re-enter, and
verify pending projections and the request ledger converge to zero.
## Pass rule
The pass rule was satisfied for #444#450 on the exact binary recorded above.
#443 remains open by itself because the transaction rows passed and the failure
was confined to private viewport residency.

View file

@ -0,0 +1,43 @@
# Issues #444, #445, #447 — consolidated owner gate
Run these checks together on the next connected test build. They deliberately
require no special probes; record the client log and one screenshot per
section. If a split fails, also record the exact visible error text.
## #444 — alternate-currency vendor balance
1. Open a vendor that accepts an alternate currency and note the amount shown
in both the Items cost sentence and the Buying-tab purse line.
2. Buy one item.
3. Confirm both visible amounts decrease immediately and remain correct after
the server refresh. Close/reopen the tab and vendor and confirm the amount
does not bounce back to the old snapshot.
4. Buy All once with enough currency, then once without enough. Confirm the
first uses the refreshed holding and the second shows retail's insufficient-
money notice without sending a purchase.
## #445 — inventory and vendor partial stack splits
1. In the main pack, select a stack of 10, set the slider to 2, and drag it to
an empty main-pack slot while at least one side bag is equipped.
2. Confirm the source becomes 8 and a new stack of exactly 2 appears at the
chosen loose-item position; no error should appear.
3. Repeat into an open side bag and confirm the same 8+2 result.
4. Reset to a stack of 10, select 2, and drag it onto the vendor Selling list.
Confirm the client prints `Splitting the <name> before selling them`, then
the staged row resolves to the new stack of 2 rather than the source stack.
5. Press Sell All and confirm exactly 2 are sold and 8 remain.
## #447`@acecommands` multiline response
1. Run `@acecommands` on the test account.
2. Confirm command text is visible, consecutive server lines are readable,
and there is no screen of blank chat rows.
3. Scroll through the retained result. A response beyond retail's 10,000-
character transcript cap should retain the newest complete command lines
instead of blanking the entire response.
4. Run `@acehelp acecommands` and one ordinary chat command afterward; confirm
their text and normal chat presentation remain intact.
Pass all three sections on one exact binary, then mark #444/#445/#447
owner-accepted together.

View file

@ -0,0 +1,745 @@
# Retail inventory interaction audit
**Date:** 2026-08-26
**Scope:** Selection, status text, single/double/right click, drag/drop,
container movement, ground pickup/drop, equipping, stack splitting, vendor
staging, failure feedback, and SpewBox routing.
**Change policy:** Audit followed by implementation in the same worktree.
**Source snapshot:** `0c699240`, plus the already-present working-tree fixes for
#444, #445, #446, #447, and #449. Those fixes are assessed as found; this
report does not claim that they have been committed or user-accepted.
## Implementation closeout — 2026-08-26
Slices 14 below are implemented and automated-test covered. Gameplay
refusals now use the `ClientLocal` SpewBox route; move/wield failure kinds are
complete; full move/drop/wield are request-first with pending projections;
owned-container and vendor-row mouse behavior follows the named retail
handlers; hover and release share one side-effect-free legality policy; and
local item-policy wording is composed from retail's exact literals.
The last toolbar uncertainty is also resolved. Raw retail bytes at
`gmToolbarUI::HandleSelectionChanged @ 0x004BF4EF` push format literal
`0x007B4748`, which decodes to `%d %hs (of %d)`. The owned Pyreal-stack branch
now renders that exact stack/name/total shape. The final AutoWield fallback was
also corrected: retail does not print the invented “That slot is already in
use”; with automatic unblocking enabled it moves the preferred occupied-slot
item to the backpack, waits for the authoritative move, then retries the
wield. Slice 5 remains deliberately deferred as the single combined connected
owner gate.
## Audited root causes (now fixed)
The inventory implementation was not missing one isolated rule. Most individual
operations existed and used the correct wire messages, but three seams made the
whole experience feel intermittent:
1. **Some retail-local refusal text was routed to a dead production callback.**
`ItemInteractionController` and `AutoWieldController` used a `toast` callback
for a substantial class of local rejections while `GameWindow` supplied
`null`. Retail sends these messages to the `ClientLocal` channel, which is
the SpewBox in acdream. The result was a real silent-failure class, not merely
different wording.
2. **Full moves, world drops, and wield operations mutated canonical inventory
state before the server accepted them.** Retail normally leaves the source
canonical object in place, adds a waiting/ghost projection at the intended
destination, and commits only after the authoritative object update. The
old optimistic mutation was reversible, but selection, capacity,
paperdoll, vendor, and other observers could see a transient state that never
existed on the server. This was the largest structural flakiness risk.
3. **Several list-specific mouse behaviors did not match retail.** In
particular, staged vendor rows could not be double-clicked or dragged to
remove them, staged rows lacked right-click examine, and owned side-pack
double-click/open ordering differed from retail.
The wire builders, global selection/split model, merge-first rule, request gate,
most right-click examine paths, normal item double-click use/equip, external
container pickup, paperdoll placement validation, and the newly repaired
vendor-split/main-pack-capacity paths are broadly aligned with retail.
The implementation was executed in this order:
1. Route every local item refusal through `ClientLocal`/SpewBox.
2. Replace canonical optimistic movement with retail-style pending projections.
3. Close the vendor staged-row and owned-container input differences.
4. Deepen hover/drop legality and finish exact status/failure text parity.
5. Run one connected interaction matrix across inventory, paperdoll, ground,
external containers, and vendors.
## Method and evidence standard
This audit used four evidence layers:
- The September 2013 named retail pseudo-C under
`docs/research/named-retail/acclient_2013_pseudo_c.txt`, searched by named
class and method before relying on older address-only material.
- Existing focused retail notes under `docs/research/`, especially the item,
drag, give, world-drop, use/autowear, and vendor investigations.
- The current production controllers, Runtime owners, UI input dispatch, wire
request builders, and communication routing.
- Existing focused tests, used to distinguish implemented intent from behavior
that is not currently protected.
Verdicts in this report mean:
- **Match:** the important retail behavior and ownership rule are present.
- **Partial:** the common path matches, but a retail branch, presentation rule,
or failure path is absent.
- **Mismatch:** direct retail evidence contradicts the current behavior.
- **Risk:** the mechanism differs in a way likely to produce transient or race
defects, but this audit does not assert a particular live symptom without a
connected reproduction.
- **Gate pending:** a code fix exists in the working tree and has automated
coverage, but the owner has not yet accepted the live behavior.
## Retail reference model
### One selected object and one split quantity
Retail has a client-global selected object. Clicking an item selects it;
right-click first selects it and then examines it; beginning a drag selects it
if it was not already selected. The toolbar observes that global selection and
shows the name, stack quantity, and split controls.
The split quantity is also global and applies only when the dragged/requested
object is the selected object. An unselected stack always means the full stack.
Changing selection resets/reseeds the split amount. Vendor-owned selected
stacks use a different initial amount from normal owned stacks.
Primary anchors:
- `UIElement_ItemList::ListenToElementMessage` at `0x004E4D50`
- `UIElement_ItemList::BeginDrag` at `0x004E32D0`
- `gmToolbarUI::HandleSelectionChanged` at `0x004BF380`
- `ItemHolder::GetObjectSplitSize` in the named retail pseudo-C
### Mouse-down establishes intent; click completion performs list action
For a retail item-list entry, left press first gives target mode a chance to
consume the object. Otherwise it selects the object. A container-list entry
also opens that child container and updates its open indicator in this same
item-list message path.
Right press selects and examines. Double-click invokes generic `UseObject` for
ordinary list items, but the generic double-use path is suppressed for an
owned `containerList` entry. The ground/external root is explicitly allowed.
This distinction matters: a side pack is opened as a container, not opened and
then generically used as an ordinary item on the second click.
### Dragging is a request with pending presentation
Beginning a physical-item drag produces a source waiting/ghost state. Vendor,
salvage, and shortcut lists are special list types and do not use the same
physical-source waiting ghost.
Hover is advisory and silent. Release reruns legality with feedback enabled.
For a normal container move, retail retains the canonical source ownership and
adds a pending destination projection. The server's authoritative object update
commits the move. Rejection removes the pending projection and prints the local
failure. This same general principle appears in world placement and split-to-
world handling.
Primary anchors:
- `UIElement_ItemList::BeginDrag` at `0x004E32D0`
- `UIElement_ItemList::DragOver` at `0x004E3400`
- `UIElement_ItemList::AcceptDragObject` at `0x004E4250`
- `UIElement_ItemList::HandleDropRelease` at `0x004E4790`
- `ItemHolder::AttemptToPlaceInContainer_IsItemLegal` at `0x005870C0`
- `ItemHolder::AttemptToPlaceInContainer_IsContainerLegal` at `0x005879B0`
- `ItemHolder::WillItemFitInContainer` at `0x00587D60`
- `ItemHolder::IsDragIntoContainerAttemptLegal` at `0x00587E90`
### Drop target dispatch is ordered
Retail's three-dimensional drop/give dispatcher follows this practical order:
1. Require an owned, movable source that is not currently in trade.
2. Dropping on self means the main backpack.
3. Target zero means ground placement or split-to-world.
4. Try stack merge before treating the target as a container.
5. A player target opens/routes through secure trade.
6. A creature target uses give-item behavior.
7. A container target must be open, unlocked, and legal.
8. Vendor lists use their own staging rules.
9. Otherwise resolve as a ground placement or refuse it.
`AttemptMerge` uses the selected split amount, clamps to target capacity, sends
the merge request, and selects the target stack. Give-item is request-only; it
does not optimistically remove the source from canonical inventory.
Primary anchors:
- `ItemHolder::AttemptMerge` at `0x005878F0`
- `ItemHolder::AttemptPlaceIn3D` at `0x00588600`
- `docs/research/2026-07-13-retail-give-item-pseudocode.md`
- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md`
### Use and equip
Generic double-click use passes through `ItemHolder::DetermineUseResult` and
`ItemHolder::UseObject`, with a short use throttle. The item is classified as
direct-use, targeted-use, pickup, equip/autowear, trade, salvage, or game use.
Retail locally refuses invalid states and prints a `ClientLocal` message.
Paperdoll 3D clicks and discrete equipment-slot lists share the same global
selection/examine model. Dropping on a paperdoll location validates the exact
location, then chooses auto-wear or auto-wield behavior. Clothing overlap can
be rejected locally; weapon replacement has different rules.
Primary anchors:
- `ItemHolder::DetermineUseResult` at `0x00588460`
- `ItemHolder::UseObject` at `0x00588A80`
- `CPlayerSystem::UsingItem` at `0x00562F70`
- `gmPaperDollUI::ListenToElementMessage` at `0x004A5C30`
- `gmPaperDollUI::AcceptDragObject` at `0x004A3B10`
- `gmPaperDollUI::AcceptPaperDollDragObject` at `0x004A4A70`
- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md`
### Vendor rows are active item lists
Direct named-retail evidence establishes these behaviors:
- Double-clicking a vendor browse row buys one item.
- Double-clicking a staged buying row removes it and prints
“Removing %s from shopping list” through `ClientLocal`.
- Double-clicking a staged selling row removes it, clears its sell state, and
prints the same form of message.
- Dragging an already-staged selling row removes it from the staged list.
- If a partial split is selected while dragging a staged selling row, retail
refuses to split that row, prints “You cannot split items from this panel”,
and resets the split control to the stack maximum.
- A new partial-stack drag into the Selling list sends a split request, creates
a temporary staged row, and replaces that row when the new matching object
arrives.
- Hover rejection is silent; release rejection prints to `ClientLocal`.
Primary anchors:
- `gmVendorUI::HandleMousePresses` at `0x004C40D0`
- `gmVendorUI::RecvNotice_ItemListBeginDrag` at `0x004C4380`
- `VendorSellUI::DragItemAcceptable` at `0x004C20C0`
- `VendorSellUI::AcceptDragObject` at `0x004C4F00`
- `VendorSellUI::ItemAttributesChanged` at `0x004C3FD0`
This corrects an older project research conclusion: browse-row double-click
buy is retail behavior. It is not an acdream modernization.
### Feedback destination
Retail item-policy and request-failure messages are sent on the local client
text channel. In acdream, `RuntimeCommunicationState.AddText` maps
`ClientLocal` (`0x1A`) to the SpewBox only: it does not add the line to the
chat transcript and does not apply a chat timestamp.
Hover failures are normally silent. Release/action failures are not. Server
request failures are composed by `ACCWeenieObject::ServerSaysAttemptFailed`
at `0x0058EAE0`, including move and wield failures.
## Current acdream ownership and routing
The relevant production flow is:
```text
UiRoot / UiItemSlot
-> InventoryController | ExternalContainerController | PaperdollController
| VendorUiController | SelectedObjectController
-> ItemInteractionController / AutoWieldController
-> RuntimeInventoryState + RuntimeActionState transactions
-> ClientObjectTable (canonical object ownership)
-> outbound request builder
-> authoritative object update / request failure
-> RuntimeCommunicationState.ClientLocal -> SpewBox
```
Important owners:
- `SelectionState` is the sole selected-object owner shared by inventory,
paperdoll, vendor, world selection, and toolbar status.
- `RuntimeInventoryState` owns external-container state, item-use transaction
state, shared busy/request state, split/pending placement state, and borrows
the canonical `ClientObjectTable`.
- `SelectedObjectController` projects selection into the authored toolbar and
owns the split-slider presentation.
- `ItemInteractionController` classifies use/drop/give/move operations and
sends requests.
- `InventoryController`, `ExternalContainerController`, `PaperdollController`,
and `VendorUiController` own their list-specific input and projections.
This ownership shape aligns with the architecture document. The central issue
is not duplicate state; it is which state is mutated before acknowledgement.
## Behavior matrix
| Surface/action | Retail | Current acdream | Verdict |
|---|---|---|---|
| Inventory left press | Target-mode consume, otherwise select | `PrimaryItemPressed` does the same | Match |
| Ordinary item single click | Select; no generic use | Mouse-down selects | Match |
| Ordinary item double-click | Generic use/equip | `DoubleClicked = ActivateItem` | Match |
| Owned side-pack single press | Select and open in the item-list handler | Selects and opens on mouse-down | Match, implemented |
| Owned side-pack double-click | Open behavior; generic item double-use suppressed | Opens once; generic activation is suppressed | Match, implemented |
| Inventory right-click | Select, then examine | Select and examine | Match |
| Drag lift | Select if needed; source ghost | Selects and ghosts | Match |
| Drag hover | Silent, legality-aware green/red | Silent and shares the release legality decision | Match, implemented |
| Full internal move | Request plus pending destination projection; canonical source waits for server | Request-first pending projection; authoritative update commits | Match, implemented |
| Merge stacks | Merge before container placement; selected split amount; select target | Same broad behavior | Match |
| Partial move to container | Split request; wait for authoritative object | Request-only | Match |
| Drop to ground | Request/pending presentation; source remains canonical until response | Request-first; canonical source waits for response | Match, implemented |
| Split to ground | Global pending split; select arriving matching object; timeout | Request/pending path exists | Broad match |
| Pick up from ground | Pending destination projection; authoritative commit | Pending destination path | Match |
| Open external container | Root/nested list-specific behavior | Root double-click, nested open behavior | Broad match |
| Move to external container | Request-only, open/unlocked legality, server commit | Request-only with shared hover/release legality | Match, implemented |
| Give to creature | Request-only; selected split amount | Request-only | Match |
| Give/drop to player | Secure-trade routing | Secure-trade routing exists | Broad match |
| Paperdoll click/right-click | Global select/examine | Global select/examine | Match |
| Paperdoll drag equip | Exact location validation; auto-wear/wield | Same broad split | Broad match |
| Full wield | Authoritative request model | Request-first; canonical ownership waits for response | Match, implemented |
| Invalid item use/equip | ClientLocal text in SpewBox | Shared `ReportClientLocal` route | Match, implemented |
| Selected status | Normal name or `{quantity} name`; owned coin is `%d %hs (of %d)` | Both branches implemented | Match, implemented |
| Split applicability | Only selected stack uses global quantity | Same | Match |
| Vendor browse single/right | Select; right-click examine | Select and right-click examine | Match |
| Vendor browse double | Buy one | Buy one | Match |
| Drag inventory to Selling | Stage full or selected partial quantity | Present; partial temp-row replacement present | Match, #445 gate pending |
| Vendor hover refusal | Silent | Silent | Match |
| Vendor release refusal | ClientLocal/SpewBox | System message/SpewBox path | Match |
| Staged Buying double-click | Remove one + SpewBox line | Same | Match, implemented |
| Staged Selling double-click | Remove row, clear state + SpewBox line | Same | Match, implemented |
| Staged Selling drag | Remove row; partial selection warns and resets split | Same, exact refusal + reset | Match, implemented |
| Staged row right-click | Generic select/examine item-list behavior | Select and examine on every vendor list role | Match, implemented |
| Main-pack capacity | Items and carried containers counted separately | Separate loose-item count now present | Match, #449 gate pending |
| Server move/wield failure text | Exact ClientLocal move/wield compositions | Both request kinds and compositions present | Match, implemented |
## Findings
### F1 — local inventory refusals can be completely silent
**Resolution:** CLOSED IN CODE — one `ReportClientLocal` route now selects
interface text, system text, or the test fallback in that order.
**Priority:** P0
**Confidence:** Confirmed by production composition
`ItemInteractionController` uses two different presentation routes:
- `_systemMessage` / `_interfaceText`, which are wired to
`RuntimeCommunicationState.AddText(..., ClientLocal)` and reach SpewBox.
- `_toast`, used by many local policy refusals.
`InteractionRetainedUiComposition` forwards its `toast` dependency, but
`GameWindow` currently sets the production composition toast to `null` after
the developer-toast surface was removed. Consequently, the local rejection
still aborts the action, but the user receives no explanation.
Affected classes include invalid item use, missing use target, trade/wield
requirements, locked or unsuitable targets, invalid move/give/drop states,
midair/drop refusal, and paperdoll slot-in-use refusal. Exact membership should
be frozen in a focused message-routing test before changing it.
Retail evidence is unambiguous: these are local client text messages and belong
in SpewBox, not a transient developer toast.
**Future fix:** remove the semantic split for gameplay failure text. Give item
controllers one `ClientLocal` sink and reserve any visual toast mechanism for
non-retail developer/launcher notifications.
### F2 — optimistic canonical moves expose impossible intermediate state
**Resolution:** CLOSED IN CODE — full move, world drop, and wield dispatch
requests without mutating canonical ownership; pending source/destination
presentation converges on confirmation, failure, and reset.
**Priority:** P0 architectural correction
**Confidence:** Confirmed mechanism divergence; symptom linkage requires gates
The full-stack internal move and world-drop paths use optimistic operations
against the canonical object table. Full wield uses the same pattern. Failure
rollback exists, but all borrowers can observe the speculative state:
- selection and toolbar status;
- loose-item and carried-container capacity;
- paperdoll slots;
- vendor sell eligibility/staging;
- external-container views;
- plugins and Runtime views.
Retail instead keeps source canonical ownership stable and uses waiting/ghost
presentation at the intended destination until the server update arrives.
This does not prove that every reported intermittent inventory symptom comes
from this seam. It does explain why otherwise-correct controllers can disagree
briefly and why a rejection/late response/re-entrant action can make the UI feel
flaky.
**Future fix:** model full move/drop/wield like the existing request-only split,
give, ground-pickup, and external-container paths. Store a generation-scoped
pending placement intent and presentation ghost, send the request, and let the
authoritative update commit canonical ownership. On failure/timeout/reset,
remove only the pending presentation.
### F3 — vendor staged-row removal behavior is missing
**Resolution:** CLOSED IN CODE — staged rows implement the retail
double-click, right-click, drag-lift, message, and split-reset branches.
**Priority:** P1
**Confidence:** Confirmed by direct named-retail functions
Current staged Buying and Selling rows only bind selection. They have no
double-click removal. Selling rows also disable drag source behavior.
Retail supports:
- double-click staged Buying to remove;
- double-click staged Selling to remove and clear sell state;
- drag staged Selling to remove;
- a precise ClientLocal removal line;
- a partial-split refusal/reset when dragging from the staged Selling list.
**Future fix:** add list-role-specific actions rather than routing these rows
through generic item activation. Protect each action with unit tests that also
assert selection, sell-state cleanup, totals, and exact SpewBox routing.
### F4 — owned side-pack click/double-click sequencing differs
**Resolution:** CLOSED IN CODE — carried containers open on press and the
generic double-use route is suppressed for that list role.
**Priority:** P1
**Confidence:** Confirmed structural mismatch
Retail opens a carried child container in the item-list press handler and
suppresses generic double-click use for a `containerList` item. acdream selects
on mouse-down, opens on completed click, and binds generic activation to double
click for every inventory cell. `UiRoot` emits the second click before the
double-click event, so a double-click can both open and activate the pack.
This is a plausible source of redundant requests and awkward drag/open
interactions. It should be fixed by explicit item-list role, not by a global
double-click timing change, because ordinary items and the external-container
root intentionally retain double-click use/open behavior.
### F5 — hover acceptance is less strict than release/server legality
**Resolution:** CLOSED IN CODE — `InventoryContainerPlacementPolicy` is the
shared silent-hover/speaking-release decision for owned and external lists.
**Priority:** P1/P2
**Confidence:** Confirmed code difference
Inventory-grid hover mostly checks list role, basic object class, and capacity.
External-container hover is broader still. Retail's predicates incorporate
ownership, trade state, source/destination identity, real carrying-container
restrictions, open/locked state, destination capacity type, and other legal
conditions.
The practical symptom is a green cursor followed by a refusal or apparent
no-op on release. Hover must remain silent, but its boolean should be produced
from the same pure legality decision used at release.
**Future fix:** extract one side-effect-free placement decision that returns a
reason code. Hover consumes only allowed/denied; release converts the same
reason to exact ClientLocal text.
### F6 — selected status lacks retail's owned-coin special case
**Resolution:** CLOSED IN CODE — the PDB-matched retail executable resolves
the literal at `0x007B4748` to `%d %hs (of %d)`; the controller now reads the
player's `CoinValue` and uses that exact branch for owned WCID 273 stacks.
**Priority:** P2
**Confidence:** Byte-resolved from the PDB-matched retail executable
Normal current text—name for a singleton and `{stackSize} {name}` for a
stack—matches the main retail branch. Retail has an additional owned-coinstack
formatting branch that derives a total/value-aware display and name. The
current controller always uses the generic stack prefix.
Binary inspection resolves the apparent vtable-symbol artifact: the raw call
site pushes `0x007B4748`, `%d %hs (of %d)`, with stack size, appropriate name,
and the player's integer `CoinValue` as its three arguments.
### F7 — request failure coverage omits move and wield kinds
**Resolution:** CLOSED IN CODE — both request kinds are represented and route
through the item-aware retail failure composer.
**Priority:** P2
**Confidence:** Confirmed enum/composer gap
Retail's `ServerSaysAttemptFailed` includes move and wield result families.
The current request-failure model and `InventoryFailureMessages` cover merge,
split, pickup, put, drop, and give, but do not represent the retail move/wield
families. A server-side failure in those operations therefore cannot produce
the exact item-aware retail sentence through the common composer.
### F8 — current retail-divergence documentation is wrong about vendor double-click
**Resolution:** CLOSED — the older research is corrected and AP-171 retired.
**Priority:** Documentation correction before implementation
**Confidence:** Confirmed by direct named-retail evidence
Older vendor research and AP-171 characterize double-click browse-row purchase
as an acdream enhancement. `gmVendorUI::HandleMousePresses` directly calls
`BuySingleItem` on the retail Items-list double-click. Current browse behavior
is correct; the documentation is not. Leaving this claim in the register risks
a future parity cleanup deleting a retail feature.
### F9 — #445 and #449 need connected acceptance, not more inference
**Priority:** Gate now
**Confidence:** Automated fixes present
- #445 now uses the selected split quantity for vendor selling, creates a
temporary staged row, and replaces it when the authoritative split object
arrives.
- #449 now counts loose items separately from carried container objects when
deciding whether the main backpack is full.
Both have focused tests in the current working tree. Neither should be marked
closed until a live server gate covers success, refusal, repeated action, and
selection changes.
### F10 — paperdoll disappearance is a separate rendering/residency defect
**Priority:** Keep separate from transaction fixes
**Confidence:** Existing issue #443
The intermittent missing paperdoll that heals after a delay is tracked as
paperdoll first-open/residency behavior. It can make a correct equip transaction
look broken, so it belongs in the combined user gate, but it should not be
folded into inventory ownership or input logic without evidence.
## SpewBox contract
The following should appear in the SpewBox through `ClientLocal` when the user
commits the action and it is refused or changed:
- invalid use/equip/wield state;
- “choose a target” or invalid target;
- cannot move/drop/give an item;
- locked, closed, full, or otherwise illegal destination;
- merge/split/pickup/put/drop/give/move/wield request failure;
- vendor item cannot be sold or split in that list;
- removal from a vendor shopping/selling list;
- automatic removal of conflicting wear items where retail reports it;
- midair or other locally cancelled placement when retail reports it.
The following should be silent:
- merely hovering a rejected drop target;
- moving the pointer away without releasing;
- ordinary selection changes;
- beginning a legal drag.
These messages should not be duplicated into the normal chat log and should
not gain chat timestamps. That is already how `ClientLocal` behaves in the
communication owner.
## Existing automated coverage
The repository already has strong narrow coverage in:
- `InventoryControllerTests`: population, selection, open/right-click,
drag/ghost, pending pickup, split, merge, capacity, rollback, and #449.
- `ExternalContainerControllerTests`: root/nested behavior, selection,
right-click, partial split, and pending gates.
- `PaperdollControllerTests`: selection, examine, drag, and wield placement.
- `SelectedObjectControllerTests`: name, stack status, slider, and vendor split
initialization.
- `VendorUiControllerTests`: browse, buy quantities, selection/examine,
staging, partial vendor split/failure, rejection feedback, and alternate
currency.
- `ItemInteractionControllerTests`: use/equip, world drop, give, partial-stack
behavior, failures, and transaction lifecycle.
- Runtime inventory tests: request ownership, reset, and lifecycle behavior.
The pre-implementation test suite was strongest at proving controller-local
intent. The implementation program below adds the missing transaction and
cross-controller coverage.
## Automated gates added by the implementation
The implementation adds or updates coverage for the following:
1. A production-composition test proving every local policy rejection reaches
`ClientLocal`/SpewBox and no gameplay failure depends on a toast callback.
2. Owned side-pack single/double-click tests proving one open action and no
generic use request, including the second-click event order.
3. Vendor staged Buying and Selling double-click removal tests with exact
selection, totals, state cleanup, and message assertions.
4. Vendor staged Selling drag-to-remove and selected-partial split-reset tests.
5. Staged vendor-row right-click select/examine tests.
6. A table-driven pure legality test shared by hover and release for inventory,
external container, ground, player, creature, vendor, self, locked container,
full item slots, and full container slots.
7. Owned coinstack toolbar-status parity using the byte-resolved exact format.
8. Move and wield authoritative failure-composition tests.
9. Transaction-observer tests proving canonical ownership does not change
before acknowledgement while selection, capacity, vendor, and paperdoll
borrow the same state.
10. Re-entrant sequences: drag while a request is pending, selection change
during split, rejection after container close, late response after session
reset, and repeated action after rollback.
## Executed implementation program
### Slice 1 — feedback integrity — COMPLETE
- Replace gameplay `toast` refusal calls with the shared ClientLocal sink.
- Add the missing move/wield failure kinds and exact item-aware compositions.
- Freeze hover-silent versus release-speaks behavior.
- Correct the vendor double-click documentation claim.
This is small, high-confidence, and immediately turns “nothing happened” into
an actionable player explanation.
### Slice 2 — authoritative placement ownership — COMPLETE
- Introduce one generation-scoped pending placement record for full move,
world drop, and wield.
- Preserve canonical source ownership until the authoritative object update.
- Project source waiting/ghost and destination pending visuals separately.
- Converge success, refusal, timeout, disconnect, and late-response cleanup.
- Prove all borrowed observers see either pre-commit or committed state, never
a speculative canonical move.
This is the most important solidity work and should receive dual review because
it crosses Runtime ownership and retained presentation.
### Slice 3 — item-list mouse parity — COMPLETE
- Make carried-container press/open and double-click suppression explicit.
- Add staged vendor double-click removal.
- Add staged Selling drag-to-remove and split reset/refusal.
- Restore right-click select/examine consistently across vendor list roles.
### Slice 4 — shared legality and exact presentation — COMPLETE
- Unify hover/release placement decisions with reason codes.
- Add the owned-coinstack toolbar branch after capturing exact retail text.
- Reconcile hard-coded local item wording with DAT-backed retail strings.
### Automated verification — COMPLETE
- Focused inventory/external-container/paperdoll/vendor/selection/item-use
matrix: 328 passed, 0 failed.
- Cross-controller retained-UI interaction flow: 10 passed, 0 failed.
- Complete Release build: 0 warnings, 0 errors.
- Repository hermetic lane (the exact release filter, serial execution):
15,755 passed, 0 skipped, 0 failed across 14 test assemblies.
The repository wrapper's project-consistency preflight explicitly excludes the
tracked deployment-only ACE comparison mods under `tools/ace-mods/`. They
compile against a separately installed ACE server and intentionally remain
outside `AcDream.slnx`; the portable product graph still owns every other
project under `src/`, `tests/`, and `tools/`.
### Slice 5 — connected closure — DEFERRED OWNER GATE
Run the manual matrix below against ACE using an exact built binary and retain
logs/screenshots for failures. Close #445 and #449 only after their rows pass.
Keep #443 independent unless the evidence links paperdoll rendering to an
inventory acknowledgement.
## Connected manual matrix
Use one normal item, one wearable item, one wieldable item, two mergeable
stacks, one side pack, a full main backpack, a full side pack, an open chest,
a locked/closed container if available, a creature/player target, and a vendor
with normal and alternate currency.
1. Single-click each item/list type; verify selection border and exact status.
2. Right-click inventory, side-pack, external-container, paperdoll, browse,
Buying, and Selling rows; verify selection and examine.
3. Double-click ordinary usable, wearable, wieldable, and unusable items;
verify one request and correct SpewBox refusal where applicable.
4. Single- and double-click a carried side pack; verify one open action, no
redundant generic use, and stable selection.
5. Drag a full item between main pack and side pack; observe source/destination
before response, after success, and after forced rejection.
6. Fill a side pack, reject a move, free one slot, and retry immediately.
7. Fill the main pack with loose items while carrying side packs; verify item
and container capacities independently (#449).
8. Merge full and partial stacks; verify selected split amount, target
selection, source remainder, and full-target refusal text.
9. Split to an inventory container, external container, creature, ground, and
vendor; change selection while the request is pending.
10. Drop full and partial stacks to ground; verify ghost/pending behavior,
selected arriving object, rejection cleanup, and no duplicate item.
11. Pick up from ground into a nearly full destination, then retry after
freeing capacity.
12. Equip by double-click and by paperdoll drag; test clothing conflict and
weapon replacement. Verify source/paperdoll state before acknowledgement.
13. Drag full and partial stacks to vendor Selling; verify exact quantities,
temp-row replacement, totals, and #445 behavior.
14. Double-click staged Buying and Selling rows to remove them; verify SpewBox
text and state cleanup.
15. Drag a staged Selling row to remove it; repeat with a partial split selected
and verify refusal plus slider reset.
16. Complete/cancel transactions in normal and alternate currency; verify
currency balance refresh (#444) and selection/status stability.
17. Repeat representative actions while another inventory request is pending,
immediately after rejection, and immediately after reopening a container.
18. Log out/portal/re-enter with a pending or recently completed interaction;
verify the request ledger and pending projections converge to zero.
For every refused release/action, record whether the cursor was green/red,
whether a SpewBox line appeared, the exact line, and whether canonical item
ownership changed before the server response.
## Evidence index
Retail research already in the tree:
- `docs/research/deepdives/r06-items-inventory.md`
- `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`
- `docs/research/2026-07-13-retail-give-item-pseudocode.md`
- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md`
- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md`
- `docs/research/2026-08-08-slice6-vendor-transactions-research.md`
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
Primary current implementation surfaces:
- `src/AcDream.App/UI/UiRoot.cs`
- `src/AcDream.App/UI/UiItemSlot.cs`
- `src/AcDream.App/UI/ItemInteractionController.cs`
- `src/AcDream.App/UI/Layout/InventoryController.cs`
- `src/AcDream.App/UI/Layout/ExternalContainerController.cs`
- `src/AcDream.App/UI/Layout/PaperdollController.cs`
- `src/AcDream.App/UI/Layout/SelectedObjectController.cs`
- `src/AcDream.App/UI/Layout/VendorUiController.cs`
- `src/AcDream.App/UI/AutoWieldController.cs`
- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`
- `src/AcDream.App/Rendering/GameWindow.cs`
- `src/AcDream.Core/Items/ItemInteractionPolicy.cs`
- `src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs`
- `src/AcDream.Runtime/Gameplay/RuntimeActionState.cs`
## Closure statement
The retail-backed work order is implemented through Slice 4. The code now has
one ClientLocal feedback route, request-first authoritative placement,
list-role-specific retail mouse behavior, shared placement legality, complete
move/wield failure composition, exact local-policy literals, and the exact
owned-coinstack status format. Occupied-slot AutoWield now also follows retail's
move-confirm-retry transaction instead of emitting an invented refusal. The
complete hermetic automated lane is green. No connected acceptance is claimed
here; the combined owner gate remains the final closure step, and #443 remains
an independent private-viewport residency issue.

View file

@ -0,0 +1,115 @@
# Retail keyboard defaults and routing audit — 2026-08-26
## Verdict
The code gate for #446 now covers all 306 user-bindable rows in the installed
Sept-2013 EoR ActionMap. Each row has a distinct `InputAction`, appears enabled
in Configure Keyboard, persists through retail-compatible named `.keymap`
profiles, and reaches
a concrete subsystem consumer. The exact installed-DAT default chord set has
zero exceptions. The remaining gate is a connected visual/behavior pass and a
fresh-process persistence check.
The approved acdream extension is deliberately retained: mouse-wheel chase
zoom may pull back to 40 m. It does not change the retail keyboard defaults or
the keypad camera actions.
## Oracles
- `docs/research/named-retail/retail-default.keymap.txt` and installed
`client_portal.dat` ActionMap DID `0x26000000`: the 306 rows, default chords,
activation types, input contexts, and `ConflictingMaps` relationships.
- Installed MasterInputMaps `0x14000000` and `0x14000002`: non-bindable system
and mouse commands.
- `ClientUISystem::OnAction @0x00564B90`: Escape priority.
- `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800` and
`ControlNameMapper::LoadSemantics`: displayed keyboard/mouse names.
- `ACCmdInterp::InitializeEmoteInputActionHash @0x0058B510`: all 87 emote
action-to-motion mappings.
- `CPlayerSystem::SelectNext @0x0055F9A0`: selection-cycle filtering and
opened-corpse behavior.
## Exact ActionMap coverage
| Retail input map | Rows | Consumer |
|---|---:|---|
| Movement | 14 | Runtime movement owner, including four postures |
| Camera + alternate camera | 22 | Held camera input, presets, alternate-scope modifier, instant mouse look |
| Combat + melee + missile + magic | 32 | Runtime combat attack owner and spellcasting controller, including spell slots 112 |
| Emotes | 87 | Exact retail raw-motion table and Runtime `ExecuteMotion` |
| Item selection | 26 | Selection controller/query and canonical inventory interaction state |
| UI | 42 | Retained panels, screenshot, help/plugin result, logout, and selection commands |
| Chat + chat-entry toggle | 7 | Retained chat entry/reply/command routes |
| Quickslots | 28 | Toolbar use/select/create routes, including slots 1018 |
| Character settings | 48 | Exact `CharacterOptionId` bit toggle through Runtime |
| **Total** | **306** | **306 distinct live identities** |
The low MasterInputMap entries such as bare Escape and raw mouse event
commands are intentionally not Configure Keyboard rows in retail and are not
counted among the 306. Unknown rows from a future DAT can still round-trip in
the compatibility sibling store, but the installed EoR DAT has no such row and
shows no dimmed/store-only keyboard entry.
## Behavior completed
- Defaults are an exact installed-DAT transcription, including bare
`LeftShift`; device, modifier, activation, and scope all match.
- Primary and alternate camera maps remain distinct rebind targets even where
retail reuses an action id. The alternate modifier changes the active camera
scope without aliasing saved bindings.
- Same physical chord may fire each distinct retail action allowed by the
ActionMap. In particular, the authored Alt+1..4 chat/UI and quickslot rows
multicast instead of one silently replacing the other.
- Rebind conflicts use the DAT `ConflictingMaps` table. The shared melee,
missile, and magic key cluster remains legal; true conflicts still prompt.
- Capture accepts keyboard keys, modifier-only bindings, and mouse buttons.
Physical modifier self-bits are normalized, so binding LeftShift does not
accidentally become Shift+LeftShift. Mouse button names use retail's
`DIMOFS_BUTTON0..7` semantics table. Unsupported joystick and left/right
mouse inputs keep the instruction dialog open and re-arm capture.
- Setting the chord already present on the same row is a no-op. New chords use
retail's dense two-slot insertion rule, and conflicts use priority dialogs
with the exact installed-DAT singular/plural and non-bindable text.
- Apply/OK, Revert, Defaults, Cancel, explicit unbinding, schema migration,
and startup persistence are covered. Revert is enabled only while dirty;
OK avoids rewriting an unchanged file.
- Load File and Save As use retail's type-7 menu/type-5 text-entry dialogs,
PFile bracket-text grammar, filename normalization, overwrite/read-only
handling, `Documents\Asheron's Call\*.keymap` directory, selected-profile
preference, startup load, and graceful-shutdown rewrite. The portable JSON
file remains only as an acdream-host-command compatibility mirror.
- Escape follows retail's priority: finish jump charge, release focused UI,
stop movement/repeat attack, cancel target mode, clear selection, then
toggle the authored Gameplay Options page. It never exits player mode or
exposes the orbit/developer camera. Shift+Escape reaches the normal logout
gate.
- Selection cycling applies retail's containment, cloaking, radar, attackable,
fellow, vendor, environment, combat-mode, and opened-corpse rules.
Opened-corpse history lives for the session and retires on object deletion.
- Screenshot, help, and plugin actions are consumed. Missing separately
shipped retail help/plugin surfaces report an honest chat/system result
rather than doing nothing.
## Automated verification
- App: 6,413/6,413 passed.
- Core: 4,713/4,713 passed.
- Runtime: 1,849/1,849 passed.
- UI.Abstractions: 879/879 passed.
- Installed-DAT identity/default conformance and the authored Configure
Keyboard mount pin all 306 rows.
The `.keymap` codec parses the committed real retail file and round-trips all
306 user-bindable identities, including low-bit Shift/Ctrl/Alt/Win modifiers,
DirectInput controls, fixed Escape/system/edit/pointer maps, and the 48
CharacterOption action names. AP-202 is retired. The connected gate could not
be run because no local ACE endpoint was listening on UDP port 9000.
## Connected acceptance gate
Use the installed EoR DATs and the normal owner-gate pak. In Configure
Keyboard, verify that all rows are enabled and that a key, modifier-only chord,
and mouse button can each be rebound. Exercise representative movement,
camera, melee/missile/magic, emote, selection, panel, chat, quickslot, and
character-option actions. Verify conflict prompt, Cancel, Revert, Defaults,
Apply, and OK, then restart the process and confirm the applied bindings remain.