fix(combat): charge power bar from speed

Read the authored meter direction instead of inferring it from the combat element id, so the bright charge texture grows left-to-right from Speed. Keep the user-approved dark-red middle baseline visible independently and record its exact retail Recklessness visibility edge.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-12 21:25:24 +02:00
parent 256c1930bd
commit 707add8539
13 changed files with 50 additions and 49 deletions

View file

@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 1. Intentional architecture (IA) — 17 rows ## 1. Intentional architecture (IA) — 18 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -58,6 +58,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | | IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |
| IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the middle baseline between Speed and Power. Retail owns that media as field `0x100005EF` and toggles it with trained Recklessness during the scalar-message path. | `src/AcDream.App/UI/UiScrollbar.cs`; binding geometry in `src/AcDream.App/UI/Layout/CombatUiController.cs` | Explicit connected visual direction: the dark middle track must remain present behind the live bright-red charge fill; the exact skill-gated presentation remains tracked by AP-112 | Untrained characters see the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::PostInit @ 0x004CC1A0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` child `0x100005EF` |
--- ---
@ -213,7 +214,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| 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-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: 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-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-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, trained-Recklessness range, ready-stance gating, request/release, server-response queueing, and auto-repeat, but omits `StartAttackRequest`'s `FinishJump`/`MaybeStopCompletely` command-interpreter calls | `src/AcDream.App/Combat/CombatAttackController.cs` | The M2 attack contract and authored basic panel are live; the remaining seam requires the jump/movement command owner rather than a UI-local guess | Starting an attack while charging a jump or deliberately moving may not stop/cancel exactly when retail does | `ClientCombatSystem::StartAttackRequest @ 0x0056C040` | | 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 exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The M2 attack contract and authored basic panel are live; the remaining seams require the jump/movement command owner and a distinct Recklessness treatment rather than UI-local guesses | Starting an attack while charging a jump or deliberately moving may not stop/cancel exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
## 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) ## 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)

View file

@ -497,7 +497,7 @@ behavior. Estimated 1726 days focused work, 35 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. - **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 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 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; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the layered gray/dark-red/live-bright-red power track, exact Speed-left/Power-right justification, trained-Recklessness range, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 now records only the remaining attack-start command-interpreter seam. - **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the centered dark-red baseline (accepted IA-20), left-to-right live bright-red charge fill from Speed, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness 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 — 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. - **✓ 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. - **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.

View file

@ -171,23 +171,31 @@ applies the associated player option.
track = scrollbar DirectState image track = scrollbar DirectState image
thumb = structural child element 1 thumb = structural child element 1
live power fill = nested meter child 0x10000050 / image child 2 live power fill = nested meter child 0x10000050 / image child 2
trained Recklessness range = nested meter child 0x100005EF dark interior texture source = nested meter child 0x100005EF
``` ```
These roles are identified by authored element ids, never by resolved width. These roles are identified by authored element ids, never by resolved width.
The combat thumb is a 12x14 green jewel; inheritance reflow can make its The combat thumb is a 12x14 green jewel; inheritance reflow can make its
effective child geometry wide enough that a width heuristic incorrectly tiles effective child geometry wide enough that a width heuristic incorrectly tiles
the jewel as the background. The base track is `0x060074CA`, thumb the jewel as the background. The base track is `0x060074CA`, thumb
`0x06001923`, live bright-red fill `0x06001200`, and the trained- `0x06001923`, live bright-red fill `0x06001200`, and dark-red interior
Recklessness dark-red range `0x0600715E`. texture `0x0600715E`.
The three visual layers remain separate. The gray scrollbar track spans the The three visual layers remain separate. The gray scrollbar track spans the
whole authored control. The dark-red Recklessness range is visible only when whole authored control. Retail skill-gates element `0x100005EF` to Recklessness
skill `0x32` has advancement class `Trained` or higher; retail re-evaluates it advancement class `Trained` or higher when scalar message `0x0A` is received.
when scalar message `0x0A` is received. The meter fill is driven independently Per the connected visual gate, acdream uses that dark-red media as the always-
visible middle baseline (intentional divergence IA-20); the remaining exact
skill-gated treatment stays in AP-112. The meter fill is driven independently
by `RecvNotice_SetPowerbarLevel`, while the green thumb is driven by by `RecvNotice_SetPowerbarLevel`, while the green thumb is driven by
`RecvNotice_DesiredAttackPowerChanged`. `RecvNotice_DesiredAttackPowerChanged`.
The meter authors attribute `0x6F = 1`. `UIElement_Meter` constructor and
`DrawChildren @ 0x0046FBD0` make direction 1 the forward horizontal clip, so
the bright texture grows left-to-right from Speed. Direction 3 is the reverse
horizontal clip. The importer reads this attribute; it does not infer direction
from the combat element id.
The Speed and Power text rectangles are later siblings of the scrollbar. Their The Speed and Power text rectangles are later siblings of the scrollbar. Their
inner edges define the visible meter interval so the authored gray track stays inner edges define the visible meter interval so the authored gray track stays
behind both captions. Speed's raw property `0x14` value is `2`, which is left behind both captions. Speed's raw property `0x14` value is `2`, which is left

View file

@ -2135,8 +2135,7 @@ public sealed class GameWindow : IDisposable
Combat, Combat,
_combatAttackController, _combatAttackController,
() => _persistedGameplay, () => _persistedGameplay,
SetRetailCombatGameplay, SetRetailCombatGameplay),
() => LocalPlayer.GetSkill(0x32u)?.Status >= 2u),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings( Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects, Objects,
() => Shortcuts, () => Shortcuts,

View file

@ -105,7 +105,6 @@ public static class FixtureProvider
attacks, attacks,
() => gameplay, () => gameplay,
value => gameplay = value, value => gameplay = value,
() => true,
new CombatUiLabels( new CombatUiLabels(
"Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View", "Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View",
"High", "Medium", "Low"), "High", "Medium", "Low"),

View file

@ -66,7 +66,6 @@ public sealed class CombatUiController : IRetainedPanelController
CombatAttackController attacks, CombatAttackController attacks,
Func<GameplaySettings> gameplay, Func<GameplaySettings> gameplay,
Action<GameplaySettings> setGameplay, Action<GameplaySettings> setGameplay,
Func<bool> recklessnessTrained,
CombatUiLabels labels, CombatUiLabels labels,
Action<bool> setWindowVisible) Action<bool> setWindowVisible)
{ {
@ -95,7 +94,6 @@ public sealed class CombatUiController : IRetainedPanelController
_powerControl.SetScalarPosition(_attacks.DesiredPower); _powerControl.SetScalarPosition(_attacks.DesiredPower);
_powerControl.ScalarChanged = _attacks.SetDesiredPower; _powerControl.ScalarChanged = _attacks.SetDesiredPower;
_powerControl.ScalarFill = () => _attacks.PowerBarLevel; _powerControl.ScalarFill = () => _attacks.PowerBarLevel;
_powerControl.ScalarRangeVisible = recklessnessTrained;
BindAttackButton(_high, AttackHeight.High); BindAttackButton(_high, AttackHeight.High);
BindAttackButton(_medium, AttackHeight.Medium); BindAttackButton(_medium, AttackHeight.Medium);
@ -131,7 +129,6 @@ public sealed class CombatUiController : IRetainedPanelController
CombatAttackController attacks, CombatAttackController attacks,
Func<GameplaySettings> gameplay, Func<GameplaySettings> gameplay,
Action<GameplaySettings> setGameplay, Action<GameplaySettings> setGameplay,
Func<bool> recklessnessTrained,
CombatUiLabels labels, CombatUiLabels labels,
Action<bool> setWindowVisible) Action<bool> setWindowVisible)
{ {
@ -140,7 +137,6 @@ public sealed class CombatUiController : IRetainedPanelController
ArgumentNullException.ThrowIfNull(attacks); ArgumentNullException.ThrowIfNull(attacks);
ArgumentNullException.ThrowIfNull(gameplay); ArgumentNullException.ThrowIfNull(gameplay);
ArgumentNullException.ThrowIfNull(setGameplay); ArgumentNullException.ThrowIfNull(setGameplay);
ArgumentNullException.ThrowIfNull(recklessnessTrained);
ArgumentNullException.ThrowIfNull(labels); ArgumentNullException.ThrowIfNull(labels);
ArgumentNullException.ThrowIfNull(setWindowVisible); ArgumentNullException.ThrowIfNull(setWindowVisible);
@ -158,8 +154,7 @@ public sealed class CombatUiController : IRetainedPanelController
return new CombatUiController( return new CombatUiController(
layout, basic, advanced, power, high, medium, low, layout, basic, advanced, power, high, medium, low,
repeatAttacks, autoTarget, keepInView, repeatAttacks, autoTarget, keepInView,
combat, attacks, gameplay, setGameplay, recklessnessTrained, combat, attacks, gameplay, setGameplay, labels, setWindowVisible);
labels, setWindowVisible);
} }
public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode); public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode);
@ -238,7 +233,6 @@ public sealed class CombatUiController : IRetainedPanelController
_attacks.StateChanged -= OnAttackStateChanged; _attacks.StateChanged -= OnAttackStateChanged;
_powerControl.ScalarChanged = null; _powerControl.ScalarChanged = null;
_powerControl.ScalarFill = () => null; _powerControl.ScalarFill = () => null;
_powerControl.ScalarRangeVisible = () => false;
_high.OnPressed = null; _high.OnPressed = null;
_high.OnReleased = null; _high.OnReleased = null;
_medium.OnPressed = null; _medium.OnPressed = null;

View file

@ -178,19 +178,22 @@ public static class DatWidgetFactory
.FirstOrDefault(); .FirstOrDefault();
bar.ScalarFillSprite = fill is null ? 0u : DefaultImage(fill); bar.ScalarFillSprite = fill is null ? 0u : DefaultImage(fill);
// gmCombatUI preserves a second authored meter child for the // gmCombatUI preserves a second authored meter child for its dark
// trained-Recklessness range. The controller owns its visibility, // red interior range. The widget retains the media because horizontal
// exactly like gmCombatUI::ListenToElementMessage @ 0x004CC430; // scrollbars consume their DAT children.
// the widget only retains the media because it consumes DAT children.
ElementInfo? scalarRange = meter?.Children.FirstOrDefault( ElementInfo? scalarRange = meter?.Children.FirstOrDefault(
child => child.Id == 0x100005EFu); child => child.Id == 0x100005EFu);
bar.ScalarRangeSprite = scalarRange is null bar.ScalarRangeSprite = scalarRange is null
? 0u ? 0u
: DefaultImage(scalarRange); : DefaultImage(scalarRange);
// gmCombatUI meter 0x10000050 grows from the Power end at the // UIElement_Meter::UIElement_Meter @ 0x0046F4C0 defaults direction
// right toward Speed at the left (retail screenshot oracle + // 1; DrawChildren @ 0x0046FBD0 clips that direction left-to-right.
// gmCombatUI::RecvNotice_SetPowerbarLevel 0x004CC0E0). // Direction 3 is the horizontal reverse. Read authored attribute
bar.ScalarFillFromRight = meter?.Id == 0x10000050u; // 0x6F instead of inferring direction from the combat element id.
bar.ScalarFillFromRight = meter is not null
&& meter.TryGetEffectiveProperty(0x6Fu, out UiPropertyValue direction)
&& direction.Kind == UiPropertyKind.Enum
&& direction.UnsignedValue == 3u;
return bar; return bar;
} }

View file

@ -40,8 +40,7 @@ public sealed record CombatRuntimeBindings(
CombatState State, CombatState State,
CombatAttackController Attacks, CombatAttackController Attacks,
Func<GameplaySettings> Gameplay, Func<GameplaySettings> Gameplay,
Action<GameplaySettings> SetGameplay, Action<GameplaySettings> SetGameplay);
Func<bool> RecklessnessTrained);
public sealed record ToolbarRuntimeBindings( public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects, ClientObjectTable Objects,
@ -481,7 +480,6 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Combat.Attacks, _bindings.Combat.Attacks,
_bindings.Combat.Gameplay, _bindings.Combat.Gameplay,
_bindings.Combat.SetGameplay, _bindings.Combat.SetGameplay,
_bindings.Combat.RecklessnessTrained,
labels, labels,
visible => visible =>
{ {

View file

@ -42,11 +42,10 @@ public sealed class UiScrollbar : UiElement
public Func<float?> ScalarFill { get; set; } = () => null; public Func<float?> ScalarFill { get; set; } = () => null;
public uint ScalarFillSprite { get; set; } public uint ScalarFillSprite { get; set; }
/// <summary> /// <summary>
/// Optional authored texture beneath the live scalar fill. gmCombatUI uses /// Optional authored texture beneath the live scalar fill. gmCombatUI's
/// element <c>0x100005EF</c> for the trained-Recklessness range. /// dark-red interior media comes from element <c>0x100005EF</c>.
/// </summary> /// </summary>
public uint ScalarRangeSprite { get; set; } public uint ScalarRangeSprite { get; set; }
public Func<bool> ScalarRangeVisible { get; set; } = () => false;
public float ScalarRangeLeft { get; set; } public float ScalarRangeLeft { get; set; }
public float ScalarRangeWidth { get; set; } = float.PositiveInfinity; public float ScalarRangeWidth { get; set; } = float.PositiveInfinity;
/// <summary> /// <summary>
@ -139,7 +138,7 @@ public sealed class UiScrollbar : UiElement
{ {
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
(float rangeLeft, float rangeWidth) = ScalarRangeRect(); (float rangeLeft, float rangeWidth) = ScalarRangeRect();
if (ScalarRangeVisible() && ScalarRangeSprite != 0) if (ScalarRangeSprite != 0)
DrawTiled(ctx, resolve, ScalarRangeSprite, DrawTiled(ctx, resolve, ScalarRangeSprite,
rangeLeft, 0f, rangeWidth, Height); rangeLeft, 0f, rangeWidth, Height);
if (ScalarFill() is float fill && ScalarFillSprite != 0) if (ScalarFill() is float fill && ScalarFillSprite != 0)

View file

@ -28,7 +28,7 @@ public sealed class CombatLayoutConformanceTests
Assert.Equal(0x06001923u, power.ThumbSprite); Assert.Equal(0x06001923u, power.ThumbSprite);
Assert.Equal(0x06001200u, power.ScalarFillSprite); Assert.Equal(0x06001200u, power.ScalarFillSprite);
Assert.Equal(0x0600715Eu, power.ScalarRangeSprite); Assert.Equal(0x0600715Eu, power.ScalarRangeSprite);
Assert.True(power.ScalarFillFromRight); Assert.False(power.ScalarFillFromRight);
var repeat = Assert.IsType<UiButton>( var repeat = Assert.IsType<UiButton>(
layout.FindElement(CombatUiController.RepeatAttacksId)); layout.FindElement(CombatUiController.RepeatAttacksId));

View file

@ -21,7 +21,6 @@ public sealed class CombatUiControllerTests
GameplaySettings gameplay = GameplaySettings.Default; GameplaySettings gameplay = GameplaySettings.Default;
using var controller = CombatUiController.Bind( using var controller = CombatUiController.Bind(
layout, combat, attacks, () => gameplay, value => gameplay = value, layout, combat, attacks, () => gameplay, value => gameplay = value,
() => false,
Labels, visibility.Add)!; Labels, visibility.Add)!;
controller.SyncVisibility(); controller.SyncVisibility();
@ -46,14 +45,12 @@ public sealed class CombatUiControllerTests
GameplaySettings gameplay = GameplaySettings.Default; GameplaySettings gameplay = GameplaySettings.Default;
using var controller = CombatUiController.Bind( using var controller = CombatUiController.Bind(
layout, combat, attacks, () => gameplay, value => gameplay = value, layout, combat, attacks, () => gameplay, value => gameplay = value,
() => true,
Labels, _ => { })!; Labels, _ => { })!;
combat.SetCombatMode(CombatMode.Melee); combat.SetCombatMode(CombatMode.Melee);
high.OnEvent(new UiEvent(0, high, UiEventType.MouseDown, Data1: 2, Data2: 2)); high.OnEvent(new UiEvent(0, high, UiEventType.MouseDown, Data1: 2, Data2: 2));
now = 4.5d; now = 4.5d;
Assert.Equal(0.5f, power.ScalarFill()!.Value, 3); Assert.Equal(0.5f, power.ScalarFill()!.Value, 3);
Assert.True(power.ScalarRangeVisible());
Assert.Equal(104f, power.ScalarRangeLeft, 3); Assert.Equal(104f, power.ScalarRangeLeft, 3);
Assert.Equal(299f, power.ScalarRangeWidth, 3); Assert.Equal(299f, power.ScalarRangeWidth, 3);
high.OnEvent(new UiEvent(0, high, UiEventType.MouseUp, Data1: 2, Data2: 2)); high.OnEvent(new UiEvent(0, high, UiEventType.MouseUp, Data1: 2, Data2: 2));
@ -74,7 +71,6 @@ public sealed class CombatUiControllerTests
GameplaySettings gameplay = GameplaySettings.Default; GameplaySettings gameplay = GameplaySettings.Default;
using var controller = CombatUiController.Bind( using var controller = CombatUiController.Bind(
layout, combat, attacks, () => gameplay, value => gameplay = value, layout, combat, attacks, () => gameplay, value => gameplay = value,
() => false,
Labels, _ => { })!; Labels, _ => { })!;
var autoTarget = Assert.IsType<UiButton>(layout.FindElement(CombatUiController.AutoTargetId)); var autoTarget = Assert.IsType<UiButton>(layout.FindElement(CombatUiController.AutoTargetId));
@ -94,7 +90,7 @@ public sealed class CombatUiControllerTests
GameplaySettings gameplay = GameplaySettings.Default; GameplaySettings gameplay = GameplaySettings.Default;
using var controller = CombatUiController.Bind( using var controller = CombatUiController.Bind(
layout, combat, attacks, () => gameplay, value => gameplay = value, layout, combat, attacks, () => gameplay, value => gameplay = value,
() => true, Labels, _ => { })!; Labels, _ => { })!;
var speed = Assert.IsType<UiText>(layout.FindElement(CombatUiController.SpeedLabelId)); var speed = Assert.IsType<UiText>(layout.FindElement(CombatUiController.SpeedLabelId));
var powerLabel = Assert.IsType<UiText>(layout.FindElement(CombatUiController.PowerLabelId)); var powerLabel = Assert.IsType<UiText>(layout.FindElement(CombatUiController.PowerLabelId));

View file

@ -485,6 +485,10 @@ public class DatWidgetFactoryTests
Height = 14, Height = 14,
Children = [fill, range], Children = [fill, range],
}; };
var meterState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
meterState.Properties.Values[0x6Fu] = new UiPropertyValue
{ Kind = UiPropertyKind.Enum, UnsignedValue = 1u };
meter.States[UiStateInfo.DirectStateId] = meterState;
var info = new ElementInfo var info = new ElementInfo
{ {
Id = CombatUiController.PowerControlId, Id = CombatUiController.PowerControlId,
@ -502,7 +506,7 @@ public class DatWidgetFactoryTests
Assert.Equal(0x06001923u, bar.ThumbSprite); Assert.Equal(0x06001923u, bar.ThumbSprite);
Assert.Equal(0x06001200u, bar.ScalarFillSprite); Assert.Equal(0x06001200u, bar.ScalarFillSprite);
Assert.Equal(0x0600715Eu, bar.ScalarRangeSprite); Assert.Equal(0x0600715Eu, bar.ScalarRangeSprite);
Assert.True(bar.ScalarFillFromRight); Assert.False(bar.ScalarFillFromRight);
} }
private static ElementInfo TextInfo(params (uint Id, UiPropertyValue Value)[] properties) private static ElementInfo TextInfo(params (uint Id, UiPropertyValue Value)[] properties)

View file

@ -104,26 +104,26 @@ public class UiScrollbarTests
} }
[Theory] [Theory]
[InlineData(0f, 100f, 0f)] [InlineData(0f, 0f, 0f)]
[InlineData(0.5f, 50f, 50f)] [InlineData(0.5f, 0f, 50f)]
[InlineData(1f, 0f, 100f)] [InlineData(1f, 0f, 100f)]
public void ScalarFillRect_CombatPower_GrowsRightToLeft( public void ScalarFillRect_CombatPower_GrowsLeftToRight(
float fill, float expectedX, float expectedWidth) float fill, float expectedX, float expectedWidth)
{ {
var (x, width) = UiScrollbar.ScalarFillRect(100f, fill, fromRight: true); var (x, width) = UiScrollbar.ScalarFillRect(100f, fill, fromRight: false);
Assert.Equal(expectedX, x, 3); Assert.Equal(expectedX, x, 3);
Assert.Equal(expectedWidth, width, 3); Assert.Equal(expectedWidth, width, 3);
} }
[Theory] [Theory]
[InlineData(0f, 403f, 0f)] [InlineData(0f, 104f, 0f)]
[InlineData(0.5f, 253.5f, 149.5f)] [InlineData(0.5f, 104f, 149.5f)]
[InlineData(1f, 104f, 299f)] [InlineData(1f, 104f, 299f)]
public void ScalarFillRect_CombatPower_StaysBetweenAuthoredLabels( public void ScalarFillRect_CombatPower_StaysBetweenAuthoredLabels(
float fill, float expectedX, float expectedWidth) float fill, float expectedX, float expectedWidth)
{ {
var (x, width) = UiScrollbar.ScalarFillRect( var (x, width) = UiScrollbar.ScalarFillRect(
rangeLeft: 104f, rangeWidth: 299f, fill, fromRight: true); rangeLeft: 104f, rangeWidth: 299f, fill, fromRight: false);
Assert.Equal(expectedX, x, 3); Assert.Equal(expectedX, x, 3);
Assert.Equal(expectedWidth, width, 3); Assert.Equal(expectedWidth, width, 3);
} }