fix(studio): Task 4 — bind PaperdollController + wire empty-slot sprites

FixtureProvider.Populate for 0x21000023 was binding only
InventoryController and omitting PaperdollController, so the
~21 equip slots rendered empty even though sample equipped items
existed in the table.  Also, the three per-list empty-slot
sprites were not being resolved from the dat (contentsEmpty /
sideBagEmpty / mainPackEmpty all defaulted to 0), so the slot cells
had no background art.

Fixes:
- FixtureProvider.Populate gains a DatCollection dats param
  (mirrors the GameWindow.OnLoad lookup at line 2233-2235).
  The 0x21000023 case now resolves all three empty sprites via
  ItemListCellTemplate.ResolveEmptySprite and passes them to
  InventoryController.Bind.
- PaperdollController.Bind is now called after InventoryController
  in the same 0x21000023 case, matching GameWindow:2257-2265
  (same layout subtree, contentsEmpty as the equip-slot placeholder,
  sendWield: null since there is no live session in the studio).
- StudioWindow.OnLoad passes _dats! to the updated Populate signature.

SampleData.AddEquipped was already correct: MoveItem(guid, PlayerGuid,
-1, equipMask) at ClientObjectTable.cs:134 sets
CurrentlyEquippedLocation = newEquipLocation and calls Reindex which
places the item in _containerIndex[PlayerGuid].  No SampleData change
needed.

Tests: 2 new assertions in FixtureProviderTests —
  sideBags_matchInventoryControllerFilter (Type.HasFlag(Container)
  || ItemsCapacity > 0 filter must match both bags) and
  equippedItems_retainLocationAndAreInContents (equipped items have
  CurrentlyEquippedLocation != None AND appear in GetContents so the
  paperdoll controller can find them).  604 pass / 2 skip / 0 fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 15:59:13 +02:00
parent 0bdc866c15
commit 9ed9d8dbd9
3 changed files with 92 additions and 7 deletions

View file

@ -3,6 +3,7 @@ using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using DatReaderWriter;
namespace AcDream.App.Studio;
@ -41,11 +42,14 @@ public static class FixtureProvider
/// and <see cref="RenderStack.VitalsDatFont"/>).</param>
/// <param name="objects">A <see cref="ClientObjectTable"/> already seeded by
/// <see cref="SampleData.BuildObjectTable"/>.</param>
/// <param name="dats">The live DAT collection used to resolve per-list empty-slot sprites
/// (same lookup GameWindow.OnLoad performs for the production binding).</param>
public static void Populate(
uint layoutId,
ImportedLayout layout,
RenderStack stack,
ClientObjectTable objects)
ClientObjectTable objects,
DatCollection dats)
{
switch (layoutId)
{
@ -74,15 +78,41 @@ public static class FixtureProvider
sendRemoveShortcut: null);
break;
case 0x21000023u: // inventory
case 0x21000023u: // inventory + paperdoll
{
// Resolve the per-list empty-slot art from the dat cell template, matching the
// exact lookup GameWindow.OnLoad performs (UIElement_ItemList::InternalCreateItem
// 0x004e3570 → attr 0x1000000e → catalog 0x21000037 → ItemSlot_Empty).
uint contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000021u, 0x100001C6u);
uint sideBagEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001CAu);
uint mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001C9u);
var iconIds = MakeIconIds(stack);
InventoryController.Bind(
layout,
objects,
playerGuid: () => SampleData.PlayerGuid,
iconIds: MakeIconIds(stack),
strength: () => 100,
datFont: stack.VitalsDatFont);
playerGuid: () => SampleData.PlayerGuid,
iconIds: iconIds,
strength: () => 100,
datFont: stack.VitalsDatFont,
contentsEmptySprite: contentsEmpty,
sideBagEmptySprite: sideBagEmpty,
mainPackEmptySprite: mainPackEmpty);
// Bind the paperdoll equip slots (same imported subtree as the inventory).
// Mirrors GameWindow:2257-2265: PaperdollController.Bind with contentsEmpty as
// emptySlotSprite (each slot shows the same square-frame placeholder as the grid).
PaperdollController.Bind(
layout,
objects,
playerGuid: () => SampleData.PlayerGuid,
iconIds: iconIds,
sendWield: null, // no live session in the studio
emptySlotSprite: contentsEmpty,
datFont: stack.VitalsDatFont);
break;
}
default:
// Unknown layout — no-op; the panel renders structurally.

View file

@ -130,7 +130,7 @@ public sealed class StudioWindow : IDisposable
{
uint layoutId = _opts.LayoutId ?? 0x2100006Cu;
_objects = SampleData.BuildObjectTable();
FixtureProvider.Populate(layoutId, _source.CurrentLayout, _stack, _objects);
FixtureProvider.Populate(layoutId, _source.CurrentLayout, _stack, _objects, _dats);
}
}
else

View file

@ -102,4 +102,59 @@ public class FixtureProviderTests
Assert.True(bagCount >= 2,
$"Expected >= 2 side-bag containers in player pack; got {bagCount}");
}
/// <summary>
/// Side bags must match the InventoryController filter: ItemType.Container OR ItemsCapacity &gt; 0.
/// This ensures they appear in the side-bag column even if the exact Type flag changes.
/// </summary>
[Fact]
public void SampleTable_sideBags_matchInventoryControllerFilter()
{
var t = SampleData.BuildObjectTable();
int bagCount = 0;
foreach (var guid in t.GetContents(SampleData.PlayerGuid))
{
var obj = t.Get(guid);
if (obj is null) continue;
// InventoryController.Populate line ~203: Type.HasFlag(Container) OR ItemsCapacity > 0
bool isBag = obj.Type.HasFlag(ItemType.Container) || obj.ItemsCapacity > 0;
if (isBag) bagCount++;
}
Assert.True(bagCount >= 2,
$"Expected >= 2 items matching the side-bag filter; got {bagCount}");
}
/// <summary>
/// Equipped items must BOTH (a) retain CurrentlyEquippedLocation != None after
/// seeding AND (b) appear in GetContents(PlayerGuid) so PaperdollController.Populate
/// can find them. These are the two conditions PaperdollController checks on every
/// equipped item (PaperdollController.Populate: ContainerId == playerGuid AND
/// CurrentlyEquippedLocation != None).
/// </summary>
[Fact]
public void SampleTable_equippedItems_retainLocationAndAreInContents()
{
var t = SampleData.BuildObjectTable();
var contents = new System.Collections.Generic.HashSet<uint>(t.GetContents(SampleData.PlayerGuid));
int equippedCount = 0;
foreach (var obj in t.Objects)
{
if (obj.CurrentlyEquippedLocation == EquipMask.None) continue;
equippedCount++;
// Must appear in GetContents so the controller can pick them up.
Assert.True(contents.Contains(obj.ObjectId),
$"Equipped item 0x{obj.ObjectId:X8} ('{obj.Name}', loc={obj.CurrentlyEquippedLocation}) " +
$"is NOT in GetContents(PlayerGuid). PaperdollController will miss it.");
// The equip location must survive the MoveItem call.
Assert.NotEqual(EquipMask.None, obj.CurrentlyEquippedLocation);
}
Assert.True(equippedCount >= 3,
$"Expected >= 3 equipped items in sample table; got {equippedCount}");
}
}