fix(mosstank): unclickable button, empty skill list, dev font, and chat output

Four defects from the first in-world look, three of them with a definite root
cause rather than a plausible one.

**The Buff button did nothing.** Not a hit-testing problem -- the pointer found
the button perfectly. UiRoot's press handling asks the pressed widget whether
it owns the pointer; a widget that does not claim the press falls through to
"move the ancestor window", and a window drag returns early on release without
ever emitting a Click. UiButton and UiClickablePanel both override
HandlesClick for exactly this reason; UiSimpleButton never did. Latent since
that class was written, and invisible until it was put inside a draggable
window -- which is precisely what a markup plugin panel is.

Found by reproducing it headlessly through the real UiRoot dispatcher rather
than by reasoning about it: MarkupPanelClickTests drives press-and-release over
the button and asserts the bound action ran, with a separate test asserting the
pointer finds the button at all, so a future failure says which half broke.
My earlier guess -- that a modal at character select was swallowing the click
-- was wrong, and the screenshot of the panel live in world disproved it.

**"0 trained skills".** The skill-name table was read in OnLoad *before*
GameWindowCompositionPipeline.Run, which is what publishes the DAT collection,
so _dats was still null, the whole block was skipped, and the surface reported
an empty skill list with nothing to explain it. Bound in PublishDatCollection
instead -- the moment the data exists -- so it cannot run early again whatever
the phase ordering does, and a genuinely missing SkillTable now says so.

**Plugin text used the development bitmap font.** UiLabel and UiSimpleButton
gained a DatFont, and MarkupDocument now takes the retail interface font from
the host, so plugin panels render through the same glyph path (including
retail's two-plane outline) as authored panels.

**MossTank now writes to chat.** New BCL-only IPluginChat routes to retail's
ClientLocal log type (0x1A) -- the channel the client uses for its own notices,
local to this client, so a plugin cannot speak in the player's name. MossTank
announces the start, the finish with a cast count, and a stall.

Not addressed here: the cursor showing blue rather than amber. Traced but not
fixed -- CursorFeedbackController picks the cursor family from combat mode, and
CombatMode.Magic selects the blue Magic cursor where Default is amber. That is
a combat-mode question, unrelated to this change, and worth its own look rather
than a speculative fix folded in here.

Solution builds clean; 14,437 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-20 18:27:28 +02:00
parent 17ebfc434d
commit b9674b1f1e
8 changed files with 240 additions and 22 deletions

View file

@ -894,9 +894,32 @@ public sealed class GameWindow :
PublishCompositionOwner(ref _cameraPointerInput, value, "camera pointer input");
void IGameWindowContentEffectsAudioPublication.PublishDatCollection(
IDatReaderWriter value) =>
IDatReaderWriter value)
{
PublishCompositionOwner(ref _dats, value, "DAT collection");
// Retail skill names for the plugin automation surface, bound here
// because this is the moment the dats exist. Reading them earlier in
// OnLoad silently produced an empty table -- the composition phase that
// publishes the collection had not run yet -- and a plugin then saw
// zero skills with no error to explain it.
if (_automation is null)
return;
if (!value.TryGet<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u, out var skillTable)
|| skillTable is null)
{
Console.Error.WriteLine(
"plugin automation: retail SkillTable 0x0E000004 missing; "
+ "plugins will see unnamed skills");
return;
}
var names = new Dictionary<uint, string>(skillTable.Skills.Count);
foreach (var entry in skillTable.Skills)
names[(uint)entry.Key] = entry.Value.Name;
_automation.BindSkillNames(names);
}
void IGameWindowContentEffectsAudioPublication.PublishPreparedAssetSource(
IPreparedAssetSource value) =>
PublishCompositionOwner(
@ -1313,20 +1336,6 @@ public sealed class GameWindow :
// the executable's, so the loss is visible on every surface.
WindowIconLoader.Apply(_window!);
// Retail skill names for the plugin automation surface. Read here
// rather than at construction because content opens in OnLoad; a
// plugin showing "Life Magic" instead of "skill 33" needs the same
// table the character panel uses.
if (_automation is not null && _dats is not null
&& _dats.TryGet<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u, out var skillTable)
&& skillTable is not null)
{
var names = new Dictionary<uint, string>(skillTable.Skills.Count);
foreach (var entry in skillTable.Skills)
names[(uint)entry.Key] = entry.Value.Name;
_automation.BindSkillNames(names);
}
GameWindowCompositionPipeline.Run<
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
HostInputCameraResult,