From edc9be3008314c877862f9ffced8b9103b00494f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Jul 2026 09:55:20 +0200 Subject: [PATCH] fix(paperdoll): port quest-gated Aetheria slots Add the three authored blue, yellow, and red sigil backgrounds and drive their visibility from player PropertyInt.AetheriaBitfield bits 1/2/4 at login and on live updates, matching gmPaperDollUI::UpdateAetheria. Include the sigil equip masks in the shared slot table and narrow AP-108. Co-Authored-By: Codex --- docs/ISSUES.md | 6 +- .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 1 + ...l-paperdoll-slot-backgrounds-pseudocode.md | 29 +++++-- src/AcDream.App/Studio/SampleData.cs | 5 ++ .../UI/Layout/PaperdollController.cs | 32 ++++++-- .../UI/Layout/PaperdollSlotBackgrounds.cs | 6 +- src/AcDream.Core/Items/AetheriaUnlockState.cs | 33 ++++++++ .../UI/Layout/PaperdollControllerTests.cs | 75 ++++++++++++++++++- .../UI/Layout/PaperdollSlotBackgroundTests.cs | 6 ++ .../Items/AetheriaUnlockStateTests.cs | 29 +++++++ 11 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 src/AcDream.Core/Items/AetheriaUnlockState.cs create mode 100644 tests/AcDream.Core.Tests/Items/AetheriaUnlockStateTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 62a9ca7a..ee0d46c9 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -6905,7 +6905,7 @@ The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5) inst ## Inventory + equipment slots show the wrong empty-slot background art -**Status:** IMPLEMENTATION COMPLETE 2026-07-13; PAPERDOLL LIVE VISUAL GATE PENDING. Inventory contents/containers were visually confirmed 2026-06-22. The paperdoll now resolves all 21 supported location-specific backgrounds from the authored UIItem catalog instead of painting the generic inventory frame. The main-pack icon and selected-container indicators were completed in their later sub-phases. +**Status:** CORE PAPERDOLL BACKGROUNDS VISUALLY CONFIRMED 2026-07-13; AETHERIA EXTENSION IMPLEMENTED, LIVE GATE PENDING. Inventory contents/containers were visually confirmed 2026-06-22. The paperdoll now resolves all 24 location-specific backgrounds from the authored UIItem catalog instead of painting the generic inventory frame. The main-pack icon and selected-container indicators were completed in their later sub-phases. **Filed:** 2026-06-21 **Component:** ui — D.2b inventory (UiItemSlot empty sprite + paperdoll equip slots) @@ -6917,11 +6917,13 @@ The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5) inst **Resolution (paperdoll portion, 2026-07-13):** Re-read named retail `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`, `PostInit @ 0x004A5360`, and `UIElement_ItemList::InternalCreateItem @ 0x004E3570`, then enumerated the live `0x21000037` catalog. `PaperdollSlotBackgrounds` is now the single element/location/prototype definition table; runtime and UI Studio resolve the exact 21 authored surfaces through the existing inheritance-aware `FindIconEmpty` path. Real-DAT tests pin every prototype and RenderSurface. AP-66 retired. Research/pseudocode: `docs/research/2026-07-13-retail-paperdoll-slot-backgrounds-pseudocode.md`. +**Aetheria extension (2026-07-13):** The first visual gate passed for the original 21 locations and exposed the intentionally deferred Aetheria row. The same definition table now includes sigil elements `0x10000595..97`, equip masks `0x10000000/0x20000000/0x40000000`, prototypes `0x10000592..94`, and exact blue/yellow/red surfaces `0x06006BEF..F1`. Visibility ports `gmPaperDollUI::UpdateAetheria @ 0x004A3E50`: player `PropertyInt.AetheriaBitfield (322)` bits 1/2/4 independently expose the three slots at login and on live `0x02CD` updates. A missing property hides all three. AP-108 narrowed. + **Files:** `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs`; `InventoryController.cs`; `PaperdollSlotBackgrounds.cs`; `PaperdollController.cs`. **Research:** `.layout-dumps/uiitem-0x21000037.txt`; `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`; `docs/research/2026-07-13-retail-paperdoll-slot-backgrounds-pseudocode.md`. -**Acceptance:** empty inventory + side-bag cells show the retail pack-slot frame; equip slots show per-slot silhouettes. +**Acceptance:** empty inventory + side-bag cells show the retail pack-slot frame; all 24 equip locations show their authored backgrounds; Aetheria slots remain hidden until their individual unlock bits arrive. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 59863544..6ab3a371 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -210,7 +210,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | ~~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/AutoWield still omit 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`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots and live doll work; inventory double-click now faithfully sequences the primary weapon, incompatible shield, and mismatched ammo blockers through server-confirmed dequip→wield in both peace and war | Special slots, illegal/off-hand cases, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | +| AP-108 | Paperdoll/AutoWield still omit full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, and body-part selection lighting. **Aetheria retired from this row 2026-07-13:** all three sigil slots use exact backgrounds/equip masks and live `PropertyInt.AetheriaBitfield` visibility. | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, and live doll work; inventory double-click faithfully sequences the primary weapon, incompatible shield, and mismatched ammo blockers through server-confirmed dequip→wield in both peace and war | Remaining illegal/off-hand cases, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | | 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-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` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 0a54c20f..50107922 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -482,6 +482,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2`→`0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 1–9** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject` → `0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay` → `ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout. - **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`. - **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, inventory empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving, including retail select-before-waiting direct-drag order; **visually confirmed 2026-07-13**, `609ed8bf`) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim) + exact per-location empty backgrounds implemented 2026-07-13 (live gate pending; AP-66 retired)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅, **Wave 3.1 unified selection** (Core `SelectionState`; AP-58 retired) ✅, and **Wave 3.2 pure item-use/drop policy** (`ItemInteractionPolicy`; AP-99 retired) ✅ are shipped. Build order from here: **finish Wave 3 activation router/cursors, then D.5.3 selected-object mana + stack entry/slider and the separate favorite-spell bars; ISSUES #146/#147/#197.** Each ☐ below gets its own focused oracle/spec/plan/gate. +- **D.5 paperdoll background correction (2026-07-13):** the original 21 per-location backgrounds passed the live visual gate. The three Aetheria locations are now also implemented with exact blue/yellow/red UIItem prototypes and independent player `AetheriaBitfield (322)` login/live visibility; their corrective live gate is pending. AP-66 retired and Aetheria removed from AP-108. - **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio [--layout 0xNNNN | --dump ] [--screenshot ]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed. - **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct. - **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained matrix and live ACE-provisioned healing gate user-confirmed. The 3-D doll routes through authored hit mask `0x100001D6` (resolved `UiButton` above viewport `0x100001D5`), and local health prefers authoritative private-vital state. diff --git a/docs/research/2026-07-13-retail-paperdoll-slot-backgrounds-pseudocode.md b/docs/research/2026-07-13-retail-paperdoll-slot-backgrounds-pseudocode.md index 756776bd..370d237e 100644 --- a/docs/research/2026-07-13-retail-paperdoll-slot-backgrounds-pseudocode.md +++ b/docs/research/2026-07-13-retail-paperdoll-slot-backgrounds-pseudocode.md @@ -2,8 +2,8 @@ Date: 2026-07-13 -Scope: the empty-cell art for the 21 currently supported equipment lists in -`gmPaperDollUI`. This corrects the earlier visual inference that the slots had no +Scope: the empty-cell art for all 24 equipment lists in `gmPaperDollUI`, including +the three quest-unlocked Aetheria sigil locations. This corrects the earlier visual inference that the slots had no per-location art. The paperdoll layout elements themselves inherit the same ItemList base, but retail creates their cells from distinct `UIElement_UIItem` prototypes in the shared catalog `0x21000037`. @@ -72,6 +72,9 @@ UIItem empty draw: | `0x100001E3` | UpperLegWear | `0x1000044F` | `0x060032C4` | | `0x1000058E` | TrinketOne | `0x1000058F` | `0x06006A6C` | | `0x100005E9` | Cloak | `0x100005EA` | `0x0600708F` | +| `0x10000595` | SigilOne / blue | `0x10000592` | `0x06006BEF` | +| `0x10000596` | SigilTwo / yellow | `0x10000593` | `0x06006BF0` | +| `0x10000597` | SigilThree / red | `0x10000594` | `0x06006BF1` | | `0x100005AB` | HeadWear | `0x100005B4` | `0x06006D7F` | | `0x100005AC` | ChestArmor | `0x100005B5` | `0x06006D7B` | | `0x100005AD` | AbdomenArmor | `0x100005B6` | `0x06006D79` | @@ -82,9 +85,23 @@ UIItem empty draw: | `0x100005B2` | LowerLegArmor | `0x100005BB` | `0x06006D83` | | `0x100005B3` | FootWear | `0x100005BD` | `0x06006D85` | -The three Aetheria lists remain outside the current controller scope (AP-108). Their -authored backgrounds are blue/yellow/red prototypes `0x10000592..94`, but this fix -does not make those otherwise unimplemented slots reachable. +## Aetheria unlock visibility + +`gmPaperDollUI::UpdateAetheria @ 0x004A3E50` reads player int quality `0x142` +(`PropertyInt.AetheriaBitfield`, 322). `RecvNotice_PlayerDescReceived @ 0x004A43E0` +applies the login value, and `OnQualityChanged @ 0x004A4490` reapplies it after a +live quest-completion update: + +```text +bits = player.intQuality(0x142, default=0) +blue.visible = (bits & 1) != 0 +yellow.visible = (bits & 2) != 0 +red.visible = (bits & 4) != 0 +``` + +ACE independently confirms the property is `[SendOnLogin] AetheriaBitfield = 322` +and defines the same Blue=1, Yellow=2, Red=4 flags. The existing generic private +PropertyInt wire path (`0x02CD`) already stores the live value on the player object. ## Port mapping @@ -94,7 +111,7 @@ does not make those otherwise unimplemented slots reachable. - `ItemListCellTemplate.ResolvePrototypeEmptySprite` reuses the already-shipped inheritance-aware `ItemSlot_Empty` resolver instead of hardcoding surface IDs in the controller. -- `RetailUiRuntime` and UI Studio resolve the 21 surfaces from the live DAT once at +- `RetailUiRuntime` and UI Studio resolve the 24 surfaces from the live DAT once at bind time and inject them into the controller. A missing authored surface is a startup error rather than a silently generic slot; the old inventory-grid sprite remains only for isolated controller bindings that do not supply the paperdoll catalog map. diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs index 396c322c..f70c468c 100644 --- a/src/AcDream.App/Studio/SampleData.cs +++ b/src/AcDream.App/Studio/SampleData.cs @@ -83,6 +83,11 @@ public static class SampleData ItemsCapacity = 102, ContainersCapacity = 7, }); + // Show all three authored sigil locations in inventory/paperdoll previews. + t.UpdateIntProperty( + PlayerGuid, + AetheriaUnlocks.PropertyId, + (int)AetheriaUnlockState.All); // ── Loose items in main pack (slots 0–5) ────────────────────────── diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index 301755ab..514a0766 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -7,7 +7,7 @@ using AcDream.Core.Selection; namespace AcDream.App.UI.Layout; /// -/// Binds the ~21 equip slots mounted under the paperdoll (gmPaperDollUI 0x21000024, nested in the +/// Binds the 24 equip slots mounted under the paperdoll (gmPaperDollUI 0x21000024, nested in the /// inventory frame 0x21000023) to live equipped-item data and makes them drag-drop WIELD targets. /// The acdream analogue of gmPaperDollUI::PostInit + GetLocationInfoFromElementID (named-retail decomp /// 175480 / 173620). Slice 1: equip slots only — no 3D doll viewport (that's Slice 2). @@ -22,8 +22,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo /// /// The 9 armor-slot element-ids whose Visible state the Slots button (0x100005BE) toggles. /// Doll-view: hidden. Slot-view: shown. Source: gmPaperDollUI::ListenToElementMessage decomp - /// 175674-175706 — these are the only 9 ids that element flips; the other ~12 equip slots - /// (jewelry, weapon, wrists, feet…) are NON-armor and remain visible in both views. + /// 175674-175706 — these are the only 9 ids that element flips. The 12 ordinary non-armor + /// lists remain visible in both views; the three Aetheria lists are independently unlock-gated. /// public static readonly uint[] ArmorSlotElementIds = { @@ -53,6 +53,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo private readonly SelectionState _selection; private readonly PaperdollClickMap? _clickMap; private readonly List<(EquipMask Mask, UiItemList List)> _slots = new(); + private readonly List<(AetheriaUnlockState Bit, UiItemList List)> _aetheriaSlots = new(); // ── Slots-toggle state ──────────────────────────────────────────────────────────────────────── private readonly PaperdollViewState _viewState = new(); @@ -78,7 +79,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo for (int i = 0; i < PaperdollSlotBackgrounds.Definitions.Length; i++) { - var (element, mask, _) = PaperdollSlotBackgrounds.Definitions[i]; + var (element, mask, _, unlockBit) = PaperdollSlotBackgrounds.Definitions[i]; if (layout.FindElement(element) is not UiItemList list) continue; list.RegisterDragHandler(this); list.Cell.SourceKind = ItemDragSource.Equipment; @@ -108,6 +109,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo // insert-arrow 0x060011F7) are already wired by DatWidgetFactory when it built the UiItemList; // no need to re-set them here. _slots.Add((mask, list)); + if (unlockBit != AetheriaUnlockState.None) + _aetheriaSlots.Add((unlockBit, list)); } _objects.ObjectAdded += OnObjectChanged; @@ -198,7 +201,13 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo _selection.Select(hitObject, SelectionChangeSource.Paperdoll); } - private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } + private void OnObjectChanged(ClientObject o) + { + if (o.ObjectId == _playerGuid()) + ApplyAetheriaVisibility(); + else if (Concerns(o)) + Populate(); + } private void OnObjectMoved(ClientObject o, uint from, uint to) { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators(); @@ -238,9 +247,22 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects) ?? 0u; list.Cell.SetItem(worn.ObjectId, tex, dragIconTexture: dragTex); } + ApplyAetheriaVisibility(); ApplySelectionIndicators(); } + /// + /// Retail gmPaperDollUI::UpdateAetheria @ 0x004A3E50: property 0x142 + /// bits 1/2/4 independently expose the blue/yellow/red sigil lists. Missing + /// player state is the locked state, matching PostInit before PlayerDescription. + /// + private void ApplyAetheriaVisibility() + { + AetheriaUnlockState unlocked = AetheriaUnlocks.Read(_objects.Get(_playerGuid())); + foreach (var (bit, list) in _aetheriaSlots) + list.Visible = (unlocked & bit) != AetheriaUnlockState.None; + } + private void ApplySelectionIndicators() { foreach (var (_, list) in _slots) diff --git a/src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs b/src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs index eba93ada..ced31d90 100644 --- a/src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs +++ b/src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs @@ -20,7 +20,8 @@ public static class PaperdollSlotBackgrounds internal readonly record struct Definition( uint Element, EquipMask Mask, - uint EmptyPrototype); + uint EmptyPrototype, + AetheriaUnlockState UnlockBit = AetheriaUnlockState.None); // WEAPON_READY_SLOT_LOC (acclient.h:3235): any wieldable weapon in the hand slot. private const EquipMask WeaponSlotMask = @@ -49,6 +50,9 @@ public static class PaperdollSlotBackgrounds new(0x100001DFu, WeaponSlotMask, 0x1000044Bu), new(0x1000058Eu, EquipMask.TrinketOne, 0x1000058Fu), new(0x100005E9u, EquipMask.Cloak, 0x100005EAu), + new(0x10000595u, EquipMask.SigilOne, 0x10000592u, AetheriaUnlockState.Blue), + new(0x10000596u, EquipMask.SigilTwo, 0x10000593u, AetheriaUnlockState.Yellow), + new(0x10000597u, EquipMask.SigilThree, 0x10000594u, AetheriaUnlockState.Red), }; /// Resolves every supported slot's exact empty RenderSurface from the live DAT. diff --git a/src/AcDream.Core/Items/AetheriaUnlockState.cs b/src/AcDream.Core/Items/AetheriaUnlockState.cs new file mode 100644 index 00000000..2897d822 --- /dev/null +++ b/src/AcDream.Core/Items/AetheriaUnlockState.cs @@ -0,0 +1,33 @@ +using System; + +namespace AcDream.Core.Items; + +/// +/// Player PropertyInt.AetheriaBitfield (322 / 0x142). Each bit independently +/// unlocks one paperdoll sigil location after its corresponding retail quest. +/// +/// +/// Retail: gmPaperDollUI::UpdateAetheria @ 0x004A3E50. +/// ACE: PropertyInt.AetheriaBitfield + AetheriaBitfield. +/// +[Flags] +public enum AetheriaUnlockState : uint +{ + None = 0x0, + Blue = 0x1, + Yellow = 0x2, + Red = 0x4, + All = Blue | Yellow | Red, +} + +public static class AetheriaUnlocks +{ + public const uint PropertyId = 0x142u; + + public static AetheriaUnlockState Read(ClientObject? player) + { + if (player?.Properties.Ints.TryGetValue(PropertyId, out int value) != true) + return AetheriaUnlockState.None; + return (AetheriaUnlockState)(uint)value; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index 4d6194c2..2c819607 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -17,12 +17,19 @@ public class PaperdollControllerTests private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000 private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000 private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000 + private const uint BlueAetheriaSlot = 0x10000595u; + private const uint YellowAetheriaSlot = 0x10000596u; + private const uint RedAetheriaSlot = 0x10000597u; private sealed class RootElement : UiElement { } private static (ImportedLayout layout, Dictionary lists) BuildLayout() { - var ids = new[] { HeadSlot, ChestSlot, ChestArmorSlot, ShieldSlot, WeaponSlot, FingerLSlot }; + var ids = new[] + { + HeadSlot, ChestSlot, ChestArmorSlot, ShieldSlot, WeaponSlot, FingerLSlot, + BlueAetheriaSlot, YellowAetheriaSlot, RedAetheriaSlot, + }; var lists = new Dictionary(); var byId = new Dictionary(); var root = new RootElement { Width = 224, Height = 214 }; @@ -241,6 +248,72 @@ public class PaperdollControllerTests Assert.Equal(sprite, lists[element].Cell.EmptySprite); } + [Fact] + public void Aetheria_slots_default_hidden_without_unlock_property() + { + var (layout, lists) = BuildLayout(); + + Bind(layout, new ClientObjectTable()); + + Assert.False(lists[BlueAetheriaSlot].Visible); + Assert.False(lists[YellowAetheriaSlot].Visible); + Assert.False(lists[RedAetheriaSlot].Visible); + } + + [Fact] + public void Aetheria_slots_follow_independent_player_unlock_bits_at_login() + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject { ObjectId = Player }); + objects.UpdateIntProperty( + Player, + AetheriaUnlocks.PropertyId, + (int)(AetheriaUnlockState.Blue | AetheriaUnlockState.Red)); + + Bind(layout, objects); + + Assert.True(lists[BlueAetheriaSlot].Visible); + Assert.False(lists[YellowAetheriaSlot].Visible); + Assert.True(lists[RedAetheriaSlot].Visible); + } + + [Fact] + public void Live_aetheria_property_update_unlocks_new_slots() + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject { ObjectId = Player }); + Bind(layout, objects); + + objects.UpdateIntProperty( + Player, + AetheriaUnlocks.PropertyId, + (int)(AetheriaUnlockState.Blue | AetheriaUnlockState.Yellow)); + + Assert.True(lists[BlueAetheriaSlot].Visible); + Assert.True(lists[YellowAetheriaSlot].Visible); + Assert.False(lists[RedAetheriaSlot].Visible); + } + + [Fact] + public void Unlocked_aetheria_slot_populates_its_equipped_sigil() + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject { ObjectId = Player }); + objects.UpdateIntProperty( + Player, + AetheriaUnlocks.PropertyId, + (int)AetheriaUnlockState.Blue); + SeedEquipped(objects, 0xA37u, EquipMask.SigilOne); + + Bind(layout, objects); + + Assert.True(lists[BlueAetheriaSlot].Visible); + Assert.Equal(0xA37u, lists[BlueAetheriaSlot].Cell.ItemId); + } + [Fact] public void Live_player_wield_repaints_the_slot() // event-driven: ObjectMoved → Concerns → Populate { diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollSlotBackgroundTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollSlotBackgroundTests.cs index 910f44bc..d641952b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollSlotBackgroundTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollSlotBackgroundTests.cs @@ -24,6 +24,9 @@ public class PaperdollSlotBackgroundTests { 0x100001E3u, 0x1000044Fu }, // pants { 0x1000058Eu, 0x1000058Fu }, // trinket { 0x100005E9u, 0x100005EAu }, // cloak + { 0x10000595u, 0x10000592u }, // blue Aetheria + { 0x10000596u, 0x10000593u }, // yellow Aetheria + { 0x10000597u, 0x10000594u }, // red Aetheria { 0x100005ABu, 0x100005B4u }, // head { 0x100005ACu, 0x100005B5u }, // chest armor { 0x100005ADu, 0x100005B6u }, // abdomen armor @@ -67,6 +70,9 @@ public class PaperdollSlotBackgroundTests [0x100001E3u] = 0x060032C4u, [0x1000058Eu] = 0x06006A6Cu, [0x100005E9u] = 0x0600708Fu, + [0x10000595u] = 0x06006BEFu, + [0x10000596u] = 0x06006BF0u, + [0x10000597u] = 0x06006BF1u, [0x100005ABu] = 0x06006D7Fu, [0x100005ACu] = 0x06006D7Bu, [0x100005ADu] = 0x06006D79u, diff --git a/tests/AcDream.Core.Tests/Items/AetheriaUnlockStateTests.cs b/tests/AcDream.Core.Tests/Items/AetheriaUnlockStateTests.cs new file mode 100644 index 00000000..826eafb7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/AetheriaUnlockStateTests.cs @@ -0,0 +1,29 @@ +using AcDream.Core.Items; +using Xunit; + +namespace AcDream.Core.Tests.Items; + +public sealed class AetheriaUnlockStateTests +{ + [Fact] + public void Missing_property_means_all_slots_locked() + { + var player = new ClientObject { ObjectId = 1 }; + + Assert.Equal(AetheriaUnlockState.None, AetheriaUnlocks.Read(player)); + } + + [Fact] + public void Property_322_preserves_the_three_independent_retail_bits() + { + var player = new ClientObject { ObjectId = 1 }; + player.Properties.Ints[AetheriaUnlocks.PropertyId] = + (int)(AetheriaUnlockState.Blue | AetheriaUnlockState.Red); + + AetheriaUnlockState actual = AetheriaUnlocks.Read(player); + + Assert.True((actual & AetheriaUnlockState.Blue) != 0); + Assert.False((actual & AetheriaUnlockState.Yellow) != 0); + Assert.True((actual & AetheriaUnlockState.Red) != 0); + } +}