feat(studio): FixtureProvider — sample data populates the 2-D panels

Task 4 of the UI Studio plan: adds SampleData (static ClientObjectTable
builder with a synthetic player, 6 loose items, 2 side bags, and 3 equipped
pieces) and FixtureProvider (switches on layoutId and calls the production
controller Bind methods — VitalsController, ToolbarController,
InventoryController — so the studio previews panels with plausible data
instead of empty widgets).

Icon ids approach: raw-resolve stub — resolves the base iconId via
RenderStack.ResolveChrome and returns the GL handle directly. This is v1
(single-layer icon); the full 5-layer IconComposer composite is a live-game
concern, not a layout-preview concern.

StudioWindow wires FixtureProvider.Populate after _source.Load; the
ClientObjectTable is stored on _objects so controller event subscriptions
(ObjectAdded/ObjectMoved) remain live for the window's lifetime.

4 new FixtureProviderTests (SampleTable_hasPackContents,
SampleTable_hasWeaponAndArmor, SampleTable_hasEquippedItems,
SampleTable_hasSideBags) — all pass. Full App suite: 602 passed / 2
skipped (pre-existing) / 0 failed. Build green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 15:45:11 +02:00
parent 101a35cc2d
commit 0bdc866c15
4 changed files with 402 additions and 0 deletions

View file

@ -0,0 +1,114 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
namespace AcDream.App.Studio;
/// <summary>
/// Populates a loaded panel with sample data by calling the production
/// controller Bind methods against <see cref="SampleData"/>.
///
/// <para>
/// The studio is intentionally thin — there is no live game session, no
/// server connection, and no network. FixtureProvider bridges that gap by
/// feeding static fixtures (vitals percentages, a fake inventory, empty
/// shortcut lists) so the bound widgets show plausible state instead of
/// empty zeroes.
/// </para>
///
/// <para>
/// <b>IconIds approach:</b> raw-resolve stub — resolve the base <c>iconId</c>
/// via <see cref="RenderStack.ResolveChrome"/> and return the GL handle
/// directly. This is intentionally simpler than GameWindow's full
/// <see cref="IconComposer"/> (5-layer composite). The raw icon is enough
/// to confirm the grid cells draw something in the studio; the full
/// compositor is the live-game concern, not the layout preview concern.
/// </para>
/// </summary>
public static class FixtureProvider
{
/// <summary>
/// Populate <paramref name="layout"/> with sample data appropriate for
/// <paramref name="layoutId"/>. Calls the production controller Bind
/// methods so the panel's widgets drive off the same code path as the
/// full game.
/// </summary>
/// <param name="layoutId">The LayoutDesc dat id used to import the layout.</param>
/// <param name="layout">The imported layout whose widgets to populate.</param>
/// <param name="stack">The live render stack (for <see cref="RenderStack.ResolveChrome"/>
/// and <see cref="RenderStack.VitalsDatFont"/>).</param>
/// <param name="objects">A <see cref="ClientObjectTable"/> already seeded by
/// <see cref="SampleData.BuildObjectTable"/>.</param>
public static void Populate(
uint layoutId,
ImportedLayout layout,
RenderStack stack,
ClientObjectTable objects)
{
switch (layoutId)
{
case 0x2100006Cu: // vitals
VitalsController.Bind(layout,
healthPct: () => SampleData.HealthPct,
staminaPct: () => SampleData.StaminaPct,
manaPct: () => SampleData.ManaPct,
healthText: () => "80/100",
staminaText: () => "60/100",
manaText: () => "90/100");
break;
case 0x21000016u: // toolbar
ToolbarController.Bind(
layout,
objects,
shortcuts: () => System.Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: MakeIconIds(stack),
useItem: _ => { },
combatState: null,
peaceDigits: null,
warDigits: null,
emptyDigits: null,
sendAddShortcut: null,
sendRemoveShortcut: null);
break;
case 0x21000023u: // inventory
InventoryController.Bind(
layout,
objects,
playerGuid: () => SampleData.PlayerGuid,
iconIds: MakeIconIds(stack),
strength: () => 100,
datFont: stack.VitalsDatFont);
break;
default:
// Unknown layout — no-op; the panel renders structurally.
break;
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
/// <summary>
/// Build the <c>iconIds</c> delegate for toolbar / inventory controllers.
///
/// <para>
/// Raw-resolve stub: resolve the base <paramref name="iconId"/> (arg 2)
/// via <see cref="RenderStack.ResolveChrome"/> and return its GL handle.
/// The remaining args (type, underlayId, overlayId, effects) are ignored
/// for the studio — a single-layer icon is sufficient for layout preview.
/// </para>
///
/// <para>This is what the task spec calls "v1 raw-resolve stub".</para>
/// </summary>
private static Func<ItemType, uint, uint, uint, uint, uint> MakeIconIds(RenderStack stack)
=> (_, iconId, _, _, _) =>
{
if (iconId == 0u) return 0u;
var (handle, _, _) = stack.ResolveChrome(iconId);
return handle;
};
}

View file

@ -0,0 +1,168 @@
using AcDream.Core.Items;
namespace AcDream.App.Studio;
/// <summary>
/// Static sample data for the UI Studio fixture provider.
/// Provides a pre-built <see cref="ClientObjectTable"/> populated with a
/// representative player, their main-pack items, side bags, and equipped gear,
/// so the 2-D inventory / paperdoll panels render populated when previewed in
/// the studio without a live game session.
///
/// Icon ids used (all real 0x06xxxxxx RenderSurface ids confirmed in the dats):
/// Inventory empty-slot sprite : 0x06004D20
/// Generic "misc" item : 0x060011D4 (icon_misc_underlay / fallback)
/// Iron Sword (melee weapon) : 0x060011CBu (acclient default weapon underlay)
/// Leather Breastplate (armor) : 0x060011CFu (acclient default armor underlay)
/// Leather Gloves : 0x060011F3u (acclient default clothing underlay)
/// Steel Ring : 0x060011D5u (acclient default jewelry underlay)
/// Healing Kit : 0x060011D4u (generic misc fallback)
/// Spell components : 0x060011D4u (generic misc fallback)
/// Side-bag 1 (Container) : 0x06004D20u
/// Side-bag 2 (Container) : 0x06004D20u
/// Equipped helm (HeadWear) : 0x060011F3u
/// Equipped chest armor : 0x060011CFu
/// Equipped melee weapon : 0x060011CBu
///
/// These are the icon *base* RenderSurface ids — the same ids GameWindow passes
/// as `iconId` into the iconIds lambda. FixtureProvider resolves them via
/// <see cref="Rendering.RenderStack.ResolveChrome"/> and returns the raw GL handle.
/// </summary>
public static class SampleData
{
// ── Guids ────────────────────────────────────────────────────────────────
/// <summary>Fake server guid for the studio's synthetic player.</summary>
public const uint PlayerGuid = 0x50000001u;
// Items in main pack (slots 05).
private const uint SwordGuid = 0x50000010u;
private const uint ChestGuid = 0x50000011u;
private const uint GlovesGuid = 0x50000012u;
private const uint RingGuid = 0x50000013u;
private const uint HealKitGuid = 0x50000014u;
private const uint CompGuid = 0x50000015u;
// Side bags (also in main pack, ContainerId = PlayerGuid; slots 6 & 7).
private const uint Bag1Guid = 0x50000020u;
private const uint Bag2Guid = 0x50000021u;
// Equipped items (ContainerId = PlayerGuid, CurrentlyEquippedLocation set).
private const uint HelmGuid = 0x50000030u;
private const uint ChestEqGuid = 0x50000031u;
private const uint WeaponEqGuid = 0x50000032u;
// ── Icon ids (0x06xxxxxx RenderSurface dat ids) ───────────────────────────
// These are the same underlay/fallback icon ids the IconComposer tests pin.
private const uint IconWeapon = 0x060011CBu; // weapon underlay
private const uint IconArmor = 0x060011CFu; // armor underlay
private const uint IconClothing = 0x060011F3u; // clothing underlay
private const uint IconJewelry = 0x060011D5u; // jewelry underlay
private const uint IconMisc = 0x060011D4u; // misc / fallback underlay
// ── Public API ──────────────────────────────────────────────────────────
/// <summary>
/// Build a fresh <see cref="ClientObjectTable"/> populated with the
/// studio's sample player + a realistic inventory snapshot.
/// The table is owned by the caller and should be kept alive for the
/// window's lifetime.
/// </summary>
public static ClientObjectTable BuildObjectTable()
{
var t = new ClientObjectTable();
// ── Player object ─────────────────────────────────────────────────
t.AddOrUpdate(new ClientObject
{
ObjectId = PlayerGuid,
Name = "Studio Player",
Type = ItemType.Creature,
ItemsCapacity = 102,
ContainersCapacity = 7,
});
// ── Loose items in main pack (slots 05) ──────────────────────────
AddItem(t, SwordGuid, ItemType.MeleeWeapon, IconWeapon, "Iron Sword", PlayerGuid, 0, burden: 60);
AddItem(t, ChestGuid, ItemType.Armor, IconArmor, "Leather Breastplate", PlayerGuid, 1, burden: 200);
AddItem(t, GlovesGuid, ItemType.Clothing, IconClothing, "Leather Gloves", PlayerGuid, 2, burden: 50);
AddItem(t, RingGuid, ItemType.Jewelry, IconJewelry, "Steel Ring", PlayerGuid, 3, burden: 10);
AddItem(t, HealKitGuid, ItemType.Misc, IconMisc, "Healing Kit", PlayerGuid, 4, burden: 30);
AddItem(t, CompGuid, ItemType.SpellComponents, IconMisc, "Spell Comps", PlayerGuid, 5, burden: 25, stackSize: 50, stackMax: 100);
// ── Side bags (Container items in main pack, slots 6 & 7) ─────────
AddItem(t, Bag1Guid, ItemType.Container, IconMisc, "Small Pack 1",
containerId: PlayerGuid, slot: 6, burden: 20, itemsCapacity: 24);
AddItem(t, Bag2Guid, ItemType.Container, IconMisc, "Small Pack 2",
containerId: PlayerGuid, slot: 7, burden: 20, itemsCapacity: 24);
// ── Equipped items (ContainerId = PlayerGuid, CurrentlyEquippedLocation set) ──
AddEquipped(t, HelmGuid, ItemType.Armor, IconClothing, "Tin Helm", EquipMask.HeadWear);
AddEquipped(t, ChestEqGuid, ItemType.Armor, IconArmor, "Chain Coat", EquipMask.ChestArmor);
AddEquipped(t, WeaponEqGuid, ItemType.MeleeWeapon, IconWeapon, "Wooden Sword", EquipMask.MeleeWeapon);
return t;
}
// ── Sample vital constants (used by FixtureProvider) ────────────────────
public const float HealthPct = 0.8f;
public const float StaminaPct = 0.6f;
public const float ManaPct = 0.9f;
// ── Helpers ─────────────────────────────────────────────────────────────
private static void AddItem(
ClientObjectTable t,
uint guid,
ItemType type,
uint iconId,
string name,
uint containerId,
int slot,
int burden = 0,
int stackSize = 1,
int stackMax = 1,
int itemsCapacity = 0)
{
t.AddOrUpdate(new ClientObject
{
ObjectId = guid,
Name = name,
Type = type,
IconId = iconId,
Burden = burden,
StackSize = stackSize,
StackSizeMax = stackMax,
ItemsCapacity = itemsCapacity,
});
t.MoveItem(guid, containerId, slot);
}
private static void AddEquipped(
ClientObjectTable t,
uint guid,
ItemType type,
uint iconId,
string name,
EquipMask equipMask)
{
t.AddOrUpdate(new ClientObject
{
ObjectId = guid,
Name = name,
Type = type,
IconId = iconId,
ValidLocations = equipMask,
CurrentlyEquippedLocation = equipMask,
ContainerId = PlayerGuid,
});
// Update the equip location on the object via MoveItem (sets ContainerId +
// CurrentlyEquippedLocation via the equip overload).
t.MoveItem(guid, PlayerGuid, newSlot: -1, newEquipLocation: equipMask);
}
}

View file

@ -47,6 +47,10 @@ public sealed class StudioWindow : IDisposable
private StudioInspector? _inspector;
private UiElement? _panelRoot; // top-level element added to UiRoot (for hit-test + tree)
// Task 4: sample data table — built once in OnLoad and kept alive for the window's lifetime
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
private AcDream.Core.Items.ClientObjectTable? _objects;
public StudioWindow(StudioOptions opts)
{
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
@ -117,7 +121,18 @@ public sealed class StudioWindow : IDisposable
var root = _source.Load(_opts);
_panelRoot = root;
if (root is not null)
{
_stack.UiHost.Root.AddChild(root);
// Task 4: populate the panel with sample data via production controllers,
// so inventory / vitals / toolbar panels render with plausible content.
if (_source.CurrentLayout is not null)
{
uint layoutId = _opts.LayoutId ?? 0x2100006Cu;
_objects = SampleData.BuildObjectTable();
FixtureProvider.Populate(layoutId, _source.CurrentLayout, _stack, _objects);
}
}
else
Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}");

View file

@ -0,0 +1,105 @@
using AcDream.App.Studio;
using AcDream.Core.Items;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Unit tests for <see cref="FixtureProvider"/> and <see cref="SampleData"/>.
/// These tests have NO GL/dat dependency — they only exercise the in-memory
/// ClientObjectTable population that FixtureProvider uses.
/// </summary>
public class FixtureProviderTests
{
/// <summary>
/// SampleData.BuildObjectTable() must place at least 6 items in the
/// player's main pack (ContainerId == PlayerGuid) and the player object
/// itself must exist with the right capacities.
/// </summary>
[Fact]
public void SampleTable_hasPackContents()
{
var t = SampleData.BuildObjectTable();
// Player object must exist.
var player = t.Get(SampleData.PlayerGuid);
Assert.NotNull(player);
Assert.Equal(102, player!.ItemsCapacity);
Assert.Equal(7, player.ContainersCapacity);
// At least 6 loose items must be in the main pack.
var contents = t.GetContents(SampleData.PlayerGuid);
Assert.True(contents.Count >= 6,
$"Expected >= 6 items in player pack; got {contents.Count}");
// Verify the first item has a non-zero IconId and a recognised Type.
var firstId = contents[0];
var first = t.Get(firstId);
Assert.NotNull(first);
Assert.NotEqual(0u, first!.IconId);
Assert.NotEqual(ItemType.None, first.Type);
}
/// <summary>
/// Spot-check that the sample table also seeds a sword-like item
/// (MeleeWeapon) and a piece of armor so icon resolution has
/// recognisable item types to work with.
/// </summary>
[Fact]
public void SampleTable_hasWeaponAndArmor()
{
var t = SampleData.BuildObjectTable();
var contents = t.GetContents(SampleData.PlayerGuid);
bool hasMelee = false, hasArmor = false;
foreach (var guid in contents)
{
var obj = t.Get(guid);
if (obj is null) continue;
if ((obj.Type & ItemType.MeleeWeapon) != 0) hasMelee = true;
if ((obj.Type & ItemType.Armor) != 0) hasArmor = true;
}
Assert.True(hasMelee, "Expected at least one MeleeWeapon in sample pack");
Assert.True(hasArmor, "Expected at least one Armor item in sample pack");
}
/// <summary>
/// Sample table must include at least 1 equipped item whose
/// CurrentlyEquippedLocation is non-None.
/// </summary>
[Fact]
public void SampleTable_hasEquippedItems()
{
var t = SampleData.BuildObjectTable();
bool anyEquipped = false;
foreach (var obj in t.Objects)
{
if (obj.CurrentlyEquippedLocation != EquipMask.None)
{ anyEquipped = true; break; }
}
Assert.True(anyEquipped, "Expected at least one equipped item in sample table");
}
/// <summary>
/// Sample table must include at least 2 side-bag containers in the
/// player's pack (ContainerId == PlayerGuid, Type has Container bit).
/// </summary>
[Fact]
public void SampleTable_hasSideBags()
{
var t = SampleData.BuildObjectTable();
int bagCount = 0;
foreach (var guid in t.GetContents(SampleData.PlayerGuid))
{
var obj = t.Get(guid);
if (obj is not null && (obj.Type & ItemType.Container) != 0)
bagCount++;
}
Assert.True(bagCount >= 2,
$"Expected >= 2 side-bag containers in player pack; got {bagCount}");
}
}