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)