acdream/docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md
Erik 19f1b8b614 docs(D.5.3/B.2): toolbar shortcut drag interactivity design spec
From the user's visual-gate feedback + a retail decomp trace this session.
Retail's model is remove-on-lift / place-on-drop / no-restore (confirmed:
RecvNotice_ItemListBeginDrag 0x004bd930 → RemoveShortcut 0x004bd450 at lift;
HandleDropRelease 0x004be7c0 places + bumps displaced → source; off-bar drop
leaves it removed). This unifies the user's points 3/4/5 into one mechanism.

Verified the drop sprite against client_portal.dat: 0x060011F9 = green RING
(inventory), 0x060011FA = green CROSS (toolbar) — user was right.

Scope (toolbar-internal; drag-from-inventory is Stream C): spine extensions
(lift hook on IItemListDragHandler; ghost snapshotted at BeginDrag at full
opacity so it survives the source emptying; FinishDrag delivers a drop only on
a real hit, off-bar = nothing); a mutable 18-slot ShortcutStore; ToolbarController
as the live handler (OnDragLift removes; HandleDropRelease places + bumps); the
AddShortcut 0x019C / RemoveShortcut 0x019D wire (fix the BuildAddShortcut param
names; SendAddShortcut/SendRemoveShortcut on WorldSession); green-cross overlay.
Amends AP-47 (ghost now full opacity), retires TS-33 (real wire replaces the stub).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:57:31 +02:00

386 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# D.2b toolbar shortcut drag interactivity (Stream B.2) — design
**Date:** 2026-06-20
**Phase:** D.2b retail-UI → D.5 core panels. Stream **B.2** of the 2026-06-18 handoff — make the
toolbar shortcut drag *functional* (reorder / remove) and *retail-faithful*, on top of the B.1
spine (`9d48346..acdefc2`, shipped + visually confirmed this session).
**Branch:** `claude/hopeful-maxwell-214a12`.
**Driver:** user visual-gate feedback 2026-06-20 (6 points) + the retail research below.
**Read alongside:**
- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` §5 (HandleDropRelease/RemoveShortcut/wire).
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 (the cell drag chain).
- `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md` (B.1 — what this extends).
- `claude-memory/project_d2b_retail_ui.md`.
---
## 1. Goal & non-goals
**Goal.** A player drags a hotbar shortcut, sees the item icon lift off the slot (slot empties) and
follow the cursor at full opacity with a green-cross drop indicator; dropping on another slot moves
it there (reordering, bumping any occupant back to the source slot); dropping anywhere else removes
it from the bar. All changes are sent to the server (`AddShortcut 0x019C` / `RemoveShortcut 0x019D`)
so they persist. This realizes the user's 6 visual-gate points as retail's single **remove-on-lift /
place-on-drop / no-restore** mechanism.
**In scope:**
1. **Spine extensions** (small, to the B.1 toolkit): a drag-LIFT hook to the source's handler;
full-opacity ghost snapshotted at drag-begin (survives the source emptying); `FinishDrag`
delivers a drop only when released on a real element (off-bar = no drop fires).
2. **`ShortcutStore`** — a mutable 18-slot model (port of retail `ShortCutManager::shortCuts_[18]`).
3. **`ToolbarController` as the live drag handler** — `OnDragLift` removes; `HandleDropRelease`
places + bumps displaced → source; both drive the store, the wire, and a re-`Populate()`.
4. **The wire** — fix `BuildAddShortcut` param names; add `WorldSession.SendAddShortcut` /
`SendRemoveShortcut`.
5. **Green-cross accept overlay** (`0x060011FA`) on toolbar cells (verified against the dat).
**Out of scope (later streams):**
- **Drag FROM inventory onto the bar** (the `flags & 0xE == 0` "fresh-from-inventory" branch) — needs
the inventory window as a drag source (Stream C). This spec is reorder-within-bar + remove only
(the `flags & 4` branch).
- Spell shortcuts (the `SpellId|Layer` payload) — items only (`spellId=layer=0`); the store carries a
spell field for forward-compat but the toolbar binds item guids.
- Server-pushed shortcut mutations after login (the store is client-authoritative post-`Load`).
- The selected-object mana meter / stack-split UI (issue #141 deferred bits).
---
## 2. Retail grounding (the model — CONFIRMED by research 2026-06-20)
Retail's toolbar drag is **remove-on-lift, place-on-drop, no-restore** (decomp trace, this session):
- **On drag-begin** (`UIElement_ItemList::ItemList_BeginDrag` 0x004e32d0 → broadcasts msg `0x21`
`gmToolbarUI::RecvNotice_ItemListBeginDrag` 0x004bd930): `PrepareDragIcon` makes the **full icon**
the cursor ghost (m_dragIcon), then `gmToolbarUI::RemoveShortcut` (0x004bd450) **removes the
shortcut**: `ItemList_Flush` → cell → empty state `0x1000001c`, `Event_RemoveShortCut` sends
`0x019D`, and `m_lastShortcutNumDragged` = the source slot. The slot is now empty; the item is "in
hand."
- **On mouse-up over a slot** (`UIElementManager::StopDragandDrop` 0x00459810 → success →
`gmToolbarUI::HandleDropRelease` 0x004be7c0): within-bar reorder branch (`flags & 4`):
`RemoveShortcutInSlotNum(target)` (evict the occupant, get its objId) → `AddShortcut(draggedObjId,
target)` (`0x019C`); if an item was displaced and the source slot is free, `AddShortcut(displaced,
m_lastShortcutNumDragged)` — the bumped item lands in the vacated source slot. (deep-dive §5.)
- **On mouse-up over nothing** (`m_pElementLastDragCursorOver == null` → success=0): no
`HandleDropRelease`, no re-add — the shortcut stays gone (already removed + `0x019D` sent at lift).
- **No cancel/restore path.** `gmToolbarUI` has no end-drag/restore vtable entry. Lifted-then-not-
landed = permanently removed (server already told).
- **Drop indicator** is a per-slot OVERLAY sprite on `m_elem_Icon_DragAccept` (`0x1000045A`), NOT a
cursor change (`UpdateCursorState` is combat/target-only). The toolbar accept sprite is the **green
cross `0x060011FA`** (verified by exporting the `0x060011F7..FA` family from `client_portal.dat`:
F7=green move-arrow, F8=red ∅ reject, F9=green **ring** [inventory], FA=green **cross** [toolbar]);
reject = `0x060011F8`.
**Wire (`ShortCutData`, CONFIRMED 3 refs — deep-dive §131-145):**
- `AddShortCut 0x019C` body after the 12-byte GameAction envelope: `Index(u32), ObjectId(u32),
SpellId(u16), Layer(u16)` (item → `ObjectId=guid, SpellId=Layer=0`).
- `RemoveShortCut 0x019D` body: `Index(u32)`.
- ACE handles the remove-then-add reorder pattern (`Player_Character.cs:254`).
---
## 3. Architecture — components & boundaries
```
press+move on OCCUPIED slot ─► UiRoot.BeginDrag
│ snapshot ghost (full icon) BEFORE the lift clears the source cell
│ fire DragBegin on the source cell
UiItemSlot.OnEvent(DragBegin) ─► FindList().DragHandler.OnDragLift(list, cell, payload)
ToolbarController.OnDragLift ─► ShortcutStore.Remove(srcSlot); SendRemoveShortcut(srcSlot); Populate()
(source slot now empty; ghost still shows the snapshot)
drag-over target slot ─► UiItemSlot DragEnter ─► handler.OnDragOver → green-cross accept overlay (FA)
release ─► UiRoot.FinishDrag:
├─ over a slot ─► that cell.OnEvent(DropReleased) ─► handler.HandleDropRelease(target, payload)
│ ShortcutStore place + bump displaced→srcSlot; SendAddShortcut×N; Populate()
└─ over nothing ─► no DropReleased delivered → lift's removal stands (item gone)
```
**Boundaries:** `UiRoot`/`UiElement`/`UiItemSlot`/`UiItemList` stay item-agnostic — they gain a lift
*hook* and a ghost snapshot, nothing item-specific. `ShortcutStore` is pure logic in `AcDream.Core`
(testable, no GL). The wire lives in `AcDream.Core.Net`. `ToolbarController` (App) orchestrates.
Honors structure Rules 1/2/3/6.
---
## 4. New & changed types (precise)
### 4.1 Spine — `IItemListDragHandler` gains the lift hook (`UI/IItemListDragHandler.cs`)
```csharp
public interface IItemListDragHandler
{
/// <summary>The drag STARTED from a cell in this list — retail's
/// RecvNotice_ItemListBeginDrag → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler
/// removes the lifted item from its model + wire, so the source slot empties immediately. The
/// item is "in hand" until HandleDropRelease (place) or the drag ends off-target (stays removed).</summary>
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
}
```
### 4.2 Spine — `UiItemSlot` (`UI/UiItemSlot.cs`)
- `OnEvent` `DragBegin` case changes from `return true` (no-op) to: notify the list's handler so it
can lift:
```csharp
case UiEventType.DragBegin:
if (FindList() is { DragHandler: { } h } list && e.Payload is ItemDragPayload p)
h.OnDragLift(list, this, p);
return true;
```
- `DropReleased` case: drop the `e.Data0 == 1` gate — reaching the cell means a real slot was hit
(`FinishDrag` only delivers on a hit). The handler is authoritative (place; drop-on-self re-adds):
```csharp
case UiEventType.DropReleased:
_dragAccept = DragAcceptState.None;
if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp)
dh.HandleDropRelease(dl, this, dp);
return true;
```
(`DragEnter`/`DragOver` overlay handling unchanged from B.1.)
### 4.3 Spine — `UiRoot` (`UI/UiRoot.cs`)
- Add `private (uint tex, int w, int h)? _dragGhost;`. In `BeginDrag`, **snapshot the ghost before
firing `DragBegin`** (the lift will empty the source cell, so a live re-read would vanish):
```csharp
private void BeginDrag(UiElement source)
{
var payload = source.GetDragPayload();
if (payload is null) { _dragCandidate = false; return; }
DragSource = source;
DragPayload = payload;
_dragGhost = source.GetDragGhost(); // snapshot NOW — survives the source emptying
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
source.OnEvent(in e); // → cell → handler.OnDragLift (clears the source slot)
}
```
- `DrawDragGhost` uses the snapshot, not a live read; `GhostAlpha` → **1.0** (full opacity, retail
m_dragIcon):
```csharp
private const float GhostAlpha = 1.0f;
private void DrawDragGhost(UiRenderContext ctx)
{
if (_dragGhost is not { } g || g.tex == 0) return;
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
}
```
- `FinishDrag` delivers a drop ONLY to a real hit element; off-bar (null hit) fires nothing; clear
the ghost:
```csharp
private void FinishDrag(int x, int y)
{
var (t, lx, ly) = HitTestTopDown(x, y);
if (t is not null)
{
var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
t.OnEvent(in e); // the hit cell's handler places; a non-item target ignores it → item stays removed
}
// else: dropped off any element — no drop fires; the lift's removal stands (retail).
DragSource = null; DragPayload = null; _dragGhost = null; _lastDragHoverTarget = null;
}
```
(`GetDragGhost()` on `UiElement`/`UiItemSlot` stays as-is — `BeginDrag` calls it once.)
### 4.4 NEW — `ShortcutStore` (`src/AcDream.Core/Items/ShortcutStore.cs`)
Port of retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`). Pure logic.
```csharp
public sealed class ShortcutStore
{
public const int SlotCount = 18;
private readonly uint[] _objIds = new uint[SlotCount]; // 0 = empty
/// <summary>Replace all slots from the login PlayerDescription shortcut list.</summary>
public void Load(IReadOnlyList<PlayerDescriptionParser.ShortcutEntry> entries)
{
System.Array.Clear(_objIds);
foreach (var e in entries)
if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[e.Index] = e.ObjectGuid;
}
public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
public bool IsEmpty(int slot) => Get(slot) == 0u;
public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; }
public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; }
}
```
### 4.5 Wire — `InventoryActions.BuildAddShortcut` rename (`Core.Net/Messages/InventoryActions.cs`)
Byte layout is already correct; FIX the misleading param names (handoff §B.2; deep-dive §131-145):
```csharp
/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32),ObjectId(u32),
/// SpellId(u16),Layer(u16). For an item: objectGuid + spellId=layer=0.</summary>
public static byte[] BuildAddShortcut(uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); // 0x019C
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
return body;
}
```
(`BuildRemoveShortcut(seq, slotIndex)` is correct — unchanged.) Any existing caller of the old
4-arg `BuildAddShortcut` must be updated; grep first (likely none wired today).
### 4.6 Wire — `WorldSession` sends (`Core.Net/WorldSession.cs`, mirror `SendChangeCombatMode` :1134)
```csharp
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
}
public void SendRemoveShortcut(uint index)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
}
```
### 4.7 `ToolbarController` — the live handler (`UI/Layout/ToolbarController.cs`)
- Holds a `ShortcutStore _store`. `Populate()` reads `_store` (18 slots → `repo.Get(objId)` → icon)
INSTEAD of the read-only `_shortcuts()` provider. **Lazy-load-once:** the PlayerDescription shortcut
list arrives AFTER Bind (at login), so load the store the first time `_shortcuts()` is non-empty,
then treat it as authoritative:
```csharp
// top of Populate(), before rendering:
if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; }
```
Bind's initial `Populate` (pre-login) sees an empty list and skips the load; the existing
`repo.ObjectAdded → Populate` path (shortcut items arriving via `CreateObject`, by which time the PD
shortcut list is set) triggers the one-time load. After load, drag ops mutate `_store` directly and
it is never reloaded within the session (a relog = a fresh process → `_storeLoaded` resets). No
GameWindow change is needed for loading.
- `IsShortcutGuid(guid)` (the repo-event gate) checks `_store` (any slot holds `guid`) rather than
`_shortcuts()`, so an item dragged ONTO the bar (a new guid) still gets its later updates rendered.
- Each toolbar cell: `DragAcceptSprite = 0x060011FAu` (green cross). (`DragRejectSprite` stays
`0x060011F8`.)
- Inject two `Action`s for the wire (so the controller stays testable without a live session):
`Action<uint,uint> sendAdd` = `(index, objId) => session.SendAddShortcut(index, objId)`,
`Action<uint> sendRemove` = `index => session.SendRemoveShortcut(index)`. Null in tests.
- `OnDragLift(sourceList, sourceCell, payload)` — retail `RemoveShortcut`:
```csharp
_store.Remove(payload.SourceSlot);
_sendRemove?.Invoke((uint)payload.SourceSlot);
Populate();
```
- `HandleDropRelease(targetList, targetCell, payload)` — retail reorder branch (deep-dive §5):
```csharp
int target = targetCell.SlotIndex;
uint evicted = _store.Get(target); // RemoveShortcutInSlotNum
if (evicted != 0) { _store.Remove(target); _sendRemove?.Invoke((uint)target); }
_store.Set(target, payload.ObjId); _sendAdd?.Invoke((uint)target, payload.ObjId); // AddShortcut
if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
{ // bump displaced → vacated source slot
_store.Set(payload.SourceSlot, evicted);
_sendAdd?.Invoke((uint)payload.SourceSlot, evicted);
}
Populate();
```
- `OnDragOver` unchanged from B.1 (accept any real item → green-cross overlay).
---
## 5. Data flow — frame by frame (reorder slot 3 → occupied slot 5)
1. Press+move on slot 3 (objId A). `BeginDrag`: snapshot ghost = A's icon; fire `DragBegin`.
2. Cell 3 → `OnDragLift`: `_store.Remove(3)` + `SendRemoveShortcut(3)` (0x019D) + `Populate()` →
slot 3 shows empty. Ghost (A's full icon) follows the cursor.
3. Drag over slot 5 (objId B) → green-cross accept overlay on slot 5.
4. Release on slot 5 → `FinishDrag` delivers `DropReleased` to cell 5 → `HandleDropRelease`:
`evicted=B`; `Remove(5)`+`SendRemoveShortcut(5)`; `Set(5,A)`+`SendAddShortcut(5,A)`; source slot 3
is empty → `Set(3,B)`+`SendAddShortcut(3,B)`; `Populate()` → slot 5 = A, slot 3 = B (swapped).
5. (Off-bar variant) release over the 3D world → no `DropReleased` → A stays removed → slot 3 empty,
A gone from the bar (server already told at step 2).
---
## 6. Edge cases
- **Drop on self (slot 3 → slot 3):** at lift slot 3 emptied; at drop `evicted=0`, `Set(3,A)` +
`SendAddShortcut(3,A)` → A back at 3. Net no-op (2 wire msgs — retail does the same).
- **Drop on a non-item element** (chrome / another window): `FinishDrag` delivers `DropReleased` to
it; its `OnEvent` doesn't handle it → no place → A stays removed (off-bar). Correct.
- **Ghost lifetime:** snapshotted at `BeginDrag`, cleared in `FinishDrag` — independent of the source
cell emptying. A cancelled drag (mouse-up off-bar) still clears `_dragGhost`.
- **Empty-slot lift:** an empty slot is not a drag source (`IsDragSource => ItemId != 0` from B.1) →
no drag arms → no lift. (And empty slots still move the window — IA-12, from B.1.)
- **Spell shortcuts in the loaded list** (`ObjectGuid == 0`, a spell): `ShortcutStore.Load` skips
them (item-only this stream); they neither render nor drag. Forward-compat: the store could hold
spell entries later.
---
## 7. Divergence register (update in the implementation commits)
- **AP-47 (amend):** the ghost is now **full opacity** (retail m_dragIcon) — retire the
`GhostAlpha=0.6` reduced-alpha approximation. The row's REMAINING approximation: the ghost reuses
the full composited `m_pIcon` (which includes the type-default underlay) rather than retail's
dedicated underlay-less `m_pDragIcon`. Reword AP-47 to drop the alpha claim, keep the underlay note.
- **TS-33 (retire):** the toolbar `HandleDropRelease` logging stub is replaced by the real
store-mutation + `AddShortcut`/`RemoveShortcut` wire. Delete the row.
- **No new row** for remove-on-lift / off-bar-remove — it is the faithful retail mechanism (it
*reduces* divergence). The store being client-authoritative post-login matches retail's
`ShortCutManager` (client owns the array; server is notified via the wire).
---
## 8. Testing (conformance)
Core tests (`tests/AcDream.Core.Tests/`):
1. `ShortcutStore`: `Load` maps Index→ObjId (skips ObjectGuid==0 + out-of-range); `Set`/`Remove`/
`Get`/`IsEmpty`; bounds-safe.
2. `BuildAddShortcut`: 24-byte body, envelope+seq+`0x019C`+index+objectGuid+spellId(u16)+layer(u16) at
the right offsets (golden bytes). `BuildRemoveShortcut`: 16 bytes, index at +12.
App tests (`tests/AcDream.App.Tests/`):
3. Spine: `BeginDrag` snapshots the ghost so it survives the source cell being cleared mid-drag (clear
the source cell after begin; assert the drawn ghost source is still the snapshot — assert via a
testable `UiRoot` accessor for `_dragGhost`).
4. Spine: `FinishDrag` over null delivers NO `DropReleased` (a spy cell/handler isn't called); over a
real cell, delivers it (drop the Data0 gate — drop-on-self now dispatches).
5. `ToolbarController.OnDragLift`: removes the source slot from the store, invokes `sendRemove(src)`,
and the source cell empties after `Populate`.
6. `ToolbarController.HandleDropRelease` reorder onto an OCCUPIED target: store ends with dragged@target
+ displaced@source; `sendRemove`+`sendAdd` called with the retail sequence/args. Onto an EMPTY
target: dragged@target, source stays empty, displaced-branch not taken. Drop-on-self: re-adds to
source.
7. Update the B.1 tests changed by §4.2/§4.3: `DropReleased_notAccepted_skipsDispatch` →
`DropReleased_alwaysDispatchesToHandler` (the cell dispatches whenever it receives a DropReleased);
the full-chain drag tests still pass (a completed reorder fires `HandleDropRelease`, not `Clicked`).
---
## 9. Acceptance criteria
- [ ] `dotnet build` + `dotnet test` green (Core + App).
- [ ] AP-47 reworded (no alpha); TS-33 deleted; wire builder renamed; no stray old-signature callers.
- [ ] `UiRoot`/`UiElement`/`UiItemSlot` remain item-agnostic (the lift hook is generic; only
`ToolbarController` knows shortcuts).
- [ ] **Visual (user), `ACDREAM_RETAIL_UI=1`, in-world:** lift a hotbar item → **slot empties
immediately**, **full-opacity** icon follows the cursor with a **green cross** over hovered
slots; drop on another slot → it **moves there** (occupant bumps to the source slot); drop
off-bar → it's **removed**; a single click still **uses** the item. Changes **persist after
relog** (the wire reached ACE).
- [ ] Memory crib + roadmap/ISSUES updated; #141's note refreshed if relevant.
---
## 10. Subagent task slices
1. **Core: `ShortcutStore` + wire** — `ShortcutStore.cs` + `BuildAddShortcut` rename +
`WorldSession.SendAddShortcut`/`SendRemoveShortcut` + Core tests (§8.1-2).
2. **Spine extensions** — `IItemListDragHandler.OnDragLift`; `UiItemSlot` DragBegin→lift + DropReleased
ungate; `UiRoot` ghost snapshot + full opacity + `FinishDrag` deliver-on-hit-only; update the B.1
tests (§8.3-4, §8.7).
3. **ToolbarController handler** — `_store` + `Load` + `Populate`-from-store; green-cross sprite;
`OnDragLift`/`HandleDropRelease`; wire `Action`s; GameWindow injects the session sends; controller
tests (§8.5-6). Update AP-47, delete TS-33.
(1 and 2 are independent; 3 depends on both.)