fix(D.5.1): toolbar movable + chrome-grab + peace-only indicator + no prototype square

D1 — Toolbar not movable: toolbarRoot.Anchors = AnchorEdges.None (was Left|Top)
so ApplyAnchor early-returns and doesn't re-pin the window every frame.
Matches the vitalsRoot idiom exactly.

D2 — Cannot grab toolbar by chrome: toolbarRoot.ClickThrough = false
so HitTest succeeds over the UiDatElement chrome and the drag starts.
UiDatElement ctor defaults ClickThrough=true; vitalsRoot already overrides it.

C1 — All four combat-mode indicators visible at once (war/flame stacked on
peace): ports gmToolbarUI::RecvNotice_SetCombatMode
(acclient_2013_pseudo_c.txt:196632-196669). CombatIndicatorIds[] maps
index 0-3 to NonCombat/Melee/Missile/Magic; SetCombatMode shows exactly one
and hides the other three. Default to NonCombat at bind (player always
spawns in peace). Wires CombatState.CombatModeChanged for live updates.
Tests: CombatIndicator_defaultNonCombat_onlyPeaceVisible,
CombatIndicator_setCombatModeMelee_onlyMeleeVisible,
CombatIndicator_liveSignal_updatesWhenCombatStateChanges.

V1 — Blue empty-slot square at top-left (prototype 0x100001B2 materialized):
ImportInfos now skips top-level elements that are (a) referenced as a
BaseElement by another element in the same layout AND (b) have no own state
media. The CollectBaseRefsInDesc walk covers nested children; HasNoOwnMedia
re-uses ToInfo's media extraction. The Resolve path reads BaseElement from the
raw dat via dats.Get<LayoutDesc> — it never depends on the prototype being in
the built widget tree — so the skip is safe. Conformance tests (vitals, chat)
are unaffected (they exercise Build, not ImportInfos).
Test: BuildFromInfos_PrototypeSkipped_DerivedPresent_PrototypeAbsent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-17 13:03:07 +02:00
parent b3e5e8b0f7
commit bfc452d610
5 changed files with 262 additions and 11 deletions

View file

@ -1921,11 +1921,17 @@ public sealed class GameWindow : IDisposable
toolbarLayout, Items,
() => Shortcuts,
iconIds: (icon, under, over) => iconComposer.GetIcon(icon, under, over),
useItem: guid => UseItemByGuid(guid));
useItem: guid => UseItemByGuid(guid),
combatState: Combat);
var toolbarRoot = toolbarLayout.Root;
toolbarRoot.Left = 10; toolbarRoot.Top = 300;
toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top;
// D1: Anchors=None so ApplyAnchor skips re-pinning every frame and
// the drag position is preserved (matches vitalsRoot pattern).
toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
// D2: UiDatElement ctor defaults ClickThrough=true; override so the
// chrome is hittable and the drag can start (matches vitalsRoot pattern).
toolbarRoot.ClickThrough = false;
toolbarRoot.Draggable = true;
_uiHost.Root.AddChild(toolbarRoot);
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");

View file

@ -139,9 +139,34 @@ public static class LayoutImporter
var ld = dats.Get<LayoutDesc>(layoutId);
if (ld is null) return null;
// Collect the set of element ids that are referenced as a BaseElement by ANY
// element in THIS layout (where BaseLayoutId == layoutId). Such elements are
// purely inheritance templates ("prototypes") — retail never instantiates them
// as live widgets. Example: the toolbar slot prototype 0x100001B2 in LayoutDesc
// 0x21000016, which all 18 slot elements inherit from and which has no own media.
//
// NOTE: the Resolve path reads BaseElement from the raw dat directly (via
// dats.Get<LayoutDesc>), so the prototype never needs to appear in the built
// widget tree for inheritance to work. Skipping it here is safe.
var referencedAsBase = new HashSet<uint>();
foreach (var kv in ld.Elements)
CollectBaseRefsInDesc(kv.Value, layoutId, referencedAsBase);
var tops = new List<ElementInfo>();
foreach (var kv in ld.Elements)
tops.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
{
// Skip pure prototype elements: top-level elements that are referenced as a
// base template by another element in this same layout AND have no own state
// media (so they draw nothing and contribute nothing but their inherited shape).
var d = kv.Value;
if (referencedAsBase.Contains(d.ElementId) && HasNoOwnMedia(d))
{
Console.WriteLine($"[D.2b] LayoutImporter: skipping prototype element 0x{d.ElementId:X8} in layout 0x{layoutId:X8} (no own media, referenced as BaseElement).");
continue;
}
tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>()));
}
return tops.Count == 1
? tops[0]
@ -270,6 +295,37 @@ public static class LayoutImporter
}
}
// ── Prototype detection helpers ───────────────────────────────────────────
/// <summary>
/// Recursively walks <paramref name="d"/> and all its children, adding to
/// <paramref name="result"/> the <c>BaseElement</c> of every descriptor that
/// references this layout (<c>BaseLayoutId == layoutId</c>). Used by
/// <see cref="ImportInfos"/> to identify pure prototype/template elements that
/// should not be instantiated as live widgets.
/// </summary>
private static void CollectBaseRefsInDesc(ElementDesc d, uint layoutId, HashSet<uint> result)
{
if (d.BaseElement != 0 && d.BaseLayoutId == layoutId)
result.Add(d.BaseElement);
foreach (var kv in d.Children)
CollectBaseRefsInDesc(kv.Value, layoutId, result);
}
/// <summary>
/// Returns true when <paramref name="d"/> carries no own state media — i.e. its
/// <c>StateDesc</c> (DirectState) and <c>States</c> (named states) yield no
/// <see cref="MediaDescImage"/> entries with a non-zero file id.
/// Such elements are pure inheritance templates with no rendering content.
/// </summary>
private static bool HasNoOwnMedia(ElementDesc d)
{
// Re-use ToInfo's media extraction: if the resulting StateMedia is empty the
// element has no renderable image in any state.
var info = ToInfo(d);
return info.StateMedia.Count == 0;
}
// ── Element tree search ───────────────────────────────────────────────────
/// <summary>

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
@ -39,7 +40,15 @@ public sealed class ToolbarController
// Ids confirmed from the toolbar LayoutDesc dump.
private static readonly uint[] HiddenIds = { 0x100001A1, 0x100001A2, 0x100001A4 };
// Four mutually-exclusive combat-mode indicator elements — exactly one visible at a time.
// Index 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
// Retail ref: gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669)
// SetVisible's exactly one element depending on the incoming mode.
private static readonly uint[] CombatIndicatorIds =
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
private readonly ItemRepository _repo;
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
private readonly Func<uint, uint, uint, uint> _iconIds; // (iconId, underlayId, overlayId) → GL tex
@ -50,7 +59,8 @@ public sealed class ToolbarController
ItemRepository repo,
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
Func<uint, uint, uint, uint> iconIds,
Action<uint> useItem)
Action<uint> useItem,
CombatState? combatState)
{
_repo = repo;
_shortcuts = shortcuts;
@ -64,10 +74,23 @@ public sealed class ToolbarController
WireClick(list);
}
// Cache the four mutually-exclusive combat-mode indicator elements.
for (int i = 0; i < CombatIndicatorIds.Length; i++)
_combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]);
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
foreach (var id in HiddenIds)
if (layout.FindElement(id) is { } e) e.Visible = false;
// Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669):
// exactly one indicator visible at a time. Default to NonCombat (peace) — the player
// always spawns in peace mode; retail has not yet called SetVisible when PostInit runs.
SetCombatMode(CombatMode.NonCombat);
// Wire live combat-mode changes if a CombatState was provided.
if (combatState is not null)
combatState.CombatModeChanged += SetCombatMode;
// Re-bind any deferred slot whenever the repo learns about a new/updated item.
repo.ItemAdded += _ => Populate();
repo.ItemPropertiesUpdated += _ => Populate();
@ -84,14 +107,21 @@ public sealed class ToolbarController
/// <param name="shortcuts">Provider for the current shortcut bar list.</param>
/// <param name="iconIds">Resolves (iconId, underlayId, overlayId) → GL texture handle.</param>
/// <param name="useItem">Callback fired when a bound slot is clicked; receives the item guid.</param>
/// <param name="combatState">
/// Optional live combat state — when provided, the toolbar subscribes to
/// <see cref="CombatState.CombatModeChanged"/> and updates the four mutually-exclusive
/// combat-mode indicator elements accordingly.
/// Pass null to skip live wiring (e.g. in unit tests that don't exercise the indicator).
/// </param>
public static ToolbarController Bind(
ImportedLayout layout,
ItemRepository repo,
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
Func<uint, uint, uint, uint> iconIds,
Action<uint> useItem)
Action<uint> useItem,
CombatState? combatState = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem);
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState);
c.Populate();
return c;
}
@ -123,6 +153,33 @@ public sealed class ToolbarController
}
}
/// <summary>
/// Port of <c>gmToolbarUI::RecvNotice_SetCombatMode</c>
/// (acclient_2013_pseudo_c.txt:196632-196669): show exactly one of the four
/// mutually-exclusive combat-mode indicator elements and hide the other three.
/// Called at bind-time with <see cref="CombatMode.NonCombat"/> (the player
/// always starts in peace mode) and subsequently whenever
/// <see cref="CombatState.CombatModeChanged"/> fires.
/// </summary>
public void SetCombatMode(CombatMode mode)
{
// Index → mode mapping matches CombatIndicatorIds declaration order:
// 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
bool[] show =
{
mode == CombatMode.NonCombat,
mode == CombatMode.Melee,
mode == CombatMode.Missile,
mode == CombatMode.Magic,
};
for (int i = 0; i < _combatIndicators.Length; i++)
{
if (_combatIndicators[i] is { } e)
e.Visible = show[i];
}
}
/// <summary>
/// Wire the <see cref="UiItemSlot.Clicked"/> callback on a slot cell so that
/// clicking a bound item fires <see cref="_useItem"/> with the slot's current guid.