diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 33bf3a01..c5efe336 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -104,11 +104,11 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 97 active rows
+## 3. Documented approximation (AP) — 96 active rows
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
-AP-94..AP-110 for the confirmed retail-UI completion gaps.
+AP-94..AP-112 for the confirmed retail-UI completion gaps.
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -132,7 +132,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
| AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1−SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive → D3DBLEND_ONE per-surface routing |
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
-| AP-24 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:176` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) |
+| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
@@ -198,7 +198,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
| AP-93 | Paperdoll does not port `UpdateForRace`; all characters use the cdb-confirmed default held pose `0x030003C0` and default presentation | `src/AcDream.App/Rendering/DollEntityBuilder.cs`; paperdoll mount wiring | Exact for the tested Horan character; unresolved for other heritage/gender/body combinations | Non-default races can use the wrong pose, heading, camera framing, or presentation asset | `gmPaperDollUI::UpdateForRace @ 0x004A3ED0` |
| AP-94 | Imported Type-12 text defaults to interactive/selectable behavior; display labels can focus/capture/drag, and vitals synthesize duplicate runtime labels instead of binding `0x100000EB/ED/EF` | `src/AcDream.App/UI/UiText.cs`; `src/AcDream.App/UI/Layout/VitalsController.cs` | Historical widget-generalization default; Wave 1 ports explicit Display/Selectable/Editable roles | Invisible/static text steals input and duplicate labels drift from DAT geometry | `UIElement_Text` property handlers; `gmVitalsUI::PostInit @ 0x004BFCE0` |
-| AP-95 | `UiButton` dispatches Click only; it lacks retail hover, pressed, released-outside, disabled, selected/toggle, and missing-state fallback transitions | `src/AcDream.App/UI/UiButton.cs` | Controllers manually set a few semantic states; generic pointer visuals remain incomplete | Buttons look inert or overwrite custom states, and disabled/toggle behavior can fire incorrectly | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` |
+| ~~AP-95~~ | **RETIRED 2026-07-11** — `UiButton` owns retail hover/pressed/released-outside, disabled, selected/toggle, missing-state fallback, hot-click, and distinct press/release controller callbacks. | `src/AcDream.App/UI/UiButton.cs`; `UiButtonStateMachine.cs` | — | — | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` |
| AP-96 | Layout/property import loses raw edge-mode semantics (especially 3/4) and treats default scalar values as absent, so explicit `false`/`0` cannot override inheritance | `src/AcDream.App/UI/Layout/ElementReader.cs`; `LayoutImporter.cs` | Existing shipped layouts are hand-gated; Wave 1 adds presence-aware properties and the exact solver | Non-reference resizing recenters/stretches incorrectly; derived DAT properties silently inherit the wrong base value | `UIElement::UpdateForParentSizeChange @ 0x00462640`; production LayoutDesc inheritance |
| ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` |
| ~~AP-98~~ | **RETIRED 2026-07-10 (Wave 2.3)** — typed handles/manager own lifecycle; every production/Studio window uses `RetailWindowFrame`; schema-v2 `UiWindowLayout` persists outer bounds, visibility, toolbar collapse, and chat maximize by character/resolution with legacy-radar migration, nearest-resolution fallback, clamping, and a pre-login `default` write guard. Chat maximize uses DAT 100–360 limits and preserves its lower edge when growing upward. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowLayoutPersistence.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs`; `SettingsStore.cs` | — | — | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail `saveui/loadui` behavior |
@@ -210,8 +210,9 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
| AP-108 | Paperdoll omits Aetheria, full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, and body-part selection lighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, optimistic wield, exact DAT body hit map, authoritative upper-item priority, normal selection, and target-self dispatch work | Special slots, invalid drops, doll examine/drag, and selection lighting still differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` |
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
-| AP-110 | Remaining retained gameplay panels and world HUD are absent: combat/powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Existing panels cover the current M2 loop; Wave 10 lands each missing surface against authoritative state | Large portions of retail gameplay have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog |
+| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Basic `gmCombatUI` now covers the active M2 melee/missile loop; Wave 10 lands each remaining surface against authoritative state | Large portions of retail gameplay still have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog |
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
+| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, server-response queueing, and auto-repeat, but omits `StartAttackRequest`'s `FinishJump`/`MaybeStopCompletely` command-interpreter calls and the trained-Recklessness submeter | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The M2 attack contract and authored basic panel are live; the two omitted seams require the jump-command owner and skill-advancement state rather than UI-local guesses | Starting an attack while charging a jump or deliberately moving may not stop/cancel exactly when retail does; trained Recklessness lacks its green range overlay | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; `RecvNotice_SetCombatMode @ 0x004CC620` |
## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 61847577..184f8d8b 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -497,6 +497,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
+- **M2 basic retail combat bar implemented 2026-07-11; live visual gate pending.** Production now mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, binds authored high/medium/low buttons and desired-power slider, and routes mouse plus keyboard through one `CombatAttackController`. Matching retail x86 recovered exact 1.0-second normal and 0.8-second dual-wield power-up times; the same constants retire the guessed jump charge. Keybinding schema v3 migrates attack actions from one-shot Press to transition-emitting Hold. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`; AP-24/AP-95 retired, AP-110 narrowed, AP-112 records the remaining advanced/Recklessness and command-interpreter seams.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 5f6a5022..fffc0759 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -458,6 +458,11 @@ include dungeons.
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
funnel and action-stamp gate, so ACE's server-selected melee/missile action
drives the player's motion table; direct command-list replay is removed.
+- **M2 basic combat bar (implemented 2026-07-11; live gate pending)** — authored
+ `gmCombatUI` LayoutDesc `0x21000073` now pops for Melee/Missile and shares one
+ press/hold/release attack-request owner across DAT buttons and keybindings.
+ Retail x86 resolves full charge to 1.0 s (0.8 s dual wield), also retiring the
+ guessed jump-charge timing. See `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`.
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
**Freeze on landing:**
diff --git a/docs/research/2026-07-11-retail-combat-bar-pseudocode.md b/docs/research/2026-07-11-retail-combat-bar-pseudocode.md
new file mode 100644
index 00000000..f490ee30
--- /dev/null
+++ b/docs/research/2026-07-11-retail-combat-bar-pseudocode.md
@@ -0,0 +1,135 @@
+# Retail basic combat bar — port pseudocode
+
+## Sources
+
+- Named retail decomp:
+ - `gmCombatUI::RecvNotice_AttackHeightChanged` `0x004CC080`
+ - `gmCombatUI::RecvNotice_SetPowerbarLevel` `0x004CC0E0`
+ - `gmCombatUI::RecvNotice_DesiredAttackPowerChanged` `0x004CC110`
+ - `gmCombatUI::ListenToElementMessage` `0x004CC430`
+ - `gmCombatUI::RecvNotice_SetCombatMode` `0x004CC620`
+ - `ClientCombatSystem::Begin` `0x0056A460`
+ - `ClientCombatSystem::StartPowerBarBuild` `0x0056ADB0`
+ - `ClientCombatSystem::GetPowerBarLevel` `0x0056ADE0`
+ - `ClientCombatSystem::StartAttackRequest` `0x0056C040`
+ - `ClientCombatSystem::EndAttackRequest` `0x0056C0E0`
+ - `ClientCombatSystem::UseTime` `0x0056C1F0`
+ - `ClientCombatSystem::HandleAttackDoneEvent` `0x0056C500`
+ - `ClientCombatSystem::SetRequestedAttackHeight` `0x0056C8F0`
+- Matching retail executable disassembly (`acclient.exe` v11.4186 + PDB):
+ `ATTACK_POWERUP_TIME = 1.0` seconds and
+ `DUAL_WIELD_POWERUP_TIME = 0.8` seconds. The named pseudo-C's x87
+ expression had incorrectly collapsed the division operands.
+- LayoutDesc `0x21000073` (`gmCombatUI`), cross-checked against the retail UI
+ layout dump. Root is 610×90; basic page is `0x1000005C`, desired-power
+ scrollbar is `0x1000004F`, embedded meter is `0x10000050`, and the
+ high/medium/low buttons are `0x10000057/58/59`.
+- Interpretation cross-checks: ACE combat request/power fields and the
+ holtburger `DesiredAttackProfile -> attack_request` wire path summarized in
+ `docs/research/deepdives/r02-combat-system.md`.
+
+## Initial state
+
+```text
+combatMode = NonCombat
+buildInProgress = false
+attackRequestInProgress = false
+attackServerResponsePending = false
+requestedAttackPower = 0
+requestedAttackHeight = Medium
+desiredUiPower = 0.5
+repeatAttacking = false
+```
+
+## Panel visibility
+
+```text
+when combat mode changes:
+ if AdvancedCombatUI:
+ hide gmCombatUI
+ else if mode == Melee:
+ set root state Melee (0x10000003)
+ show gmCombatUI
+ else if mode == Missile:
+ set root state Missile (0x10000004)
+ show gmCombatUI
+ else:
+ hide gmCombatUI
+```
+
+The current acdream port binds the basic page because the persisted gameplay
+default has `AdvancedCombatUI = false`. The separate advanced-combat surface is
+still tracked by divergence row AP-110.
+
+## Press / charge / release
+
+```text
+SetRequestedAttackHeight(height):
+ changed = requestedAttackHeight != height
+ requestedAttackHeight = height
+ if changed OR no attack request is active:
+ StartAttackRequest()
+
+StartAttackRequest():
+ target = current attack target
+ if target is not attackable: report no target and stop
+ attackRequestInProgress = true
+ requestedAttackPower = 1
+ stop jump and movement
+ currentBuildIsAutomatic = false
+ if player is ready and no build is active:
+ StartPowerBarBuild()
+
+StartPowerBarBuild():
+ buildInProgress = true
+ buildStartTime = currentTime
+ displayedPower = 0
+
+GetPowerBarLevel():
+ if no build: return 0
+ duration = current stance is DualWieldCombat ? 0.8 seconds : 1.0 second
+ return clamp((currentTime - buildStartTime) / duration, 0, 1)
+
+EndAttackRequest(height, explicitPower = -1):
+ if no attack request: stop
+ attackRequestInProgress = false
+ current = GetPowerBarLevel()
+ requestedAttackPower = explicitPower >= 0
+ ? explicitPower
+ : min(desiredUiPower, current)
+ if a prior attack is awaiting the server:
+ queue this power for AttackDone
+ else if desiredUiPower <= current OR repeat attack is active:
+ ExecuteAttack(height)
+ otherwise:
+ UseTime commits on its next eligible tick at requestedAttackPower
+
+UseTime():
+ if a build is active:
+ current = GetPowerBarLevel()
+ publish current to meter
+ if request was released AND current >= requestedAttackPower:
+ publish min(requestedAttackPower, current)
+ if build is not automatic: ExecuteAttack(requestedHeight)
+ else stop automatic build
+```
+
+## UI messages
+
+```text
+mouse-down high/medium/low:
+ SetRequestedAttackHeight(High/Medium/Low)
+
+mouse-up high/medium/low:
+ EndAttackRequest(the height, -1)
+
+desired-power slider changed with integer position 0..1000:
+ desiredUiPower = clamp(position * 0.001, 0, 1)
+
+attack power increase/decrease action:
+ move desiredUiPower by one sixth, clamped to 0..1
+```
+
+Both keyboard and retained buttons therefore use one transition-based attack
+controller. A one-shot click/keypress is not sufficient: down begins charging,
+up ends the request.
diff --git a/src/AcDream.App/Combat/CombatAttackController.cs b/src/AcDream.App/Combat/CombatAttackController.cs
new file mode 100644
index 00000000..eeb382d2
--- /dev/null
+++ b/src/AcDream.App/Combat/CombatAttackController.cs
@@ -0,0 +1,357 @@
+using System.Diagnostics;
+using AcDream.Core.Combat;
+using AcDream.UI.Abstractions.Input;
+
+namespace AcDream.App.Combat;
+
+///
+/// Owns retail's basic-combat attack request and power-bar state machine.
+/// UI buttons and keyboard actions both enter through
+/// and ; the wire send remains a host callback.
+///
+///
+/// Retail references: ClientCombatSystem::Begin (0x0056A460),
+/// StartPowerBarBuild (0x0056ADB0), GetPowerBarLevel
+/// (0x0056ADE0), StartAttackRequest (0x0056C040),
+/// EndAttackRequest (0x0056C0E0), UseTime (0x0056C1F0),
+/// HandleAttackDoneEvent (0x0056C500), and
+/// SetRequestedAttackHeight (0x0056C8F0).
+///
+public sealed class CombatAttackController : IDisposable
+{
+ public const double AttackPowerUpSeconds = CombatInputPlanner.AttackPowerUpSeconds;
+ public const double DualWieldPowerUpSeconds = CombatInputPlanner.DualWieldPowerUpSeconds;
+ public const float InitialDesiredPower = 0.5f;
+ public const float DesiredPowerStep = 1f / 6f;
+
+ private readonly CombatState _combat;
+ private readonly Func _canStartAttack;
+ private readonly Func _sendAttack;
+ private readonly Func _isDualWield;
+ private readonly Func _playerReadyForAttack;
+ private readonly Func _autoRepeatAttack;
+ private readonly Func _now;
+
+ private bool _buildInProgress;
+ private double _buildStartTime;
+ private bool _attackRequestInProgress;
+ private bool _attackServerResponsePending;
+ private bool _attackWhenResponseReceived;
+ private float _attackWhenResponseReceivedPower;
+ private bool _currentBuildIsAutomatic;
+ private bool _repeatAttacking;
+ private float _requestedAttackPower;
+ private float _latestPowerBarLevel;
+ private bool _disposed;
+
+ public CombatAttackController(
+ CombatState combat,
+ Func canStartAttack,
+ Func sendAttack,
+ Func? isDualWield = null,
+ Func? playerReadyForAttack = null,
+ Func? autoRepeatAttack = null,
+ Func? now = null)
+ {
+ _combat = combat ?? throw new ArgumentNullException(nameof(combat));
+ _canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack));
+ _sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
+ _isDualWield = isDualWield ?? (() => false);
+ _playerReadyForAttack = playerReadyForAttack ?? (() => true);
+ _autoRepeatAttack = autoRepeatAttack ?? (() => false);
+ _now = now ?? (() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency);
+
+ _combat.CombatModeChanged += OnCombatModeChanged;
+ _combat.AttackCommenced += OnAttackCommenced;
+ _combat.AttackDone += OnAttackDone;
+ }
+
+ public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium;
+ public float DesiredPower { get; private set; } = InitialDesiredPower;
+ public bool AttackRequestInProgress => _attackRequestInProgress;
+ public bool BuildInProgress => _buildInProgress;
+
+ /// The level retail publishes to the embedded combat meter.
+ public float PowerBarLevel => _buildInProgress
+ ? GetPowerBarLevel()
+ : _latestPowerBarLevel;
+
+ public event Action? StateChanged;
+
+ public bool HandleInputAction(InputAction action, ActivationType activation)
+ {
+ AttackHeight? height = action switch
+ {
+ InputAction.CombatLowAttack => AttackHeight.Low,
+ InputAction.CombatMediumAttack => AttackHeight.Medium,
+ InputAction.CombatHighAttack => AttackHeight.High,
+ _ => null,
+ };
+
+ if (height is not null)
+ {
+ if (activation == ActivationType.Press)
+ PressAttack(height.Value);
+ else if (activation == ActivationType.Release)
+ ReleaseAttack();
+ return true;
+ }
+
+ if (activation != ActivationType.Press)
+ return false;
+
+ if (action == InputAction.CombatDecreaseAttackPower)
+ {
+ StepDesiredPower(-1);
+ return true;
+ }
+ if (action == InputAction.CombatIncreaseAttackPower)
+ {
+ StepDesiredPower(1);
+ return true;
+ }
+ return false;
+ }
+
+ public void SetDesiredPower(float power)
+ {
+ float value = Math.Clamp(power, 0f, 1f);
+ if (Math.Abs(DesiredPower - value) < float.Epsilon)
+ return;
+ DesiredPower = value;
+ StateChanged?.Invoke();
+ }
+
+ public void PressAttack(AttackHeight height)
+ {
+ bool heightChanged = RequestedHeight != height;
+ RequestedHeight = height;
+ if (heightChanged || !_attackRequestInProgress)
+ StartAttackRequest();
+ StateChanged?.Invoke();
+ }
+
+ public void ReleaseAttack()
+ {
+ if (!_attackRequestInProgress)
+ return;
+
+ _attackRequestInProgress = false;
+ float currentPower = GetPowerBarLevel();
+ _requestedAttackPower = Math.Min(DesiredPower, currentPower);
+
+ if (_attackServerResponsePending)
+ {
+ _attackWhenResponseReceived = true;
+ _attackWhenResponseReceivedPower = _requestedAttackPower;
+ }
+ else if (DesiredPower <= currentPower || _repeatAttacking)
+ {
+ ExecuteAttack(RequestedHeight, setServerPending: true);
+ }
+
+ StateChanged?.Invoke();
+ }
+
+ ///
+ /// Retail UseTime: publishes the live build and commits a released
+ /// request once the ticking level reaches the power captured on release.
+ ///
+ public void Tick()
+ {
+ if (_attackRequestInProgress
+ && !_buildInProgress
+ && !_attackServerResponsePending)
+ AttemptStartBuildingAttack();
+
+ if (!_buildInProgress)
+ return;
+
+ if (!_playerReadyForAttack())
+ {
+ if (_attackRequestInProgress)
+ {
+ StopBuild();
+ _latestPowerBarLevel = 0f;
+ }
+ else
+ {
+ _repeatAttacking = false;
+ ResetPowerBar();
+ }
+ StateChanged?.Invoke();
+ return;
+ }
+
+ float currentPower = GetPowerBarLevel();
+ _latestPowerBarLevel = currentPower;
+ if (!_attackRequestInProgress
+ && currentPower >= _requestedAttackPower)
+ {
+ _latestPowerBarLevel = Math.Min(_requestedAttackPower, currentPower);
+ if (!_currentBuildIsAutomatic)
+ ExecuteAttack(RequestedHeight, setServerPending: true);
+ else
+ StopBuild();
+ }
+ StateChanged?.Invoke();
+ }
+
+ private void StepDesiredPower(int direction)
+ {
+ int sixth = (int)MathF.Round(DesiredPower / DesiredPowerStep);
+ SetDesiredPower((sixth + Math.Sign(direction)) * DesiredPowerStep);
+ }
+
+ private void StartAttackRequest()
+ {
+ if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)
+ || !_canStartAttack())
+ return;
+
+ _attackRequestInProgress = true;
+ _requestedAttackPower = 1f;
+ _currentBuildIsAutomatic = false;
+ AttemptStartBuildingAttack();
+ }
+
+ private void AttemptStartBuildingAttack()
+ {
+ if (_buildInProgress
+ || _attackServerResponsePending
+ || !_playerReadyForAttack())
+ return;
+ StartPowerBarBuild();
+ }
+
+ private void StartPowerBarBuild()
+ {
+ _buildInProgress = true;
+ _buildStartTime = _now();
+ _latestPowerBarLevel = 0f;
+ }
+
+ private float GetPowerBarLevel()
+ {
+ if (!_buildInProgress)
+ return 0f;
+ double duration = _isDualWield()
+ ? DualWieldPowerUpSeconds
+ : AttackPowerUpSeconds;
+ return (float)Math.Clamp((_now() - _buildStartTime) / duration, 0d, 1d);
+ }
+
+ private void ExecuteAttack(AttackHeight height, bool setServerPending)
+ {
+ StopBuild();
+ if (!_sendAttack(height, Math.Clamp(_requestedAttackPower, 0f, 1f)))
+ {
+ ResetPowerBar();
+ return;
+ }
+
+ if (_autoRepeatAttack())
+ _repeatAttacking = true;
+ _attackServerResponsePending = setServerPending;
+ }
+
+ private void OnAttackCommenced()
+ {
+ _attackServerResponsePending = true;
+ if (!_attackRequestInProgress)
+ {
+ _latestPowerBarLevel = _requestedAttackPower;
+ StopBuild(preserveLevel: true);
+ }
+ StateChanged?.Invoke();
+ }
+
+ private void OnAttackDone(uint _, uint weenieError)
+ {
+ _attackServerResponsePending = false;
+ if (weenieError != 0)
+ _repeatAttacking = false;
+
+ if (!_attackRequestInProgress
+ && _autoRepeatAttack()
+ && _repeatAttacking)
+ {
+ if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f)
+ _requestedAttackPower = DesiredPower;
+ ExecuteAttack(RequestedHeight, setServerPending: false);
+ }
+
+ if (!_autoRepeatAttack() || !_repeatAttacking)
+ {
+ _repeatAttacking = false;
+ ResetPowerBar();
+ }
+ else if (_attackRequestInProgress)
+ {
+ AttemptStartBuildingAttack();
+ }
+ else
+ {
+ StartPowerBarBuild();
+ _currentBuildIsAutomatic = true;
+ }
+
+ if (_attackWhenResponseReceived)
+ {
+ float queuedPower = _attackWhenResponseReceivedPower;
+ _attackWhenResponseReceived = false;
+ _attackWhenResponseReceivedPower = 0f;
+ StartAttackRequest();
+ if (_attackRequestInProgress)
+ {
+ _requestedAttackPower = queuedPower;
+ _attackRequestInProgress = false;
+ ExecuteAttack(RequestedHeight, setServerPending: true);
+ }
+ }
+
+ StateChanged?.Invoke();
+ }
+
+ private void OnCombatModeChanged(CombatMode mode)
+ {
+ if (!CombatInputPlanner.SupportsTargetedAttack(mode))
+ Reset();
+ StateChanged?.Invoke();
+ }
+
+ private void StopBuild(bool preserveLevel = false)
+ {
+ if (!preserveLevel && _buildInProgress)
+ _latestPowerBarLevel = GetPowerBarLevel();
+ _buildInProgress = false;
+ _buildStartTime = 0d;
+ }
+
+ private void ResetPowerBar()
+ {
+ StopBuild();
+ _latestPowerBarLevel = 0f;
+ _currentBuildIsAutomatic = false;
+ }
+
+ private void Reset()
+ {
+ _attackRequestInProgress = false;
+ _attackServerResponsePending = false;
+ _attackWhenResponseReceived = false;
+ _attackWhenResponseReceivedPower = 0f;
+ _repeatAttacking = false;
+ _requestedAttackPower = 0f;
+ ResetPowerBar();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _combat.CombatModeChanged -= OnCombatModeChanged;
+ _combat.AttackCommenced -= OnAttackCommenced;
+ _combat.AttackDone -= OnAttackDone;
+ }
+}
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 60c23d23..6649967b 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -165,15 +165,14 @@ public sealed class PlayerMovementController
// Jump charge state.
private bool _jumpCharging;
private float _jumpExtent;
- // K-fix6 (2026-04-26): retail's PowerBar charge constant for jump is
- // not legible in the named decomp (the divisor was clobbered in
- // GetPowerBarLevel's FPU stack reordering at FUN_0056ade0). 2.0/s
- // (full charge in 0.5s) feels matches retail muscle memory better
- // than the previous 1.0/s — a tap gives a noticeable hop, half-hold
- // a meaningful jump, full-hold the maximum extent. The vertical
- // velocity formula itself (height × 19.6 → vz) is unchanged and
- // matches retail byte-for-byte; only the time-to-fill is faster.
- private const float JumpChargeRate = 2.0f;
+ // Matching v11.4186 x86 resolves GetPowerBarLevel's collapsed x87
+ // operands: ATTACK_POWERUP_TIME=1.0 s, DUAL_WIELD_POWERUP_TIME=0.8 s.
+ // Jump uses the same shared powerbar function, so its normal fill rate is
+ // 1 extent/s and DualWieldCombat is 1/0.8 = 1.25 extent/s.
+ private const float JumpChargeRate =
+ 1f / (float)AcDream.Core.Combat.CombatInputPlanner.AttackPowerUpSeconds;
+ private const float DualWieldJumpChargeRate =
+ 1f / (float)AcDream.Core.Combat.CombatInputPlanner.DualWieldPowerUpSeconds;
// Airborne → grounded transition detection. Flipped on every frame where
// the body transitions from airborne to on-walkable; used by the GameWindow
@@ -780,7 +779,11 @@ public sealed class PlayerMovementController
// this line despite the W3 port.
_motion.ChargeJump();
}
- _jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f);
+ float chargeRate = _motion.InterpretedState.CurrentStyle
+ == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle
+ ? DualWieldJumpChargeRate
+ : JumpChargeRate;
+ _jumpExtent = MathF.Min(_jumpExtent + dt * chargeRate, 1.0f);
}
else if (_jumpCharging)
{
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 9cc5ea69..a637a15b 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -760,6 +760,7 @@ public sealed class GameWindow : IDisposable
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
+ private AcDream.App.Combat.CombatAttackController? _combatAttackController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
@@ -1979,11 +1980,19 @@ public sealed class GameWindow : IDisposable
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
_samplerCache = new SamplerCache(_gl);
- // Phase D.2b — retail-look UI (ACDREAM_RETAIL_UI=1). Wires the existing
- // UiHost retained-mode tree (dormant until now) + a first vitals panel.
- // Render-only: UiHost input is NOT yet bridged to the InputDispatcher
- // (next sub-phase), so the close button + window drag are inert. Coexists
- // with the ImGui devtools path (ACDREAM_DEVTOOLS=1), which is unchanged.
+ // Retail ClientCombatSystem attack-request owner. This exists independently
+ // of the retained UI so keyboard combat keeps the same press/hold/release
+ // semantics even when the retail renderer is disabled.
+ _combatAttackController = new AcDream.App.Combat.CombatAttackController(
+ Combat,
+ CanStartLiveCombatAttack,
+ SendLiveCombatAttack,
+ isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle
+ == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle,
+ playerReadyForAttack: IsPlayerReadyForCombatAttack,
+ autoRepeatAttack: () => _persistedGameplay.AutoRepeatAttack);
+
+ // Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
if (_options.RetailUi)
{
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
@@ -2116,6 +2125,9 @@ public sealed class GameWindow : IDisposable
radarSnapshotProvider.BuildSnapshot,
_selection,
SetRetailUiLocked),
+ Combat: new AcDream.App.UI.CombatRuntimeBindings(
+ Combat,
+ _combatAttackController),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects,
() => Shortcuts,
@@ -8194,6 +8206,7 @@ public sealed class GameWindow : IDisposable
// re-fire while their chord is held. K.1b adds the subscribers
// that actually consume the events.
_inputDispatcher?.Tick();
+ _combatAttackController?.Tick();
// Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse
// on selection, then revert). No-op when nothing is flashing.
@@ -11730,6 +11743,12 @@ public sealed class GameWindow : IDisposable
return;
}
+ // Retail attack actions consume their full transition stream: key-down
+ // starts the power build and key-up commits it. Handle them before the
+ // generic Press-only gate below.
+ if (_combatAttackController?.HandleInputAction(action, activation) == true)
+ return;
+
// Every other action fires on Press only (no Release / Hold side-
// effects in the K.1b set). Filter out non-Press activations early
// so subscribers that have Release-mode bindings don't accidentally
@@ -11853,18 +11872,6 @@ public sealed class GameWindow : IDisposable
ToggleLiveCombatMode();
break;
- case AcDream.UI.Abstractions.Input.InputAction.CombatLowAttack:
- SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Low);
- break;
-
- case AcDream.UI.Abstractions.Input.InputAction.CombatMediumAttack:
- SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Medium);
- break;
-
- case AcDream.UI.Abstractions.Input.InputAction.CombatHighAttack:
- SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.High);
- break;
-
case AcDream.UI.Abstractions.Input.InputAction.SelectLeft:
PickAndStoreSelection(useImmediately: false);
break;
@@ -11928,17 +11935,28 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast(text);
}
- private void SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction action)
+ private bool IsPlayerReadyForCombatAttack()
+ {
+ if (_playerController is null)
+ return false;
+ var motion = _playerController.Motion.InterpretedState;
+ return AcDream.Core.Combat.CombatInputPlanner.PlayerInReadyPositionForAttack(
+ Combat.CurrentMode,
+ motion.CurrentStyle,
+ motion.ForwardCommand);
+ }
+
+ private bool CanStartLiveCombatAttack()
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
- return;
+ return false;
if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode))
{
_debugVm?.AddToast("Enter melee or missile combat first");
Console.WriteLine("combat: attack ignored; not in melee/missile combat mode");
- return;
+ return false;
}
uint? target = GetSelectedOrClosestCombatTarget();
@@ -11946,21 +11964,32 @@ public sealed class GameWindow : IDisposable
{
_debugVm?.AddToast("No monster target");
Console.WriteLine("combat: attack ignored; no creature target found");
- return;
+ return false;
}
- var height = AcDream.Core.Combat.CombatInputPlanner.HeightFor(action);
- const float FullBar = 1.0f;
+ return true;
+ }
+
+ private bool SendLiveCombatAttack(
+ AcDream.Core.Combat.AttackHeight height,
+ float power)
+ {
+ if (!CanStartLiveCombatAttack())
+ return false;
+
+ uint target = _selection.SelectedObjectId!.Value;
+ power = Math.Clamp(power, 0f, 1f);
if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
{
- _liveSession.SendMissileAttack(target.Value, height, FullBar);
- Console.WriteLine($"combat: missile attack target=0x{target.Value:X8} height={height} accuracy={FullBar:F2}");
+ _liveSession!.SendMissileAttack(target, height, power);
+ Console.WriteLine($"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}");
}
else
{
- _liveSession.SendMeleeAttack(target.Value, height, FullBar);
- Console.WriteLine($"combat: melee attack target=0x{target.Value:X8} height={height} power={FullBar:F2}");
+ _liveSession!.SendMeleeAttack(target, height, power);
+ Console.WriteLine($"combat: melee attack target=0x{target:X8} height={height} power={power:F2}");
}
+ return true;
}
private uint? GetSelectedOrClosestCombatTarget()
@@ -13556,6 +13585,8 @@ public sealed class GameWindow : IDisposable
// UI resource is still alive. UiHost disposes controllers before its renderer.
_retailUiRuntime?.Dispose();
_retailUiRuntime = null;
+ _combatAttackController?.Dispose();
+ _combatAttackController = null;
_uiHost = null;
_itemInteractionController?.Dispose();
_itemInteractionController = null;
diff --git a/src/AcDream.App/UI/Layout/CombatUiController.cs b/src/AcDream.App/UI/Layout/CombatUiController.cs
new file mode 100644
index 00000000..a83c02a3
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/CombatUiController.cs
@@ -0,0 +1,158 @@
+using AcDream.App.Combat;
+using AcDream.Core.Combat;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Binds the imported retail gmCombatUI layout to the client combat
+/// state machine. No panel geometry is synthesized here: every control is the
+/// authored element from LayoutDesc 0x21000073.
+///
+///
+/// Retail references: gmCombatUI::RecvNotice_AttackHeightChanged
+/// (0x004CC080), RecvNotice_SetPowerbarLevel (0x004CC0E0),
+/// RecvNotice_DesiredAttackPowerChanged (0x004CC110),
+/// ListenToElementMessage (0x004CC430), and
+/// RecvNotice_SetCombatMode (0x004CC620).
+///
+public sealed class CombatUiController : IRetainedPanelController
+{
+ public const uint LayoutId = 0x21000073u;
+ public const uint BasicPanelId = 0x1000005Cu;
+ public const uint AdvancedPanelId = 0x10000061u;
+ public const uint PowerControlId = 0x1000004Fu;
+ public const uint HighButtonId = 0x10000057u;
+ public const uint MediumButtonId = 0x10000058u;
+ public const uint LowButtonId = 0x10000059u;
+
+ private const uint MeleeState = 0x10000003u;
+ private const uint MissileState = 0x10000004u;
+
+ private readonly UiElement _root;
+ private readonly UiElement _basicPanel;
+ private readonly UiElement _advancedPanel;
+ private readonly UiScrollbar _powerControl;
+ private readonly UiButton _high;
+ private readonly UiButton _medium;
+ private readonly UiButton _low;
+ private readonly CombatState _combat;
+ private readonly CombatAttackController _attacks;
+ private readonly Action _setWindowVisible;
+ private bool _disposed;
+
+ private CombatUiController(
+ ImportedLayout layout,
+ UiElement basicPanel,
+ UiElement advancedPanel,
+ UiScrollbar powerControl,
+ UiButton high,
+ UiButton medium,
+ UiButton low,
+ CombatState combat,
+ CombatAttackController attacks,
+ Action setWindowVisible)
+ {
+ _root = layout.Root;
+ _basicPanel = basicPanel;
+ _advancedPanel = advancedPanel;
+ _powerControl = powerControl;
+ _high = high;
+ _medium = medium;
+ _low = low;
+ _combat = combat;
+ _attacks = attacks;
+ _setWindowVisible = setWindowVisible;
+
+ // PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
+ // basic page. The advanced page belongs to the separate advanced-
+ // combat flow and stays hidden in the basic panel.
+ _basicPanel.Visible = true;
+ _advancedPanel.Visible = false;
+
+ _powerControl.SetScalarPosition(_attacks.DesiredPower);
+ _powerControl.ScalarChanged = _attacks.SetDesiredPower;
+ _powerControl.ScalarFill = () => _attacks.PowerBarLevel;
+
+ BindAttackButton(_high, AttackHeight.High);
+ BindAttackButton(_medium, AttackHeight.Medium);
+ BindAttackButton(_low, AttackHeight.Low);
+
+ _combat.CombatModeChanged += OnCombatModeChanged;
+ _attacks.StateChanged += OnAttackStateChanged;
+ SyncControls();
+ }
+
+ public static CombatUiController? Bind(
+ ImportedLayout layout,
+ CombatState combat,
+ CombatAttackController attacks,
+ Action setWindowVisible)
+ {
+ ArgumentNullException.ThrowIfNull(layout);
+ ArgumentNullException.ThrowIfNull(combat);
+ ArgumentNullException.ThrowIfNull(attacks);
+ ArgumentNullException.ThrowIfNull(setWindowVisible);
+
+ if (layout.FindElement(BasicPanelId) is not { } basic
+ || layout.FindElement(AdvancedPanelId) is not { } advanced
+ || layout.FindElement(PowerControlId) is not UiScrollbar power
+ || layout.FindElement(HighButtonId) is not UiButton high
+ || layout.FindElement(MediumButtonId) is not UiButton medium
+ || layout.FindElement(LowButtonId) is not UiButton low)
+ return null;
+
+ return new CombatUiController(
+ layout, basic, advanced, power, high, medium, low,
+ combat, attacks, setWindowVisible);
+ }
+
+ public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode);
+
+ private void BindAttackButton(UiButton button, AttackHeight height)
+ {
+ button.OnPressed = () => _attacks.PressAttack(height);
+ button.OnReleased = _attacks.ReleaseAttack;
+ }
+
+ private void OnCombatModeChanged(CombatMode mode)
+ {
+ bool visible = CombatInputPlanner.SupportsTargetedAttack(mode);
+ if (_root is IUiDatStateful stateful)
+ {
+ if (mode == CombatMode.Melee)
+ stateful.TrySetRetailState(MeleeState);
+ else if (mode == CombatMode.Missile)
+ stateful.TrySetRetailState(MissileState);
+ }
+ _setWindowVisible(visible);
+ SyncControls();
+ }
+
+ private void OnAttackStateChanged() => SyncControls();
+
+ private void SyncControls()
+ {
+ _powerControl.SetScalarPosition(_attacks.DesiredPower);
+ _high.Selected = _attacks.RequestedHeight == AttackHeight.High;
+ _medium.Selected = _attacks.RequestedHeight == AttackHeight.Medium;
+ _low.Selected = _attacks.RequestedHeight == AttackHeight.Low;
+ }
+
+ public void OnShown() => SyncControls();
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _combat.CombatModeChanged -= OnCombatModeChanged;
+ _attacks.StateChanged -= OnAttackStateChanged;
+ _powerControl.ScalarChanged = null;
+ _powerControl.ScalarFill = () => null;
+ _high.OnPressed = null;
+ _high.OnReleased = null;
+ _medium.OnPressed = null;
+ _medium.OnReleased = null;
+ _low.OnPressed = null;
+ _low.OnReleased = null;
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 2e803e88..bb042520 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -160,6 +160,19 @@ public static class DatWidgetFactory
.FirstOrDefault();
bar.TrackSprite = track is null ? 0u : DefaultImage(track);
bar.ThumbSprite = scalarThumb is null ? 0u : DefaultImage(scalarThumb);
+
+ // gmCombatUI's desired-power slider (0x1000004F) authors the
+ // live charge as a nested Type-7 meter. UiScrollbar consumes its
+ // DAT children, so retain the meter's fill image on the scalar
+ // widget itself. The fill container is element 2; the Recklessness
+ // overlay (0x100005EF) is deliberately not the charge image.
+ ElementInfo? meter = info.Children.FirstOrDefault(child => child.Type == 7u);
+ ElementInfo? fill = meter?.Children.FirstOrDefault(child => child.Id == 2u)
+ ?? meter?.Children
+ .Where(child => DefaultImage(child) != 0u)
+ .OrderByDescending(child => child.ReadOrder)
+ .FirstOrDefault();
+ bar.ScalarFillSprite = fill is null ? 0u : DefaultImage(fill);
return bar;
}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index e88f9195..a82f3ff6 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using AcDream.App.Plugins;
+using AcDream.App.Combat;
using AcDream.App.Rendering;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
@@ -35,6 +36,10 @@ public sealed record RadarRuntimeBindings(
SelectionState Selection,
Action SetUiLocked);
+public sealed record CombatRuntimeBindings(
+ CombatState State,
+ CombatAttackController Attacks);
+
public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects,
Func> Shortcuts,
@@ -96,6 +101,7 @@ public sealed record RetailUiRuntimeBindings(
VitalsRuntimeBindings Vitals,
ChatRuntimeBindings Chat,
RadarRuntimeBindings Radar,
+ CombatRuntimeBindings Combat,
ToolbarRuntimeBindings Toolbar,
CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory,
@@ -125,6 +131,7 @@ public sealed class RetailUiRuntime : IDisposable
MountRadar();
MountChat();
MountToolbar();
+ MountCombat();
MountCharacter();
MountPlugins();
MountInventory();
@@ -160,6 +167,7 @@ public sealed class RetailUiRuntime : IDisposable
public CharacterSheetProvider CharacterSheetProvider => _bindings.Character.Provider;
public ToolbarController? ToolbarController { get; private set; }
public ToolbarInputController? ToolbarInputController { get; private set; }
+ public CombatUiController? CombatUiController { get; private set; }
public SelectedObjectController? SelectedObjectController { get; private set; }
public UiViewport? PaperdollViewportWidget { get; private set; }
public UiNineSlicePanel? InventoryFrame { get; private set; }
@@ -439,6 +447,54 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
}
+ private void MountCombat()
+ {
+ ImportedLayout? layout = Import(CombatUiController.LayoutId);
+ if (layout is null)
+ {
+ Console.WriteLine("[M2] combat: LayoutDesc 0x21000073 not found.");
+ return;
+ }
+
+ CombatUiController? controller = Layout.CombatUiController.Bind(
+ layout,
+ _bindings.Combat.State,
+ _bindings.Combat.Attacks,
+ visible =>
+ {
+ if (visible) Host.ShowWindow(WindowNames.Combat);
+ else Host.HideWindow(WindowNames.Combat);
+ });
+ if (controller is null)
+ {
+ Console.WriteLine("[M2] combat: required controls missing in LayoutDesc 0x21000073.");
+ return;
+ }
+
+ CombatUiController = controller;
+ UiElement root = layout.Root;
+ RetailWindowFrame.Mount(
+ Host.Root,
+ root,
+ _bindings.Assets.ResolveSprite,
+ new RetailWindowFrame.Options
+ {
+ WindowName = WindowNames.Combat,
+ Chrome = RetailWindowChrome.Imported,
+ Left = Math.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
+ Top = Math.Max(0f, Host.Root.Height - root.Height - 10f),
+ Visible = false,
+ Resizable = false,
+ ResizeX = false,
+ ResizeY = false,
+ ConstrainDragToParent = true,
+ ContentClickThrough = false,
+ Controller = controller,
+ });
+ controller.SyncVisibility();
+ Console.WriteLine("[M2] retail combat bar from gmCombatUI LayoutDesc 0x21000073.");
+ }
+
private (uint[] Peace, uint[] War, uint[]? Empty) LoadToolbarDigits()
{
uint[]? peace = null, war = null, empty = null;
diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs
index 0b5f7936..09b973e5 100644
--- a/src/AcDream.App/UI/UiButton.cs
+++ b/src/AcDream.App/UI/UiButton.cs
@@ -47,6 +47,14 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).
public Action? OnClick { get; set; }
+ ///
+ /// Optional pointer transition handlers. These expose retail's distinct
+ /// pressed/released element messages for controls such as the combat-height
+ /// buttons, where mouse-down begins charging and mouse-up commits the attack.
+ ///
+ public Action? OnPressed { get; set; }
+ public Action? OnReleased { get; set; }
+
///
/// Optional position-aware click handler. Coordinates are local pixels in this button,
/// matching retail's UIElementMessageInfo.ptWindow paperdoll hit-test input.
@@ -292,6 +300,8 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
_pointerOver = ContainsLocal(e.Data1, e.Data2);
_pressed = true;
UpdateVisualState();
+ if (Enabled)
+ OnPressed?.Invoke();
if (HotClickEnabled && Enabled)
{
OnClick?.Invoke();
@@ -319,6 +329,8 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
_nextHotClickTime = double.NaN;
if (_pressed && _pointerOver && Enabled && ToggleBehavior)
_selected = !_selected;
+ if (_pressed && Enabled)
+ OnReleased?.Invoke();
_pressed = false;
UpdateVisualState();
return true;
diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs
index fbd57a09..d8510cbb 100644
--- a/src/AcDream.App/UI/UiScrollbar.cs
+++ b/src/AcDream.App/UI/UiScrollbar.cs
@@ -33,6 +33,15 @@ public sealed class UiScrollbar : UiElement
public Action? ScalarChanged { get; set; }
public bool Horizontal { get; set; }
+ ///
+ /// Optional fill rendered beneath the scalar thumb. Retail's combat power
+ /// control is a horizontal scrollbar containing a meter child; the importer
+ /// folds that authored child into these properties because scrollbars consume
+ /// their DAT children.
+ ///
+ public Func ScalarFill { get; set; } = () => null;
+ public uint ScalarFillSprite { get; set; }
+
/// Programmatically set retail scrollbar attribute 0x86 without broadcasting.
public void SetScalarPosition(float position)
=> ScalarPosition = Math.Clamp(position, 0f, 1f);
@@ -102,6 +111,11 @@ public sealed class UiScrollbar : UiElement
if (Horizontal)
{
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
+ if (ScalarFill() is float fill && ScalarFillSprite != 0)
+ {
+ float visibleWidth = Width * Math.Clamp(fill, 0f, 1f);
+ DrawTiled(ctx, resolve, ScalarFillSprite, 0f, 0f, visibleWidth, Height);
+ }
float thumbWidth = ScalarThumbWidth(resolve);
float travel = MathF.Max(0f, Width - thumbWidth);
float x = travel * ScalarPosition;
diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs
index a5dae9b3..df65a9b6 100644
--- a/src/AcDream.App/UI/WindowNames.cs
+++ b/src/AcDream.App/UI/WindowNames.cs
@@ -10,4 +10,5 @@ public static class WindowNames
public const string Inventory = "inventory";
public const string Chat = "chat";
public const string Radar = "radar";
+ public const string Combat = "combat";
}
diff --git a/src/AcDream.Core/Combat/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs
index 3aa2fefa..b0221418 100644
--- a/src/AcDream.Core/Combat/CombatModel.cs
+++ b/src/AcDream.Core/Combat/CombatModel.cs
@@ -50,6 +50,38 @@ public enum CombatAttackAction
///
public static class CombatInputPlanner
{
+ ///
+ /// Retail MotionStance_DualWieldCombat. GetPowerBarLevel (0x0056ADE0)
+ /// selects the faster 0.8-second charge when this is the current style.
+ ///
+ public const uint DualWieldCombatStyle = 0x80000046u;
+ public const uint ReadyForwardCommand = 0x41000003u;
+ public const double AttackPowerUpSeconds = 1.0;
+ public const double DualWieldPowerUpSeconds = 0.8;
+
+ ///
+ /// Attack-time branch of retail PlayerInReadyPosition
+ /// (0x0056B820, arg2=true). Melee accepts a valid maneuver table;
+ /// every player has one, while missile additionally requires one of
+ /// retail's launcher/thrown styles to be at Ready.
+ ///
+ public static bool PlayerInReadyPositionForAttack(
+ CombatMode mode,
+ uint currentStyle,
+ uint forwardCommand)
+ {
+ if (mode == CombatMode.Melee)
+ return true;
+ if (mode != CombatMode.Missile || forwardCommand != ReadyForwardCommand)
+ return false;
+ return currentStyle is 0x8000003Fu
+ or 0x80000041u
+ or 0x80000043u
+ or 0x80000047u
+ or 0x80000138u
+ or 0x80000139u;
+ }
+
private const EquipMask PrimaryWeaponLocations =
EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.TwoHanded;
diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs
index 069fb3b6..dd09d760 100644
--- a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs
+++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs
@@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
///
public sealed class KeyBindings
{
- private const int CurrentSchemaVersion = 2;
+ private const int CurrentSchemaVersion = 3;
private readonly List _bindings = new();
@@ -244,9 +244,12 @@ public sealed class KeyBindings
// Melee mode (active when MeleeCombat scope pushed).
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower));
- b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack));
- b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack));
- b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack));
+ // Retail HandleCombatAction consumes key-down AND key-up for attack
+ // height actions: down starts charging, up ends the request. Hold is
+ // our binding representation for that transition pair.
+ b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold));
+ b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold));
+ b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold));
// Missile + Magic + Spell-tab — same chords; resolved by scope at
// runtime per InputDispatcher's stack lookup. Add the bindings;
// subscribers arrive in Phase L when CombatState.CurrentMode is
@@ -420,6 +423,7 @@ public sealed class KeyBindings
}
var chord = new KeyChord(silkKey, mods, device);
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
+ activation = MigrateCombatAttackActivation(version, action, activation);
loaded.Add(new(chord, action, activation));
}
}
@@ -516,6 +520,25 @@ public sealed class KeyBindings
: action;
}
+ ///
+ /// Schema v2 stored the three attack-height commands as one-shot Press
+ /// bindings. Retail requires both press and release, so old and customized
+ /// chords retain their key while upgrading to Hold transition semantics.
+ ///
+ private static ActivationType MigrateCombatAttackActivation(
+ int version,
+ InputAction action,
+ ActivationType activation)
+ {
+ if (version >= 3 || activation != ActivationType.Press)
+ return activation;
+ return action is InputAction.CombatLowAttack
+ or InputAction.CombatMediumAttack
+ or InputAction.CombatHighAttack
+ ? ActivationType.Hold
+ : activation;
+ }
+
private static ModifierMask ParseModifiers(JsonElement bindingEl)
{
if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None;
diff --git a/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs b/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs
new file mode 100644
index 00000000..18cc4da2
--- /dev/null
+++ b/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs
@@ -0,0 +1,147 @@
+using AcDream.App.Combat;
+using AcDream.Core.Combat;
+using AcDream.UI.Abstractions.Input;
+
+namespace AcDream.App.Tests.Combat;
+
+public sealed class CombatAttackControllerTests
+{
+ [Fact]
+ public void NormalStance_ChargesLinearlyOverOneSecond_AndReleaseSendsCurrentPower()
+ {
+ double now = 10d;
+ var sent = new List<(AttackHeight Height, float Power)>();
+ var combat = new CombatState();
+ using var controller = Create(combat, () => now, sent);
+ combat.SetCombatMode(CombatMode.Melee);
+
+ controller.PressAttack(AttackHeight.High);
+ now = 10.5d;
+
+ Assert.Equal(0.5f, controller.PowerBarLevel, 3);
+ controller.ReleaseAttack();
+
+ var attack = Assert.Single(sent);
+ Assert.Equal(AttackHeight.High, attack.Height);
+ Assert.Equal(0.5f, attack.Power, 3);
+ }
+
+ [Fact]
+ public void DualWieldStance_UsesRetailPointEightSecondPowerUpTime()
+ {
+ double now = 3d;
+ var combat = new CombatState();
+ using var controller = Create(
+ combat,
+ () => now,
+ [],
+ isDualWield: () => true);
+ combat.SetCombatMode(CombatMode.Melee);
+
+ controller.PressAttack(AttackHeight.Medium);
+ now = 3.4d;
+
+ Assert.Equal(0.5f, controller.PowerBarLevel, 3);
+ }
+
+ [Fact]
+ public void EarlyRelease_CommitsOnUseTimeTickAtReleasedPower()
+ {
+ double now = 0d;
+ var sent = new List<(AttackHeight Height, float Power)>();
+ var combat = new CombatState();
+ using var controller = Create(combat, () => now, sent);
+ combat.SetCombatMode(CombatMode.Missile);
+ controller.SetDesiredPower(1f);
+
+ controller.PressAttack(AttackHeight.Low);
+ now = 0.25d;
+ controller.ReleaseAttack();
+ Assert.Empty(sent);
+
+ now = 0.26d;
+ controller.Tick();
+
+ var attack = Assert.Single(sent);
+ Assert.Equal(AttackHeight.Low, attack.Height);
+ Assert.Equal(0.25f, attack.Power, 3);
+ }
+
+ [Fact]
+ public void HoldBinding_UsesPressAndReleaseTransitions()
+ {
+ double now = 1d;
+ var sent = new List<(AttackHeight Height, float Power)>();
+ var combat = new CombatState();
+ using var controller = Create(combat, () => now, sent);
+ combat.SetCombatMode(CombatMode.Melee);
+
+ Assert.True(controller.HandleInputAction(
+ InputAction.CombatLowAttack, ActivationType.Press));
+ now = 1.5d;
+ Assert.True(controller.HandleInputAction(
+ InputAction.CombatLowAttack, ActivationType.Release));
+
+ Assert.Equal(AttackHeight.Low, Assert.Single(sent).Height);
+ }
+
+ [Fact]
+ public void LeavingTargetedCombat_CancelsAnActiveBuild()
+ {
+ double now = 0d;
+ var combat = new CombatState();
+ using var controller = Create(combat, () => now, []);
+ combat.SetCombatMode(CombatMode.Melee);
+ controller.PressAttack(AttackHeight.Medium);
+ now = 0.4d;
+
+ combat.SetCombatMode(CombatMode.NonCombat);
+
+ Assert.False(controller.BuildInProgress);
+ Assert.False(controller.AttackRequestInProgress);
+ Assert.Equal(0f, controller.PowerBarLevel);
+ }
+
+ [Fact]
+ public void RequestWaitsUntilPlayerReachesReadyStanceBeforeBuilding()
+ {
+ double now = 0d;
+ bool ready = false;
+ var combat = new CombatState();
+ using var controller = new CombatAttackController(
+ combat,
+ () => true,
+ (_, _) => true,
+ playerReadyForAttack: () => ready,
+ now: () => now);
+ combat.SetCombatMode(CombatMode.Missile);
+
+ controller.PressAttack(AttackHeight.High);
+ Assert.True(controller.AttackRequestInProgress);
+ Assert.False(controller.BuildInProgress);
+
+ ready = true;
+ now = 5d;
+ controller.Tick();
+
+ Assert.True(controller.BuildInProgress);
+ Assert.Equal(0f, controller.PowerBarLevel);
+ }
+
+ private static CombatAttackController Create(
+ CombatState combat,
+ Func now,
+ List<(AttackHeight Height, float Power)> sent,
+ Func? isDualWield = null)
+ => new(
+ combat,
+ canStartAttack: () => true,
+ sendAttack: (height, power) =>
+ {
+ sent.Add((height, power));
+ return true;
+ },
+ isDualWield: isDualWield,
+ autoRepeatAttack: () => false,
+ now: now);
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs
new file mode 100644
index 00000000..99575118
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs
@@ -0,0 +1,106 @@
+using AcDream.App.Combat;
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+using AcDream.Core.Combat;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+public sealed class CombatUiControllerTests
+{
+ private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
+
+ [Fact]
+ public void CombatMode_ShowsOnlyTargetedModes_AndSelectsMediumByDefault()
+ {
+ double now = 0d;
+ var combat = new CombatState();
+ using var attacks = CreateAttacks(combat, () => now, []);
+ var (layout, _, _, power, high, medium, low) = BuildLayout();
+ var visibility = new List();
+ using var controller = CombatUiController.Bind(
+ layout, combat, attacks, visibility.Add)!;
+
+ controller.SyncVisibility();
+ combat.SetCombatMode(CombatMode.Melee);
+ combat.SetCombatMode(CombatMode.Magic);
+
+ Assert.Equal([false, true, false], visibility);
+ Assert.False(high.Selected);
+ Assert.True(medium.Selected);
+ Assert.False(low.Selected);
+ Assert.Equal(CombatAttackController.InitialDesiredPower, power.ScalarPosition);
+ }
+
+ [Fact]
+ public void AuthoredHeightButton_PressChargesAndReleaseSendsThroughSharedController()
+ {
+ double now = 4d;
+ var sent = new List<(AttackHeight Height, float Power)>();
+ var combat = new CombatState();
+ using var attacks = CreateAttacks(combat, () => now, sent);
+ var (layout, basic, advanced, power, high, _, _) = BuildLayout();
+ using var controller = CombatUiController.Bind(
+ layout, combat, attacks, _ => { })!;
+ combat.SetCombatMode(CombatMode.Melee);
+
+ high.OnEvent(new UiEvent(0, high, UiEventType.MouseDown, Data1: 2, Data2: 2));
+ now = 4.5d;
+ Assert.Equal(0.5f, power.ScalarFill()!.Value, 3);
+ high.OnEvent(new UiEvent(0, high, UiEventType.MouseUp, Data1: 2, Data2: 2));
+
+ var attack = Assert.Single(sent);
+ Assert.Equal(AttackHeight.High, attack.Height);
+ Assert.Equal(0.5f, attack.Power, 3);
+ Assert.True(basic.Visible);
+ Assert.False(advanced.Visible);
+ }
+
+ private static CombatAttackController CreateAttacks(
+ CombatState combat,
+ Func now,
+ List<(AttackHeight Height, float Power)> sent)
+ => new(
+ combat,
+ () => true,
+ (height, power) =>
+ {
+ sent.Add((height, power));
+ return true;
+ },
+ autoRepeatAttack: () => false,
+ now: now);
+
+ private static (
+ ImportedLayout Layout,
+ UiElement Basic,
+ UiElement Advanced,
+ UiScrollbar Power,
+ UiButton High,
+ UiButton Medium,
+ UiButton Low) BuildLayout()
+ {
+ var root = new UiPanel { Width = 610, Height = 90 };
+ var basic = new UiPanel();
+ var advanced = new UiPanel();
+ var power = new UiScrollbar { Width = 507, Height = 14, Horizontal = true };
+ UiButton Button(uint id) => new(
+ new ElementInfo { Id = id, Type = 1, Width = 72, Height = 19 }, NoTex)
+ {
+ Width = 72,
+ Height = 19,
+ };
+ var high = Button(CombatUiController.HighButtonId);
+ var medium = Button(CombatUiController.MediumButtonId);
+ var low = Button(CombatUiController.LowButtonId);
+ var byId = new Dictionary
+ {
+ [CombatUiController.BasicPanelId] = basic,
+ [CombatUiController.AdvancedPanelId] = advanced,
+ [CombatUiController.PowerControlId] = power,
+ [CombatUiController.HighButtonId] = high,
+ [CombatUiController.MediumButtonId] = medium,
+ [CombatUiController.LowButtonId] = low,
+ };
+ return (new ImportedLayout(root, byId), basic, advanced, power, high, medium, low);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index 8456bed9..cd9b4b0f 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -451,6 +451,47 @@ public class DatWidgetFactoryTests
Assert.Equal(gold, t.DefaultColor);
}
+ [Fact]
+ public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite()
+ {
+ var track = new ElementInfo { Id = 4, Type = 3, Width = 507, Height = 14 };
+ track.StateMedia[""] = (0x06001919u, 1);
+ track.States[UiStateInfo.DirectStateId] = new UiStateInfo
+ { Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001919u, 1) };
+ var thumb = new ElementInfo { Id = 1, Type = 1, Width = 12, Height = 14 };
+ thumb.StateMedia[""] = (0x06001923u, 1);
+ thumb.States[UiStateInfo.DirectStateId] = new UiStateInfo
+ { Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001923u, 1) };
+ var fill = new ElementInfo { Id = 2, Type = 3, Width = 507, Height = 14 };
+ fill.StateMedia[""] = (0x06001200u, 1);
+ fill.States[UiStateInfo.DirectStateId] = new UiStateInfo
+ { Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001200u, 1) };
+ var meter = new ElementInfo
+ {
+ Id = CombatUiController.PowerControlId + 1,
+ Type = 7,
+ Width = 507,
+ Height = 14,
+ Children = [fill],
+ };
+ var info = new ElementInfo
+ {
+ Id = CombatUiController.PowerControlId,
+ Type = 11,
+ Width = 507,
+ Height = 14,
+ Children = [track, meter, thumb],
+ };
+
+ var bar = Assert.IsType(
+ DatWidgetFactory.Create(info, NoTex, null));
+
+ Assert.True(bar.Horizontal);
+ Assert.Equal(0x06001919u, bar.TrackSprite);
+ Assert.Equal(0x06001923u, bar.ThumbSprite);
+ Assert.Equal(0x06001200u, bar.ScalarFillSprite);
+ }
+
private static ElementInfo TextInfo(params (uint Id, UiPropertyValue Value)[] properties)
{
var info = new ElementInfo { Id = 0x10000016u, Type = 12, Width = 100, Height = 20 };
diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
index aab9d8ac..b653e39e 100644
--- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
@@ -30,6 +30,24 @@ public class UiButtonTests
Assert.Equal((17, 9), clicked);
}
+ [Fact]
+ public void PointerDownAndUp_InvokeDistinctTransitionHandlers()
+ {
+ var transitions = new List();
+ var b = new UiButton(new ElementInfo { Type = 1, Width = 20, Height = 20 }, NoTex)
+ {
+ Width = 20,
+ Height = 20,
+ OnPressed = () => transitions.Add("pressed"),
+ OnReleased = () => transitions.Add("released"),
+ };
+
+ b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
+ b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 30, Data2: 30));
+
+ Assert.Equal(["pressed", "released"], transitions);
+ }
+
[Fact]
public void NotClickThrough_SoItReceivesClicks()
{
diff --git a/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
index 89257756..afd8c11f 100644
--- a/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
+++ b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
@@ -94,6 +94,24 @@ public sealed class CombatInputPlannerTests
Assert.Equal(expected, CombatInputPlanner.SupportsTargetedAttack(mode));
}
+ [Theory]
+ [InlineData(CombatMode.Melee, 0x8000003Du, 0u, true)]
+ [InlineData(CombatMode.Missile, 0x8000003Fu, CombatInputPlanner.ReadyForwardCommand, true)]
+ [InlineData(CombatMode.Missile, 0x80000041u, CombatInputPlanner.ReadyForwardCommand, true)]
+ [InlineData(CombatMode.Missile, 0x8000003Du, CombatInputPlanner.ReadyForwardCommand, false)]
+ [InlineData(CombatMode.Missile, 0x8000003Fu, 0x41000006u, false)]
+ [InlineData(CombatMode.NonCombat, 0x8000003Du, CombatInputPlanner.ReadyForwardCommand, false)]
+ public void PlayerInReadyPositionForAttack_MatchesRetailAttackBranch(
+ CombatMode mode,
+ uint style,
+ uint forward,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ CombatInputPlanner.PlayerInReadyPositionForAttack(mode, style, forward));
+ }
+
private static ClientObject Equipped(
EquipMask location,
ItemType type,
diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs
index c7f522d3..5cbc9f67 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs
@@ -223,6 +223,40 @@ public class KeyBindingsJsonTests
}
}
+ [Fact]
+ public void LoadOrDefault_migratesV2CombatPressBindingToHold()
+ {
+ var path = TempFile();
+ try
+ {
+ const string json = """
+ {
+ "version": 2,
+ "actions": {
+ "CombatHighAttack": [
+ { "key": "F7" }
+ ]
+ }
+ }
+ """;
+ File.WriteAllText(path, json);
+
+ var loaded = KeyBindings.LoadOrDefault(path);
+
+ Assert.Equal(InputAction.CombatHighAttack,
+ loaded.Find(
+ new KeyChord(Key.F7, ModifierMask.None),
+ ActivationType.Hold)?.Action);
+ Assert.Null(loaded.Find(
+ new KeyChord(Key.F7, ModifierMask.None),
+ ActivationType.Press));
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+
[Fact]
public void LoadOrDefault_unknown_action_name_skipped_silently()
{
diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs
index cfa708da..a1134471 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs
@@ -108,6 +108,19 @@ public class KeyBindingsRetailTests
Assert.Equal(InputAction.EscapeKey, hit!.Value.Action);
}
+ [Theory]
+ [InlineData(Key.Delete, InputAction.CombatLowAttack)]
+ [InlineData(Key.End, InputAction.CombatMediumAttack)]
+ [InlineData(Key.PageDown, InputAction.CombatHighAttack)]
+ public void CombatAttackHeightBindings_EmitHoldTransitions(Key key, InputAction action)
+ {
+ var b = KeyBindings.RetailDefaults();
+
+ var hit = b.Find(new KeyChord(key, ModifierMask.None), ActivationType.Hold);
+
+ Assert.Equal(action, hit?.Action);
+ }
+
[Fact]
public void QuickSlot_5_bareUsesAndCtrlSelects()
{