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() {