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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 09:55:20 +02:00
parent 1be18cc4db
commit edc9be3008
11 changed files with 208 additions and 16 deletions

View file

@ -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.
---

View file

@ -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` |

View file

@ -482,6 +482,7 @@ behavior. Estimated 1726 days focused work, 35 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 19** (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 <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` 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.

View file

@ -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.

View file

@ -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 05) ──────────────────────────

View file

@ -7,7 +7,7 @@ using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
/// <summary>
/// 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
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// Retail <c>gmPaperDollUI::UpdateAetheria @ 0x004A3E50</c>: 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.
/// </summary>
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)

View file

@ -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),
};
/// <summary>Resolves every supported slot's exact empty RenderSurface from the live DAT.</summary>

View file

@ -0,0 +1,33 @@
using System;
namespace AcDream.Core.Items;
/// <summary>
/// Player <c>PropertyInt.AetheriaBitfield (322 / 0x142)</c>. Each bit independently
/// unlocks one paperdoll sigil location after its corresponding retail quest.
/// </summary>
/// <remarks>
/// Retail: <c>gmPaperDollUI::UpdateAetheria @ 0x004A3E50</c>.
/// ACE: <c>PropertyInt.AetheriaBitfield</c> + <c>AetheriaBitfield</c>.
/// </remarks>
[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;
}
}

View file

@ -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<uint, UiItemList> 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<uint, UiItemList>();
var byId = new Dictionary<uint, UiElement>();
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
{

View file

@ -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,

View file

@ -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);
}
}