The paperdoll doll now matches retail: correct held pose, framing, and facing. Three decomp-sourced fixes closed the visual gate. Pose: cdb-confirmed m_didAnimation = 0x030003C0 (gmPaperDollUI), played once + HELD (set_sequence_animation framerate=0, RedressCreature 0x004a3c22). Dumping the dat showed the 29-frame anim has only two distinct keyframes — frame 0 (transitional, bent arm) and frames 1..28 (byte-identical: the settled stance, arms down + leg back) — so ApplyPaperdollPose applies the LAST frame statically (no looping). Camera: ported verbatim from UIElement_Viewport::SetCamera (decomp 0x004a5a39). position (0.12,-2.4,0.88); direction (0,0,0) => IDENTITY view frame => look straight down +Y, ZERO yaw; FOV pi/4 (CreatureMode ctor default 0x004543cf); ambient 0.3. The prior hand-tune aimed the camera at mid-body, adding a ~2deg yaw that turned the doll's face away — full-body framing comes from eye-height + FOV, not aiming. Heading: retail Frame::set_heading(h) (0x00535e40) builds facing (sin h, cos h); System.Numerics CreateFromAxisAngle(+Z, +h) rotates the body's default +Y forward to (-sin h, cos h) — the X-lean was MIRRORED (~22deg), the real cause of the turned-away face. Negate the angle to land on retail's facing. Wrap-up: stripped the temporary O/P pose-frame stepper + Slice2 diagnostics; divergence register AP-66 reworded, AP-67 (RTT doll render vs in-cell CreatureMode::Render) + AP-68 (per-race UpdateForRace unimpl) added; DollCameraTests pinned to the retail values + a zero-yaw guard. tools/cdb/paperdoll-pose.cdb = the pose-DID capture script. Build + full suite green (Core 1579 / Core.Net 343 / App 597 / UI 425). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
237 lines
13 KiB
C#
237 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using AcDream.App.UI;
|
|
using AcDream.Core.Items;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Binds the ~21 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).
|
|
/// Unwield is handled by InventoryController (dragging an equipped item onto the pack grid).
|
|
/// </summary>
|
|
public sealed class PaperdollController : IItemListDragHandler
|
|
{
|
|
// ── Slots-toggle public surface ───────────────────────────────────────────────────────────────
|
|
/// <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.
|
|
/// </summary>
|
|
public static readonly uint[] ArmorSlotElementIds =
|
|
{
|
|
0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu,
|
|
0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u,
|
|
};
|
|
|
|
/// <summary>
|
|
/// View-state model for the Slots toggle (pure logic, no UI coupling).
|
|
/// Default is doll-view (SlotView == false): the 3-D character is visible,
|
|
/// the 9 armor slots are hidden. Calling Toggle() alternates between views.
|
|
/// </summary>
|
|
public sealed class PaperdollViewState
|
|
{
|
|
public bool SlotView { get; private set; } // false = doll-view (default)
|
|
public bool DollVisible => !SlotView;
|
|
public bool ArmorSlotsVisible => SlotView;
|
|
public void Toggle() => SlotView = !SlotView;
|
|
}
|
|
|
|
// WEAPON_READY_SLOT_LOC (acclient.h:3235): the weapon-hand doll slot accepts any wieldable weapon.
|
|
private const EquipMask WeaponSlotMask =
|
|
EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded;
|
|
|
|
// element-id → EquipMask (verified: dump paperdoll-0x21000024.txt ↔ deep-dive §3a ↔ acclient.h:3193).
|
|
// The 3 Aetheria sigil slots (0x10000595/96/97) are SetVisible(0) in retail — skipped (Slice 1 scope).
|
|
private static readonly (uint Element, EquipMask Mask)[] SlotMap =
|
|
{
|
|
(0x100005ABu, EquipMask.HeadWear), // 0x1
|
|
(0x100001E2u, EquipMask.ChestWear), // 0x2
|
|
(0x100001E3u, EquipMask.UpperLegWear), // 0x40
|
|
(0x100005B0u, EquipMask.HandWear), // 0x20
|
|
(0x100005B3u, EquipMask.FootWear), // 0x100
|
|
(0x100005ACu, EquipMask.ChestArmor), // 0x200
|
|
(0x100005ADu, EquipMask.AbdomenArmor), // 0x400
|
|
(0x100005AEu, EquipMask.UpperArmArmor), // 0x800
|
|
(0x100005AFu, EquipMask.LowerArmArmor), // 0x1000
|
|
(0x100005B1u, EquipMask.UpperLegArmor), // 0x2000
|
|
(0x100005B2u, EquipMask.LowerLegArmor), // 0x4000
|
|
(0x100001DAu, EquipMask.NeckWear), // 0x8000
|
|
(0x100001DBu, EquipMask.WristWearLeft), // 0x10000
|
|
(0x100001DDu, EquipMask.WristWearRight), // 0x20000
|
|
(0x100001DCu, EquipMask.FingerWearLeft), // 0x40000
|
|
(0x100001DEu, EquipMask.FingerWearRight), // 0x80000
|
|
(0x100001E1u, EquipMask.Shield), // 0x200000
|
|
(0x100001E0u, EquipMask.MissileAmmo), // 0x800000 (LIKELY — gate-verify, AP row)
|
|
(0x100001DFu, WeaponSlotMask), // 0x3500000 composite
|
|
(0x1000058Eu, EquipMask.TrinketOne), // 0x4000000
|
|
(0x100005E9u, EquipMask.Cloak), // 0x8000000
|
|
};
|
|
|
|
private readonly ClientObjectTable _objects;
|
|
private readonly Func<uint> _playerGuid;
|
|
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
|
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
|
|
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
|
|
|
|
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
|
|
private readonly PaperdollViewState _viewState = new();
|
|
private readonly List<UiItemList> _armorSlots = new();
|
|
private UiElement? _dollViewport; // UiViewport wired in Slice 3; UiElement? keeps this task independent
|
|
|
|
private PaperdollController(
|
|
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
|
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
|
|
uint emptySlotSprite, UiDatFont? datFont)
|
|
{
|
|
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
|
|
|
|
for (int i = 0; i < SlotMap.Length; i++)
|
|
{
|
|
var (element, mask) = SlotMap[i];
|
|
if (layout.FindElement(element) is not UiItemList list) continue;
|
|
list.RegisterDragHandler(this);
|
|
list.Cell.SourceKind = ItemDragSource.Equipment;
|
|
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
|
|
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
|
|
// 3D character (the "figure" — Slice 1/2 correction: NOT a per-slot
|
|
// silhouette) is the doll viewport, which arrives in Slice 2 with the Slots toggle.
|
|
// Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring
|
|
// 0x060011F9 / reject circle 0x060011F8 — the discrete-slot frames, NOT the inventory grid's
|
|
// insert-arrow 0x060011F7) are already wired by DatWidgetFactory when it built the UiItemList;
|
|
// no need to re-set them here.
|
|
_slots.Add((mask, list));
|
|
}
|
|
|
|
_objects.ObjectAdded += OnObjectChanged;
|
|
_objects.ObjectMoved += OnObjectMoved;
|
|
_objects.ObjectRemoved += OnObjectChanged;
|
|
_objects.ObjectUpdated += OnObjectChanged;
|
|
|
|
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
|
|
foreach (var id in ArmorSlotElementIds)
|
|
if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor);
|
|
|
|
_dollViewport = layout.FindElement(0x100001D5u); // doll viewport (may be null until Slice 3)
|
|
|
|
var slotsBtnEl = layout.FindElement(0x100005BEu);
|
|
if (slotsBtnEl is UiButton slotsBtn)
|
|
{
|
|
slotsBtn.OnClick = () => { _viewState.Toggle(); ApplyView(); };
|
|
// The dat element has no face sprite, so without a label the button is invisible. Give it a
|
|
// left-aligned WHITE "Slots" caption (retail is white, not gold) so it's findable + clickable.
|
|
if (datFont is not null)
|
|
{
|
|
slotsBtn.Label = "Slots";
|
|
slotsBtn.LabelFont = datFont;
|
|
slotsBtn.LabelColor = System.Numerics.Vector4.One; // white (was gold)
|
|
slotsBtn.LabelAlign = UiButton.LabelAlignment.Left; // sit at the left, before the slots
|
|
}
|
|
}
|
|
|
|
ApplyView(); // initial state = doll-view (armor slots hidden)
|
|
|
|
Populate();
|
|
}
|
|
|
|
public static PaperdollController Bind(
|
|
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
|
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null,
|
|
uint emptySlotSprite = 0u, UiDatFont? datFont = null)
|
|
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont);
|
|
|
|
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
|
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
|
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
|
|
|
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may
|
|
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
|
|
/// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always
|
|
/// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by
|
|
/// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved adds the from/to
|
|
/// player backstop for moves that transiently satisfy neither (e.g. unwield into a side bag).</summary>
|
|
private bool Concerns(ClientObject o)
|
|
{
|
|
uint p = _playerGuid();
|
|
return o.WielderId == p || o.ContainerId == p;
|
|
}
|
|
|
|
public void Populate()
|
|
{
|
|
uint p = _playerGuid();
|
|
|
|
// The player's equipped gear (one pass). Scoped to the player so a vendor's/NPC's wielded item
|
|
// (which can also carry CurrentWieldedLocation) can't leak into the doll.
|
|
var equipped = new List<ClientObject>();
|
|
foreach (var o in _objects.Objects)
|
|
if (o.CurrentlyEquippedLocation != EquipMask.None && (o.WielderId == p || o.ContainerId == p))
|
|
equipped.Add(o);
|
|
|
|
foreach (var (mask, list) in _slots)
|
|
{
|
|
ClientObject? worn = null;
|
|
foreach (var o in equipped)
|
|
if ((o.CurrentlyEquippedLocation & mask) != EquipMask.None) { worn = o; break; }
|
|
|
|
if (worn is null) { list.Cell.Clear(); continue; }
|
|
uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects);
|
|
list.Cell.SetItem(worn.ObjectId, tex);
|
|
}
|
|
}
|
|
|
|
private EquipMask MaskFor(UiItemList list)
|
|
{
|
|
foreach (var (mask, l) in _slots) if (ReferenceEquals(l, list)) return mask;
|
|
return EquipMask.None;
|
|
}
|
|
|
|
// ── IItemListDragHandler ──────────────────────────────────────────────────────────────────────
|
|
/// <summary>No-op: a wielded item stays put until the server confirms (same as the inventory grid,
|
|
/// unlike the toolbar's remove-on-lift). Unwield happens on DROP onto the pack grid — InventoryController.</summary>
|
|
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
|
|
|
|
/// <summary>Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask).
|
|
/// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302).</summary>
|
|
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
|
{
|
|
var item = _objects.Get(payload.ObjId);
|
|
if (item is null) return false;
|
|
return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None;
|
|
}
|
|
|
|
/// <summary>Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A.
|
|
/// wieldMask = ValidLocations & slotMask resolves the specific bit (the composite weapon slot, the
|
|
/// chosen finger). Retail: gmPaperDollUI HandleDropRelease → GetAndWieldItem.</summary>
|
|
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
|
{
|
|
var item = _objects.Get(payload.ObjId);
|
|
if (item is null) return;
|
|
EquipMask wieldMask = item.ValidLocations & MaskFor(targetList);
|
|
if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected)
|
|
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
|
|
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes the current <see cref="PaperdollViewState"/> onto the live widgets:
|
|
/// hides/shows the doll viewport and each armor slot according to the toggle.
|
|
/// Called once at construction (doll-view default) and on every button click.
|
|
/// </summary>
|
|
private void ApplyView()
|
|
{
|
|
if (_dollViewport is not null) _dollViewport.Visible = _viewState.DollVisible;
|
|
foreach (var a in _armorSlots) a.Visible = _viewState.ArmorSlotsVisible;
|
|
}
|
|
|
|
/// <summary>Detach event handlers (idempotent).</summary>
|
|
public void Dispose()
|
|
{
|
|
_objects.ObjectAdded -= OnObjectChanged;
|
|
_objects.ObjectMoved -= OnObjectMoved;
|
|
_objects.ObjectRemoved -= OnObjectChanged;
|
|
_objects.ObjectUpdated -= OnObjectChanged;
|
|
}
|
|
}
|