VVS's own HudList grids (Monsters/Meta/Route/Items and every other
<list><column> grid this codebase has, present and future) have no
row-selection highlight at all — only the per-cell click callbacks are a
real VTank concept. UiMarkupList.DrawColumns painted a SelectedColor band
under the selected row anyway (a holdover from the legacy single-column
list path, which keeps its own band unchanged — that's a plain list, not a
VVS grid). Removed the band draw from DrawColumns only; SelectedIndexSource
still drives scroll-into-view, and every onclick/onchange callback is
untouched.
New test: ColumnGrids_NeverDrawARowSelectionBand (a column list with a
real in-range selected index must never paint SelectedColor). Mutation
check: before the fix this test was RED against the real code
("expected no SelectedColor fill in a column-based grid"); after removing
the band draw it's green.
tests/AcDream.Plugins.MossTank.Tests: 671/671 (unchanged — this is an
App-layer fix, MossTank markup only consumes the existing <column>
grammar).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 277/3 skipped/280 (was 276/3/279, +1 new test).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Menu_Scroll_DrawsAScrollbarWhenTheMarkupItemCountOverflowsTheVisibleRows was
RED at HEAD: <menu> now defaults to the plain style (RetailButtonArt=false,
the "BIG gold/yellow buttons" owner fix), so a markup menu with no style
attribute draws DrawScrollablePopupPlain's flat DrawFill thumb, never the
retail sprite path (DrawPopupScrollbar) the test asserted on.
Fix: give the existing test style="retail" so it keeps proving the retail
sprite path draws ScrollThumbSprite. Add a plain sibling
(Menu_Scroll_DrawsAPlainFlatThumbFillWhenTheMarkupItemCountOverflowsTheVisibleRows)
that proves the DEFAULT (no style attribute) path draws the flat thumb fill:
an untextured (texture=0) quad sized ScrollbarWidth-2 wide, tinted
PlainBorderColor. Untextured DrawFill calls all batch into one texture=0
render segment, so the assertion scans per-quad (6 verts x 8 floats) inside
each segment rather than treating a whole segment as one quad.
Mutation check: commenting out DrawPopupScrollbarPlain's thumb DrawFill call
turned the new test red ("expected a plain flat thumb fill... among the
drawn segments"); restoring the call turns it green again.
tests/AcDream.Plugins.MossTank.Tests: 660/660 (unchanged).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 276 passed / 3
skipped / 279 total (was 274 passed / 1 failed / 3 skipped / 278 total).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner live-client report 2026-09-07 ("Drop down menus look horrible, there
is also a checkmark on the text there"): the prior S7 fix only replaced the
CLOSED-state button face when RetailButtonArt=false. The OPEN popup still
drew retail's sprite art unconditionally — the tan/orange gradient panel
(PopupBgSprite), the per-row highlight sprite (which bakes a checkbox/
checkmark glyph into its leftmost ~17px, per TextIndent's doc comment), and
the ornate gold scrollbar chrome — regardless of the menu's style.
UiMenu.OnDrawOverlay now branches on RetailButtonArt before ever reading
SpriteResolve: plain mode draws through two new methods (DrawGridPopupPlain,
DrawScrollablePopupPlain) that use only DrawFill/DrawRectOutline — flat
background + 1px border, the current entry filled like a list selection
(PlainSelectedColor, same value as UiMarkupList.SelectedColor), a new
hover fill (PlainHoverColor) for the row under the pointer, and left-aligned
text at PlainPadding. No checkmark is possible by construction since plain
mode never resolves or draws any sprite. Hover tracking needed a small new
mechanism: UiMenu.ReceivesHoverMouseMove now returns true while a plain
popup is open, so UiRoot's hover dispatch keeps delivering MouseMove to
_hoveredPopupIndex (reset on every open/close transition and on
HoverLeave). Scrollbar overflow (DrawPopupScrollbarPlain) draws a 1px-
bordered track and a flat thumb, both in PlainBorderColor, sharing the
exact UiScrollbar.ThumbRect geometry the hit-test math already uses — no
DAT track/thumb/arrow-button art. Hit-testing (OnHitTest/OnEvent's
MouseDown pick logic) is untouched; the retail sprite branch is now a
separate, unmodified path proven byte-identical by a new golden test.
Mutation proof: reverting UiMenu.cs alone (keeping the new tests) fails the
build outright — the six new tests reference PlainSelectedColor/
PlainHoverColor, which only exist after this change (CS1061 with the old
class). Filters run: AcDream.App.Tests Markup|UiMenu|Menu|Scrollbar
(242 passed, 3 pre-existing unrelated Lane=Manual live-DAT-probe failures
that require ACDREAM_PROBE_LIVE_MOUNT=1 and predate this change) and
AcDream.Plugins.MossTank.Tests Markup (9/9 passed).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings cfa703065's owner-driven fix (plugin <menu> renders VTank's plain
flat combo by default; style="retail" opts back into the gold pushbutton
art) into the S7.1-S7.3 window/tabs work so the fix round below can build
on both. Resolved conflicts: the ledger keeps both worktrees' entries in
chronological order (07:55/08:05 owner-read entries appended after this
worktree's own S7.1/S7.2/S7.3 entries); plugin-ui-markup.md's <menu> row
lists both new attributes (scroll, style); MarkupDocumentTests.cs keeps
both new test blocks (slider min/max + menu scroll from this worktree,
menu style from latest-main-sync).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wires the new UiMenu.RetailButtonArt switch (previous commit) into
plugin markup: <menu style="plain"> (also the default when the
attribute is absent) builds RetailButtonArt=false so a plugin's
dropdown gets the flat VTank-matching box; style="retail" opts a
panel back into the gold pushbutton face. Any other value throws
FormatException at Build naming the element, matching the existing
<icon iconkind> validation convention (ValidateIconKind).
Mutation check: temporarily stubbed ValidateMenuStyle to always return
true (as if the switch didn't exist) — 3 of the 4 new
MarkupDocumentTests.Menu_* tests failed exactly as expected
(Menu_NoStyleAttribute_DefaultsToPlain_RetailButtonArtFalse,
Menu_StylePlain_Explicit_RetailButtonArtFalse,
Menu_UnknownStyle_ThrowsFormatException_NamingTheElement); the
style="retail" test passed trivially either way, as expected for that
case. Restored before committing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner live-client report 2026-09-07: "Those BIG gold/yellow buttons HAS
to go. That is not how vtank looks." VTank/Decal's HudCombo is a flat
dark box (background/border matching its own HudList) with a
left-aligned value and a small down-arrow — retail's gold pushbutton
art (the 3-slice LED-arrow face UiMenu.DrawButtonFace draws) is a
different widget family entirely.
Adds UiMenu.RetailButtonArt (default true, so every existing
non-markup UiMenu caller — chat's channel menu, vendor's category
dropdown, Config's option menus, the retail confirmation dialog, and
DatWidgetFactory's generic Type-6 element — keeps its byte-identical
retail face) plus DrawPlainClosedState/DrawPlainTriangle, which draw
the flat box entirely with UiRenderContext.DrawFill/DrawRectOutline (no
sprite or DAT quad at all) using colors mirroring UiMarkupList's own
chrome (background 0,0,0,0.92; border 0.46,0.37,0.16,1; text
0.91,0.87,0.76,1). Open/pressed only tints the border
(0.70,0.58,0.24,1) — never a sprite swap.
Mutation check: temporarily disabled the new `if (!RetailButtonArt)`
branch in OnDraw (reverting it to the pre-fix unconditional retail
path) — 3 of the 6 new UiMenuPlainStyleTests failed exactly as
expected (Plain_ClosedState_DrawsNoTexturedFaceQuad,
Plain_ClosedState_DrawsFillOutlineTextAndTriangle,
Plain_ClosedState_TriangleSitsRightAligned_TextSitsAtListPadding); the
3 retail-path/default-value tests kept passing since they don't
exercise the removed branch. Restored before committing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Campaign VT slice 7 needs to transcribe VTank's own Vitals sliders
(minimum=0/maximum=100) and its long Profiles/Route named-item combos
control-for-control (docs/research/vtank-kb/08-ui-views.md §3's two
markup gaps). acdream's <slider> was hardwired to a 0.0-1.0 scalar and
<menu> always wrapped overflow into extra columns instead of VVS's
single scrolling column.
<slider min max>: optional literal attributes declaring the range the
bound value/onchange speak in (e.g. min="0" max="100" for a percent),
while UiScrollbar itself keeps its existing 0.0-1.0 internal math
untouched — MarkupDocument rescales at the binding boundary. Omitting
both (every pre-existing <slider>) keeps the exact historical identity
range.
<menu scroll="true">: wires UiMenu.Scrollable plus the same
track/thumb/up/down chrome sprites ConfigOptionsPageController and
VendorUiController already apply to their own Scrollable menus,
previously only reachable from C#. Omitting scroll keeps the
historical column-wrapping default.
Both are additive — no existing <slider>/<menu> markup changes
behavior. New pins in MarkupDocumentTests.cs shown to fail against the
prior MarkupDocument.cs (5 failures: Build_SliderWithNoMinMax_*,
Build_SliderWithMinMax_*, Slider_MinMax_Draws*,
Build_MenuWithScrollAttribute_*, Menu_Scroll_Draws*) before this
change, all green after.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two tests built through the full MarkupDocument.Build parse -> bind ->
draw (recording renderer) -> click pipeline, transcribing the two real
VTank list shapes named in the fix round brief
(refs/vtank/uTank2.ViewXML.mainView.xml):
- EndToEnd_MetaShapedSixColumnList: lstMetaRules's shape (3 icon
columns, a 150px text column, then two width="*" text columns, the
second with an onclick) at a list width (703) chosen so the two auto
columns' 505px leftover does NOT divide evenly (252/253) — proving
the last one absorbs the rounding slack. Asserts each column kind
draws inside its own x-range and a click in each of the six columns
reaches its own bound callback with the right row (icon columns
fire their own onclick; the plain text column still selects; the
onclick-bearing trailing text column fires that instead).
- EndToEnd_MonstersShapedTwentyThreeColumnList: lstMonsters's shape
(14 check columns, 7 text columns each given an onclick per fix item
1, 2 icon columns) built from real fixedwidth values with NO "*"
anywhere, at a list width (724) equal to the exact sum of every
column's declared width — the "no auto column, last absorbs the
remainder as today" case. Asserts the column-kind counts, each drawn
kind's x-range, and one representative click per kind reaches its own
callback (other same-kind columns share a dummy callback, proving the
bound-per-column dispatch, not just "some column of that kind fired").
Both use iconkind="item" for their icon columns (not the default
"did") so the fake resolver's echoed id is the literal texture drawn,
sidestepping PluginIcons.Normalize's did-namespace OR — an incidental
discovery while writing these (the first draft asserted on raw ids and
failed against the normalized 0x06000000-tagged textures).
Verified meaningful two ways: both failed first against the pre-fix
raw-id assumption (texture ids came back normalized), and — a stronger
check specific to these integration tests, since the underlying
mechanics were already covered by earlier fix-round commits — a
temporary mutation of ColumnLayout's auto-column share computation
(forcing share=0) made EndToEnd_MetaShapedSixColumnList fail exactly as
expected before being reverted.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
New internal static UiCheckLamp (LampSize=11f, the four checked/
unchecked colors, Draw) is the ONE definition of the five-band lamp
glyph, promoted out of UiMarkupToggle's private/internal fields —
UiMarkupToggle.OnDraw and UiMarkupList.DrawCheckCell both call
UiCheckLamp.Draw now instead of each carrying (or one exposing to the
other) its own copy of the same five DrawFill calls and four colors.
DrawCheckCell also centers the lamp horizontally in its cell instead of
drawing it flush left at cellX+1 — matching DrawIconCell, which already
centers its sprite. A check column declared wider than the glyph itself
(routine under fix item 11's PITCH-based authoring convention) no longer
strands the glyph in the cell's left edge.
UiMarkupListColumn's settable members are now internal init (Kind/Width
lost their `required` modifier — C# forbids `required` pairing with a
setter less visible than the containing public type, CS9032 — every
factory already sets both unconditionally, so this is a compiler-level
demotion, not a behavior change) — the type is constructible only
through its Text/Check/Icon factories from any external assembly (a
plugin) with no InternalsVisibleTo grant, so it can never assemble an
inconsistent instance via object-initializer syntax.
Replaced the inert draw-offset assertion in
Columns_CheckThenIcon_EachCellDrawsInsideItsOwnColumnBounds (check
column FIRST, so its own-cell assertion held trivially even with
completely broken column offsets) with
Columns_IconThenCheck_EachCellDrawsInsideItsOwnColumnBounds — check
column now SECOND, so the assertion can only pass if the glyph actually
moved into its own [20,100) cell.
New test: a single 50px-wide check column's glyph lands near the cell's
midpoint (~22.5) rather than the old flush-left x=4 — shown to fail
first before the centering change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
DrawCheckCell drew nothing for a row past its own column's bound data —
inconsistent with docs/plugin-ui-markup.md's own description of VVS's
HudList, which materializes every cell in a row regardless of which
columns actually have data for it. Text/icon cells rightly stay
no-draw past their own count (no sensible default string or icon), but
a check cell always has one: unchecked.
DrawCheckCell now computes isChecked defensively (false when the flags
array is null or too short) instead of returning early, so a short
check column draws the unchecked lamp for every row past its own data
just like every other row.
New test: a 3-row list (driven by a longer text column) with a check
column bound to only 1 row now shows the unchecked lamp for rows 1 and
2 — shown to fail first (0 unchecked lamp quads, since DrawCheckCell
returned early past index 0).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
DrawColumns allocated four fresh IReadOnlyList<T>?[] sidecar arrays and
ColumnLayout allocated a fresh (float,float)[] EVERY draw call; worse,
OnEventColumns re-invoked every column's source Func a SECOND time (via
UiMarkupListColumn.RowCount()) just to recompute the same row count
DrawColumns had already materialized, and called ColumnLayout a second
time to recompute the same layout — on every single click/scroll event.
Columns is now a property with a custom setter that (re)sizes seven
instance-field caches to Columns.Count exactly once per assignment:
_cachedTextRows/_cachedColorRows/_cachedCheckRows/_cachedIconRows (the
per-column sidecar arrays), _cachedLayout (the column x/width array),
and _scratchIsAuto/_scratchFixedWidth (ComputeColumnLayout's own working
arrays, previously freshly allocated on every layout computation too).
DrawColumns writes into these caches instead of local arrays and records
_cachedRowCount; ComputeColumnLayout (the renamed, now-instance
ColumnLayout) writes into _cachedLayout in place instead of returning a
new array. OnEventColumns reads _cachedRowCount/_cachedLayout/the
per-column cached row arrays instead of re-invoking anything — this also
completes fix item 3's row-bound guard without a second RowCount() call.
UiMarkupListColumn.RowCount() stays as public API (still asserted by an
existing test) but is no longer called from UiMarkupList internally,
which is the "single row-count definition" the fix round asked for.
This does make OnEvent depend on at least one prior Draw call for
correct row/layout data (mirrors real frame order: draw every frame,
then handle input) — the four hit-test click tests that previously
fired OnEvent with no preceding Draw now draw once first, matching what
Scroll_OffsetIsRespectedBySubsequentHitTests already did.
New test: each column source Func increments its own call counter; one
Draw call invokes each exactly once, and five rounds of subsequent
click events invoke none of them again — shown to fail first (16 calls
instead of 1, from OnEventColumns's old RowCount()-per-column-per-event
re-invocation) before the caching change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BuildListColumn's per-attribute binder calls used the generic "column
items"/"column values"/"column onchange"/"column onclick" context
strings from the initial slice — indistinguishable when a list has
several columns of the same type. Route every column-attribute binder
call (text items/colors/onclick, check values/onchange, icon
values/onclick) through the ColumnContext helper item 2 introduced, so
every throw message reads column[N] type="..." attr. ValidateIconKind
gains an optional context parameter (default "iconkind" for the
existing non-column call sites — <icon>, <button icon>, <list icons>,
none of which changed message-wise beyond wording) so the column
iconkind check can identify its own column too.
Updated all twelve column-throw tests in MarkupListColumnsTests to
assert Assert.Contains on the distinguishing phrase: the ten
attribute-specific ones now check column[0] type="..." attr; the
unknown-<column type> and combined-with-legacy-attribute tests keep
(and, for the type one, add to) their existing distinguishing
assertions. Confirmed each attribute-specific assertion is meaningful
by first running the malformed-colors case against the pre-fix
"column colors must be..." message (mismatch), then landing the
production change alongside the rest.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Matches UiButton.cs:896-906 / UiElement.cs:683-720's own clip
discipline: every per-cell PushClip in DrawColumns now has its matching
PopClip in a finally, so a cell draw that throws (e.g. a plugin's icon
resolver misbehaving) doesn't leak that PushClip onto the context's
clip stack. Without this, the leaked entry combines badly with
UiElement.DrawSelfAndChildren's own outer clip pop on the way out
(it pops the wrong stack entry), permanently corrupting the shared
UiRenderContext's clip state for every draw that follows in the frame.
Added UiRenderContext.ClipStackDepth (internal, InternalsVisibleTo
AcDream.App.Tests) purely to make this provable from a test — the
number of PushClip calls not yet matched by PopClip.
New test: a column whose icon resolver throws mid-draw still leaves
the clip stack at its pre-draw depth after the exception propagates —
shown to fail first (leaked to depth 1 instead of 0, reproducing
exactly the "outer pop consumes the wrong stack entry" mechanism
described above) before the try/finally was added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A short column (fewer bound rows than the list's own max-across-columns
row count) already draws nothing past its own row — DrawCheckCell/
DrawIconCell/DrawTextCell all no-op once index >= their own array's
Count. OnEventColumns's click routing didn't share that bound: a click
landing on a row the OVERALL list considers valid (row < the longest
column) but past a SHORTER column's own data would still invoke that
column's callback with a row index it never bound anything for.
Add the same per-column bound (index < columns[c].RowCount()) to the
check-column, icon-column, and text-column-with-onclick click paths —
text-without-onclick's plain SelectionChanged fallback is unaffected,
since row selection is a list-level concept already bounded by the
outer rowCount check.
New tests: a click past a short check/icon/onclick-bearing-text
column's own row count fires nothing (each shown to fail first — the
callback fired with the out-of-bound row index before this guard).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VVS's HudList shares remaining width equally among every 0-width column
(docs/research/vtank-kb/08-ui-views.md's HudList row, Decal3XMLParser.cs
:409-418) after fixed-width columns are subtracted — the initial slice
only let the LAST column auto-size. Port the real rule:
- A non-last <column width> that is missing, unparseable, or non-positive
throws FormatException at Build naming the column index and declared
type (column[N] type="..." width) — UNLESS it is width="*".
- width="*" (any column, including the last) marks it auto: every auto
column shares the list's remaining width equally, with the LAST column
in the list (always implicitly auto, regardless of its own width or
IsAutoWidth) absorbing the integer-division rounding slack. With no
"*" anywhere this reduces to exactly the original "last column gets
100% of the remainder" contract — zero behavior change for every
existing column-less/single-"*" list.
- A declared width overflowing the list's total width is clamped to
whatever room is left, walked left to right; every column after the
overflow point gets 0 width (and never draws — DrawColumns already
skips a <=0-width cell).
UiMarkupListColumn gains IsAutoWidth (internal to the width contract,
set via each factory's new optional isAutoWidth parameter); ColumnLayout
in UiMarkupList is rewritten around an explicit auto/fixed classification
pass instead of the old "only index n-1 is special" one-liner.
MarkupDocument's new ParseColumnWidth owns the Build-time validation,
shared through the same ColumnContext helper item 1 introduced.
New tests: non-last missing/unparseable/non-positive width throws
(each naming column[N] type="..." width), width="*" parses without
throwing on both a non-last and the last column, two auto columns
share the remainder with the last absorbing the odd pixel, and an
overflowing declared width clamps with later columns drawing nothing
— each shown to fail first (compile error for the missing isAutoWidth
parameter, then a wrong split before the ColumnLayout rewrite).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
None of VTank's eight HudList instances relies on row selection
(PluginCore.cs Monsters/Items/Meta/Route/Buffs/Consumables cell
handlers) — every real text cell is an action target. Add an optional
onclick="{Action<int>}" to <column type="text">: when present, a click
fires it with the row index INSTEAD of selecting; when absent, the
original select-the-row behavior is unchanged. BuildListColumn now
takes the column's own index (used for its onclick throw message and
threaded through for the rest of the fix round's column-context
messages); the unknown-<column type> throw also picked up the index
as a side effect of that signature change.
New tests (MarkupListColumnsTests): the onclick attribute binds and
falls back correctly when absent, a malformed onclick throws at Build,
and a click actually reaches the bound callback instead of firing
SelectionChanged — each shown to fail first (missing TextClicked
member / no 4-arg Text() overload before the production change).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VVS HudList parity (docs/research/vtank-kb/08-ui-views.md 2-3): <list>
accepts <column type="text|check|icon" width=... [iconkind] [colors]/>
children, each binding its own per-row source parallel to the row count.
Row count is the longest bound column; a click in a text column selects
(the list's own selected/onchange, unchanged), a click in a check/icon
column fires that column's own required onchange/onclick with the row
index and does NOT change selection. Last column always absorbs the
remaining width, recomputed live off the list's own Width rather than
baked in at Build. Per-cell horizontal clipping via UiRenderContext's
existing PushClip/PopClip. The check-column glyph reuses <toggle>'s own
five-band lamp primitive (UiMarkupToggle.DrawLamp/colors promoted from
private to internal) so it looks like every other checkbox. Unknown
column type, a missing required column binding, <column> combined with
the legacy items/icons/colors list attributes, or any non-<column> child
of <list> all throw FormatException at Build. A column-less <list> is
byte-for-byte the original single-text-column widget (new if/Columns
branch in OnDraw/OnEvent; the legacy branch's code is untouched).
New UiMarkupListColumn model (src/AcDream.App/UI/UiMarkupListColumn.cs)
carries each column's kind/width/bindings; MarkupDocument's `list` case
now builds either the legacy single-column fields or a Columns list,
never both. MarkupListColumnsTests (27 new tests) cover parse/binding
validation, draw-level column-offset/clipping/check-glyph/icon pins
against the recording renderer, hit-test routing, and a backward-
compatibility proof (a column-less list built through MarkupDocument
produces a byte-identical draw record to a hand-built UiMarkupList with
equivalent fields). Every new assertion was verified to fail first via
targeted temporary mutations (hit-test isolation, clip removal, last-
column-absorbs-remainder, required onchange/onclick, row-count = max),
each reverted after confirming failure.
docs/plugin-ui-markup.md gets a full Columns section (attribute grammar,
a Monsters-tab-style example, the no-header-row idiom, backward
compatibility) and the old single-text-column LIMITATION note is
retired; the bindings truth table gains the six new column-attribute
rows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner report 2026-09-06: MossTank's shelf icon (0x06002C41) drew with a white
ring. DAT icon art reserves pure-white-opaque pixels as the recolor key that
retail IconData::RenderIcons (0x0058d180) replaces per pixel through
SurfaceWindow::ReplaceColor (0x004415b0) from the effect tile — the solid-black
0x21 tile when there are no effects. The inventory already does this through
IconComposer; the plugin did sink (markup <icon did>, <button icon>, <list icons>
and the shelf button) blitted the art raw.
RetailMarkupIconResolver.ResolveDid now hands out IconComposer.GetKeyedIcon —
the drag-icon composite (base art + effects==0 recolor, no overlay, no
underlay), sharing that cache — so did icons look like a mundane inventory item
does. The resolver no longer needs a TextureCache. KeyedIconInstalledDatTests
pins both halves against the real DAT: the raw art carries the key, the
composite carries none, and ResolveDid returns exactly the keyed composite.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Split the two hermetic RetailMarkupIconResolver memoization tests (and
their counting fakes) out of the Lane=InstalledDat class into a new
untagged RetailMarkupIconResolverMemoizationTests.cs so CI's portable
filter (Lane!=InstalledDat) actually runs them.
- PluginSidePanel: move the entry button's Anchors = AnchorEdges.None from
the Add() call site into PluginShelfButton's own constructor (same
comment carried over) so a second construction path cannot miss it.
- UiRectOutlinePainterOrderTests: assert the back panel's border segment
carries exactly 4 quads (24 vertices, FloatsPerVertex each) so a partial
outline cannot pass the painter-order check.
- RetailMarkupIconResolver: document the type as UI-thread-only (every
caller is a draw-time icon source) and bound the MISS cache to 256
entries with FIFO eviction — HIT entries stay unbounded (bounded by the
DAT's own surface count already). New test proves the 257th distinct
miss evicts the first (re-probe count rises); verified failing first
against the un-bounded code (Expected 258, Actual 257) before restoring
the fix.
- docs/plugin-ui-markup.md: split the icon-binding row's failure mode into
Build-time (missing property only — the binder never checks CLR type)
vs. draw-time (a resolved value that cannot convert to a number throws
from the draw, not from Build).
- docs/ISSUES.md: filed #486 (credits picture scroll frozen by the
per-draw anchor pass) and #487 (radar compass tokens candidate, same
mechanism, unconfirmed); corrected #461's causality — the graceful
logout/reveal-cancel log lines are printed by LiveSessionController.Tick's
catch -> StopAfterFailure -> StopCore AFTER the motion-update exception,
then it rethrows, so the logout is a consequence of the crash, not its
cause; real chain is the #462 stalled login-reveal materialization
leaving PlayerMovementController in RuntimeOwnedDormant outside its
SetPosition ground phase when an inbound 0xF74C arrives.
- Plan doc: recorded the three fix-round commits' verdicts (all PASS) and
the Smoke-plugin cleanup commit SHA in the Review ledger, plus a pointer
to the two newly filed issues.
Verified: dotnet build AcDream.slnx -c Release (0/0), targeted filter
85/0/0, full App suite 7364 passed / 97 skipped / 36 failed (36 pre-existing
InstalledDat/Manual/Linux-only failures, unchanged by name from baseline;
net +1 passed test from the new eviction test).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner direction 2026-09-06: the owner visually accepted all three connected
gates from the plugin-shelf/DAT-icon work — the movable plugin shelf
(Slice A), the DAT icon markup (Slice B), and the retained-UI outline-order
fix (761a7519f). With that gate passed, the Smoke plugin's job as the gate
artifact is done, so it is deleted outright rather than merely hidden:
src/AcDream.Plugins.Smoke/ (SmokePlugin.cs, SmokeIconPanel.cs, csproj, lock
files).
Reference sites cleaned:
- AcDream.slnx: removed the project entry.
- src/AcDream.App/AcDream.App.csproj: removed the Smoke ProjectReference and
the CopySmokePluginToBuildOutput/CopySmokePluginToPublishOutput targets;
the MossTank equivalents are untouched.
- .github/workflows/headless-portability.yml: the Linux package-contract
step no longer asserts a Smoke plugin.dll/plugin.json pair — repointed at
MossTank's, since the step's job was to prove the plugin-copy packaging
mechanism works end to end, not specifically to prove Smoke.
- tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs: the inline JSON
fixture used Smoke's manifest values as arbitrary test data; swapped for
MossTank's so the parser test still proves the same thing.
- tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs: the
shipped-plugin-copy shape test counted 4 GetTargetPath targets (Smoke +
MossTank, build + publish); now 2 (MossTank only).
- tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceIconInstalledDatTests.cs:
reworded a doc comment that named the now-deleted SmokeIconPanel.
- README.md, docs/plugin-ui-markup.md: dropped Smoke-specific mentions,
kept the icon markup example/grammar (now citing MossTank's own real
IconSurfaceId).
- docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md: recorded the owner's
2026-09-06 acceptance and the Smoke removal in the review ledger; deleted
the now-moot "before shipment" Smoke-in-release-zip warning.
- docs/reviews/coverage-ledger.md, docs/reviews/findings-ledger.md: left
untouched — both are frozen audit snapshots ("complete for baseline
<hash>"), so their Smoke rows are historical record, not live claims.
- docs/ISSUES.md: left untouched — its Smoke mentions are inside closed
issue #193's historical write-up of a past investigation.
MossTank plugin shelf icon: MossTankPlugin.cs's PluginPanelDescriptor now
sets IconSurfaceId = 0x06002C41 (IconText = "MT" remains the fallback).
Verified against the installed retail DAT with a new InstalledDat-lane test,
tests/AcDream.App.Tests/UI/MossTankIconInstalledDatTests.cs, mirroring
RetailMarkupIconResolverInstalledDatTests's convention: confirms the id is a
real Portal/HighRes RenderSurface and that RetailMarkupIconResolver.ResolveDid
returns a non-zero texture for it.
Verified: dotnet build AcDream.slnx -c Release green; a stale
plugins/AcDream.Plugins.Smoke output folder from a prior build was deleted
and a fresh build does not recreate it. Full App suite: 7,363 passed / 97
skipped / 36 failed (was 7,362/97/36) — the failing set is unchanged and
none are plugin-related; the one new pass is the MossTank DAT-icon test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Bounded residual round on the Slice B review fix commit (466272ec5),
docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md.
N1 (BLOCKING): PluginSidePanel's shelf entries resolved icons through
_bindings.Assets.ResolveSprite (= ResolveChrome =
TextureCache.GetOrUploadRenderSurface), which returns a non-zero 1x1
magenta placeholder for a missing id - so PluginShelfButton's initials
fallback could never fire in production. RetailUiRuntime.MountPlugins
now passes the same iconResolver.ResolveDid every markup icon sink
already uses, which returns (0,0,0) for an unresolvable id. Re-pointed
the existing initials-fallback unit test at a resolver matching
ResolveDid's real contract, and added an InstalledDat-lane test
(ShelfButton_BogusDescriptorId_OnTheRealResolver_FallsBackToInitials)
proving a bogus descriptor id on the REAL resolver yields initials.
N2: MarkupDocument's <button icon>/<list icons> handling only built the
uint reader (BindUintLiteralOrBinding/BindUintList) when an
IMarkupIconResolver was wired, so a malformed icon="{Typo}" or
icons="notabinding" silently loaded instead of throwing at Build on a
resolver-less host. Both readers now build unconditionally (same rule
ValidateIconKind already followed); only the IconSource/IconIdsSource
assignment stays gated on icons is not null.
N3: a negative bound icon id threw OverflowException out of
Convert.ToUInt32 every frame from inside UiSimpleButton.OnDraw (scalar
path), while the list's IEnumerable<int> path silently wrapped -1 to
0xFFFFFFFF (unchecked reinterpret). Both paths now map any
out-of-range value (negative, or above uint.MaxValue) to 0u instead -
Decal's own "no icon" convention - via a shared ToUintOrZero helper
that catches exactly the OverflowException Convert.ToUInt32 already
throws for both cases.
N4 (perf): RetailMarkupIconResolver.ResolveDid probed Portal/HighRes
(two cache misses + two B-tree lookups under the database lock) on
EVERY call for an unresolvable id, and re-entered the DAT lock on
every resolve of a hit too. Memoizes the resolved (tex,w,h) tuple per
DID, including the (0,0,0) miss, in a plain Dictionary.
N5 (nit): documented in TextureCache.GetOrUploadRenderSurface that the
(id, nearest) cache key uploads the same RenderSurface twice when both
samplers are wanted (chrome via ResolveChrome, plugin icons via
ResolveDid's nearest:true) - GetOrCreateLinearUiTwin exists but only
shares in the nearest-registered-first direction, so wiring it through
here is left as a documented nit rather than a behavior change.
N6 (nit): corrected stale SampleData.cs:64 citations to :69 (Melee
Defense's real line after the file grew) across SmokeIconPanel.cs,
PluginSidePanelTests.cs, RetailMarkupIconResolverInstalledDatTests.cs,
and docs/plugin-ui-markup.md (including the 64-82 range, now 69-83).
N7 (nit): <icon iconkind="..."> was silently ignored (icon derives its
kind from which of did/spell/item is set, unlike button/list). Now
throws FormatException at Build with a message naming the correct
surfaces; documented in plugin-ui-markup.md.
N8 (nit): the truth-table's `list colors` row now says IEnumerable<uint>
or IEnumerable<int> (shared BindUintList), matching `list icons`.
N9 (ship check): added a "Before shipment" line to the plan's Review
ledger. Confirmed the Smoke plugin (including its auto-open Icon Smoke
panel) IS included in the launcher's client-<rid>.zip release payload:
tools/publish-bin.ps1's New-PayloadZip zips App's entire publish
directory unfiltered, and AcDream.App.csproj's
CopySmokePluginToPublishOutput target runs unconditionally
AfterTargets="Publish". No behavior changed per instruction - flagged
for follow-up after the owner's connected gate.
Verification: dotnet build AcDream.slnx -c Release green; filtered
test command 108/108 passed (0 skipped) including the InstalledDat
lane; full AcDream.App.Tests suite 7362 passed / 97 skipped / 36
failed (all 36 pre-existing, same names, none touching
Markup/PluginSidePanel/RetailMarkupIconResolver/TextureCache/PluginIcons);
AcDream.Plugins.MossTank.Tests 337/337 passed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner report: with the MossTank plugin window BEHIND the inventory window,
the plugin's "Force Buff"/"Cancel Force Buff" button border outlines drew on
top of the inventory paperdoll. Only outlines leaked; fills did not.
Root cause: TextRenderer composited three buckets per layer — submission-
ordered sprite segments, then ALL untextured DrawRect quads (_rectBuf), then
debug text (Flush/DrawLayer). UiRenderContext.DrawRect forwarded into that
separate rect bucket, which always flushed AFTER every sprite segment
regardless of submission order. UiRenderContext.DrawRectOutline is four
DrawRect calls, so every BorderColor outline in the retained UI (UiPanel,
UiMarkupList) composited above every window's sprite content drawn after it,
instead of only the windows actually painted before it.
Fix: UiRenderContext.DrawRect now forwards to DrawFill — the same untextured
SPRITE-bucket segment DrawFill already used for panel backgrounds — so
DrawRectOutline inherits real painter/submission order. Audited the only
other DrawRect caller (UiMeter's bg-then-bar fill, which already relied on
same-call submission order and is unaffected) and the only other
DrawRectOutline callers (UiPanel, UiMarkupList, both routed through the same
fixed chokepoint). TextRenderer.DrawRect/DrawRectOutline and the _rectBuf
bucket are left in place (not deleted) with updated doc comments noting no
caller in src/ outside TextRenderer itself reaches them anymore; a future
non-retained-UI caller (e.g. a debug overlay) could still want that
"always on top of sprites" behavior. Added TextRenderer.DebugRectVertexCount
(test-only) and a failing-first regression test
(UiRectOutlinePainterOrderTests) that builds a real UiPanel border behind a
later-added opaque sprite and asserts submission order.
No retail-divergence register row: this is a renderer ordering bug, not a
documented retail behavior deviation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
EVIDENCE (live UI probe dump at 1280x720, build 2ebcc0164): after collapsing
the plugin shelf, PluginSidePanel rect=(1240,233,24,28) was correct, but
ShelfGripPanel rect=(1240,233,20,18) and the toggle rect=(1260,233,16,18)
stayed UNCHANGED from the expanded geometry. The toggle then sat outside the
24px shelf and the ancestor clip removed it, so the owner saw the tab with
no "<".
ROOT CAUSE: UiElement.ApplyAnchor (src/AcDream.App/UI/UiElement.cs ~829-856)
runs for every child on every draw (called at :699). For any child whose
Anchors != AnchorEdges.None, it captures the Left/Top/Width/Height margins
ONCE (_anchorCaptured) on the first draw and re-applies that snapshot every
subsequent draw, overwriting whatever PluginSidePanel.LayoutChrome/Reflow had
just set. The grip, the toggle, and each PluginShelfButton entry were
constructed with the default Anchors (Left|Top), so their first-draw
geometry froze. The shelf itself already used AnchorEdges.None for exactly
this reason. Unit tests never caught it because UiRoot.Tick does not draw —
the anchor snapshot only exists after a real Draw pass, and the prior
draw-level toggle tests only ever drew once, before any collapse.
FIX: set Anchors = AnchorEdges.None on _grip, _toggle (PluginSidePanel
constructor) and each PluginShelfButton entry (PluginSidePanel.Add) — the
shelf is the sole layout owner of these children and anchoring is the wrong
mechanism for them, not a per-reflow patch via ResetAnchorCapture().
PluginMinimizeButton is untouched (it is a child of the plugin window and
deliberately anchors Top|Right).
TESTS (tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs):
- Collapse_AfterADraw_RepositionsGripAndToggle_NotFrozenAtExpandedGeometry:
draws the shelf, collapses via a real UiRoot press/release, draws again,
and asserts the grip/toggle geometry actually reflects the collapsed
Width/Height rather than the frozen expanded snapshot. Failed-first
(pre-fix) at line 364 with "Expected: 8, Actual: 20" (grip.Width frozen at
the pre-collapse value instead of the new collapsed Width - ToggleWidth).
- MultiColumnReflow_AfterADraw_EveryRemainingButtonMatchesAFreshSinglePassLayout:
12 entries, draw, unregister one window (a real removal), draw again, and
compares every surviving button's geometry against an independent
reference shelf built directly with the same final 11-entry set. Failed-
first with "Expected: 16, Actual: 48" (a surviving button's Left frozen at
its stale 12-entry column/row instead of the fresh 11-entry reflow).
Both tests use font: null (bitmap fallback) so they run in every CI lane
without an installed retail DAT, unlike the Lane=InstalledDat tests above.
VERIFY: dotnet build (Release) green for src/AcDream.App and the test
project. Targeted filter (PluginSidePanel|Markup|UiRootInput): 130/130
passed. Full tests/AcDream.App.Tests suite: 7353 passed / 97 skipped / 36
failed - matching the stated baseline (7351/97/36) plus the two new tests;
the 36 failures are the pre-existing environment-gated set (installed-DAT
version mismatch, Linux-only waiter, Lane=Manual live-mount probes) and are
unrelated to this change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two Opus reviews of commit 8217a349e (Slice B: DAT icons in plugin
markup) found one BLOCKING defect and 14 SHOULD-FIX findings. All 15
fixed here in one commit per the review contract.
BLOCKING (finding 1): an unresolvable did painted a magenta square.
TextureCache.GetOrUploadRenderSurface's 1x1 magenta placeholder for a
missing RenderSurface is load-bearing for authored chrome, but
RetailMarkupIconResolver.ResolveDid only short-circuited did==0, so any
other unresolvable id fell through to that placeholder and got scaled
up by UiMarkupIcon/UiMarkupList/UiSimpleButton -- the classic
resolve(0)-style footgun (claude-memory/feedback_ui_resolve_zero_magenta.md),
just triggered by a missing id instead of a literal 0. Fixed by probing
Portal/HighRes existence via IDatReaderWriter.TryGet<RenderSurface>
BEFORE ever calling GetOrUploadRenderSurface -- that TryGet already
serializes concurrent DAT access internally (DatDatabaseWrapper's own
_databaseLock), the same synchronization IconComposer.TryDecode relies
on, so no additional lock was needed. RetailMarkupIconResolver now
takes IDatReaderWriter + TextureCache directly (RetailUiAssets gained a
TextureCache field, its one construction site in
InteractionRetainedUiComposition.cs updated) instead of the old
resolveSprite delegate, since it also needs the nearest-sampled upload
path for finding 6 below.
Finding 2 -- Smoke panel wiring bugs: its list fed iconkind="spell"
raw art DIDs (PluginSpellInfo.IconId) instead of spell ids, so
IMarkupIconResolver.ResolveSpell composited the wrong (or no) badge
every row. SmokeIconPanel.Binding.SpellIds now yields SpellId (the
printed text still shows IconId alongside). The bare-index demo and the
descriptor both moved from the unverified literal 7735 to 0x165 --
retail's real Melee Defense skill icon (SampleData.cs:64,
0x06000165) -- so the owner's visual gate proves real art, not a guess.
StartVisible flipped true, and a character with no self-buffs known
falls back to spell 1's real catalog entry (or an honest "no spells
known" row with icon 0 if even that fails) rather than fabricating art.
Finding 3 -- PluginIcons.Normalize's threshold was silently rewritten
from the contract's 0x01000000 to 0x06000000 during Slice B. Restored
to 0x01000000; the class/method XML docs now state the number directly
(no cref to the private const); the test table adds 0x02000000 (a value
that only distinguishes the two thresholds) and 0x01000000 itself
(passes through unchanged).
Finding 4 -- an unknown iconkind (e.g. "spel") only threw when a
resolver happened to be wired, because BuildIconSource/
BuildRowIconResolve validated inside their own null-icons early return.
A new ValidateIconKind helper runs UNCONDITIONALLY before that branch,
so a malformed iconkind is a Build-time author error on every host.
Finding 5 -- BindUintLiteralOrBinding required an exact uint property
type, rejecting the int-typed bindings Decal-facing code commonly uses
(MosswartMassacre's HudPictureBox.Image is int end to end). It now
matches BindUint's existing leniency: any property, converted via
Convert.ToUInt32 at read time. BindUintList likewise now accepts
IEnumerable<int> alongside IEnumerable<uint> (unchecked per-element
reinterpret -- icon ids never go negative in practice).
Finding 6 -- TextureCache._renderSurfaceGpuTextures was keyed by id
alone, so whichever caller asked for a given RenderSurface id FIRST won
the sampler for every later caller of the same id -- UiDatFont's glyph
atlases already request nearest:true while ResolveChrome's background
art requests nearest:false, so this was a real, reachable collision,
not hypothetical. Rekeyed to (id, nearest); RetailMarkupIconResolver.
ResolveDid now requests nearest:true (pixel-exact 32x32 icon art);
ResolveChrome is untouched (still nearest:false/linear). Audited every
other _renderSurfaceGpuTextures use site (TryGetValue/set/Dispose
iteration+Clear) plus the separate _nearestUiTextureSources/
_linearUiTwinHandles/_uploadMetadata dictionaries (all keyed by handle
or accounting name, unaffected) -- no other eviction/accounting path
assumed id-only keying.
Finding 7 -- column-reservation semantics, per the DECIDED shape:
MarkupDocument now sets button.IconSource / list.IconIdsSource +
IconResolve ONLY when a resolver (icons parameter) is actually wired --
previously button.IconSource was always assigned (even to an
always-empty func on an icons:null host); combined with this finding's
other half -- UiSimpleButton.OnDraw now reserves its icon column
whenever IconSource is non-null, regardless of a per-frame resolve miss,
so a bound id that goes briefly to 0 no longer slides the caption back
and forth -- would have permanently reserved a blank column on such a
host. UiMarkupList already reserved its column whenever IconIdsSource
was set; no draw-side change needed there.
Finding 8 -- added a with/without-icons comparison test for
UiMarkupList (mirroring the existing UiSimpleButton one): asserts the
row text quad's x is strictly greater with an icon column present, and
the icon quad itself has non-zero width.
Finding 9 -- <icon tooltip=""> (empty string) was still treated as
"has a tooltip" by a bare attribute-presence check, making the icon
swallow clicks with no visible tooltip ever appearing. Now uses
!string.IsNullOrWhiteSpace, matching ApplyCommon's own predicate for
every other element's tooltip.
Finding 10 -- PluginShelfButton.OnDraw drew nothing when a non-zero
descriptor icon id resolved to no texture (a bad Decal index, a DAT id
from a different install), rather than falling back to Initials the
way a zero id already did. Now decides once, on the first draw
(memoized, so Initials' string work never repeats every frame): a
failed resolve permanently switches Text to the initials fallback,
computed and assigned BEFORE base.OnDraw actually paints the caption.
Finding 11 -- MarkupDocument.AddElement's switch had no default arm, so
an unknown or miscased element name (<Icon>, <butotn>) silently
vanished from the built tree instead of failing loudly like every
other malformed-markup case. Added a default arm that throws
FormatException. Ran AcDream.Plugins.MossTank.Tests (337/337,
unchanged) and the full App markup suite to confirm no existing markup
relies on an unknown element.
Finding 12 -- PluginPanelDescriptor.IconSurfaceId's XML doc now states
that a bare Decal index is accepted and normalized, citing
PluginIcons.Normalize.
Finding 13 -- docs/plugin-ui-markup.md: replaced the blanket "wrong
type/missing property throws at Build" sentence with the per-attribute
truth table the review produced (which attributes are silent at
runtime vs. throw at Build, and each one's bound CLR/delegate type);
restated the icon-id boundary as 0x01000000; added the "do NOT add
0x06000000 to the four already-full IconId records" warning (citing
SkillBase._iconID / UIRegion::SetImageByDID @0x004f150e); documented
that 0x-prefixed hex is required (an unprefixed all-digit literal
parses as decimal); noted unknown element names now throw; called out
list colors (0xRRGGBB) vs. color=/background=/border= (#AARRGGBB) as
non-interchangeable grammars; documented the root <panel visible>
binding-only exception; corrected the shelf's collapse toggle glyphs
(</>, not the old doc's arrows) and the 28px collapsed-tab size; added
the IconId record-equality API-v1 note; and called out iconkind as
per-<list> (mixed id spaces need pre-normalized DIDs; the composited
spell badge has no did-space escape hatch) plus the existing
one-text-column LIMITATION being deferred to MossTank.
Coverage added for finding 14: a PluginSidePanelTests case proving the
shelf button normalizes a bare descriptor index before resolving, and a
reflection-based unit on AppAutomationSurface.ProjectWorldObject (its
public callers gate on IsAvailable, which needs a fully connected
session heavier than this mapping needs -- the plan's own documented
fallback) proving PluginWorldObject.IconId carries ClientObject.IconId
through unchanged; PluginInventoryItem.IconId uses the identical
one-line pattern inline in CaptureOwnedItems, reviewed by inspection.
Finding 15: recorded a "Review ledger" section in the plan doc with
both slices' commits, both review verdicts, and the two items
explicitly deferred to the MossTank plugin work (multi-column list,
root literal visible).
Verification: full solution builds green. Targeted filter
(Markup|PluginSidePanel|PluginIcons|AppAutomation|TextureCache|
UiDatFont) passes 131/131, including the two InstalledDat-lane tests
(RetailMarkupIconResolverInstalledDatTests,
AppAutomationSurfaceIconInstalledDatTests) actually resolving against
the real installed DAT, not skipping. AcDream.Plugins.MossTank.Tests
passes 337/337 unchanged. Full AcDream.App.Tests suite: 7351 passed /
97 skipped / 36 failed -- identical failure set/count to the
7334/97/36 baseline (the +17 passes are exactly the new/expanded
tests: 2 new PluginIconsTests.Normalize theory rows, 10 new
MarkupIconTests cases, 2 new PluginSidePanelTests cases, 1 new
AppAutomationSurfaceTests case, and the 2 new standalone test files).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner report: "I dont see the < after I minimize the window" — after
collapsing the plugin shelf with the > toggle, the < (expand) glyph was not
visible.
Root cause, established with a real-DAT draw probe (font 0x40000000) before
changing anything: '<' and '>' share IDENTICAL glyph metrics (OffsetY=4,
Width=5, Height=7, VerticalOffsetBefore=5), so there is no per-glyph
asymmetry to explain "I see one but not the other." Against the pre-fix 12px
grip band, the toggle's FILL glyph plane measured fully INSIDE the band in
both states (local y=[3,10] of [0,12]) — the "16px line box overhangs a 12px
band" theory alone does not erase the glyph, so a bare clip fix would not
have addressed the report. What IS true: the border-inflated OUTLINE
(background/shadow) plane, drawn first per retail's UIElement_Text::DrawSelf,
spans y=[-1,14] before clipping and was cropped by the band's self-clip
(UiElement.ClipsChildren) to exactly [0,12] — a real but minor defect. The
actual explanation for the report is discoverability: the collapsed shelf
shrank to a bare 24x12 near-black sliver at the screen edge, several times
smaller than any other clickable affordance in the UI — easy to overlook even
though its pixels were, in fact, being drawn.
Fix (src/AcDream.App/UI/PluginSidePanel.cs):
- ExpandedGripBandHeight (new internal property) derives the EXPANDED
grip/toggle band from the real font metrics — max(12, font.LineHeight + 2)
— so neither the fill nor the border-inflated outline plane can clip for
any font; the 12px constant remains only as the bitmap-font fallback.
Threaded through the ctor's initial Height, OnTick's row-wrap height calc,
LayoutChrome, and Reflow's entry-Top/expanded-Height math.
- The COLLAPSED tab is now ButtonExtent (28px) tall instead of the 12px grip
band — the same size as an ordinary entry button — with the toggle glyph
filling and centering in the taller band. This is the actual fix for the
report: the collapsed affordance is now button-sized and findable, not a
bug-for-bug-identical-but-larger clip fix.
Tests:
- tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs (new,
Lane=InstalledDat): loads the real DAT font, builds a live shelf, and
proves via TextRenderer.DebugSpriteSegmentVerts that the toggle's ink (fill
+ outline) is fully contained in its own clip band in BOTH the expanded
('>') and real-click-collapsed ('<') states, plus that the collapsed tab is
button-sized. Verified failing against the pre-fix code (git stash of just
this file) with concrete numbers: computed unclipped span [-1,14] does not
fit inside the 12px band; collapsed height measured 12 (not button-sized).
Passes after the fix.
- tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs: added
CollapseThenExpand_WhileStillDocked_ReturnsToTheIdenticalLeftAndTop (the
dock/anchor invariant survives the collapsed-height change), and derived
Drag_StartingOnShelfPadding_DoesNotMoveTheShelf's press-below-the-grip-band
Y coordinate from the new ExpandedGripBandHeight accessor instead of a
re-hard-coded literal.
Verification: dotnet build (App + tests) green. Filtered run
(PluginSidePanel|UiDatFont|Markup|UiRootInput) 131/131 passed, 0 skipped —
the InstalledDat lane tests actually ran (DAT dir resolved). Full
AcDream.App.Tests suite: 7334 passed / 97 skipped / 36 failed (baseline was
7331/97/36) — the 3 new tests are the only delta; the 36 failed test names
are byte-identical to the pre-existing set (cathedral collision installed-dat
gates, alpha-flush conformance, layout live-mount probes, Linux frame-pacing,
credential resolver — all unrelated to this change).
No retail-divergence register change needed: IA-27 already covers the
plugin shelf's non-retail collapse-toggle glyphs/behavior in general; this
is a bug fix within that already-declared deviation, not a new one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner request: plugin panels (Decal/VirindiViewService-class, per the
MosswartMassacre reference usage) need to embed real DAT icons the way
FlagTrackerView.SafeSetListImage does — spell/skill art, raw portal
indices, and a window icon. This is Slice B of
docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md (Slice A, the
movable/collapsible shelf, landed in 01b98ca30/4fada238e/718005b21).
What shipped:
- AcDream.Plugin.Abstractions.PluginIcons.Normalize: the one Decal-style
bare-index -> 0x06xxxxxx RenderSurface DID grammar, applied at every
icon SINK (descriptor IconSurfaceId in PluginShelfButton, and markup
<icon did>/<button icon>/<list icons> did-kind ids) rather than on the
plugin-facing records, which already carry real DIDs read straight
from the client's tables.
- PluginSpellInfo.IconId / PluginSkillInfo.IconId /
PluginInventoryItem.IconId / PluginWorldObject.IconId: additive init
properties (default 0), filled in AppAutomationSurface from
SpellMetadata.IconId (already projected from SpellBase.Icon by
RetailSpellMetadataProjector — no gap there), a new BindSkillIcons
parallel to BindSkillNames (GameWindow reads
DatReaderWriter.Types.SkillBase.IconId — confirmed via reflection over
the installed Chorizite.DatReaderWriter package, since its XML docs
don't cover Pack/Unpack-generated public fields: Description, Name,
IconId (uint), TrainedCost, SpecializedCost, Category, ChargenUse,
MinLevel, Formula, UpperBound, LowerBound, LearnMod), and
ClientObject.IconId in CaptureOwnedItems/ProjectWorldObject.
- IMarkupIconResolver (AcDream.App.UI): ResolveDid/ResolveSpell/
ResolveItem. MarkupDocument.Build gains an optional parameter (null by
default -> every icon sink resolves to nothing rather than throwing,
so pre-Slice-B callers/tests are unaffected). RetailUiRuntime.
MountPlugins builds ONE RetailMarkupIconResolver per pass from
RetailUiAssets.ResolveSprite + RetailUiAssets.Icons (the shared
IconComposer) + Toolbar.Objects (the SAME ClientObjectTable
Magic/Toolbar bindings already borrow for their own icon resolution —
no second object lookup introduced).
- New UiMarkupIcon widget (<icon x y w h did|spell|item tooltip>):
exactly one source required (FormatException at Build otherwise,
matching every other malformed-attribute rule), aspect-preserved,
centered, click-through unless a tooltip makes it a real hit-test
target.
- UiSimpleButton.IconSource and UiMarkupList.IconIdsSource/IconResolve:
additive, default null/no-op, so every existing button/list caller
(including the plugin shelf's own toggle/minimize buttons) is
unaffected. Button icon draws flush left and shifts the caption's
centering region right; list icons reserve a leading RowHeight-2
column (Decal's IconColumn) and skip rows whose id is 0 or
unresolvable.
- MarkupDocument centralizes the did/spell/item dispatch (including
PluginIcons.Normalize for did) in two small helpers (BuildIconSource
for <icon>/<button>, BuildRowIconResolve for <list>) so all three
markup surfaces share one resolver call path.
- AcDream.Plugins.Smoke ships a RegisterPanelContent (in-memory KSML,
no plugin-side .xml file) proof panel exercising every new surface:
a bare-index <icon>, a literal-hex <icon>, a composited <icon
spell=...>, a <button icon=...>, and a <list icons=... iconkind=
spell> of the first five known self-buffs with their IconId printed
alongside. Descriptor IconSurfaceId reuses the same bare index to
prove the shelf button and the panel's own icon normalize identically.
- docs/plugin-ui-markup.md is the new SSOT for the full markup
vocabulary + icon grammar + the Slice A shelf; linked from
docs/README.md and docs/plans/2026-04-24-ui-framework.md.
Design decisions where the plan left room:
- Normalize runs inside the resolver dispatch (BuildIconSource/
BuildRowIconResolve), not scattered at each markup call site, so
every did-kind sink shares one choke point.
- did/spell/item all accept either a literal (decimal or 0x-hex) or a
{Binding}, via one BindUintLiteralOrBinding helper, for symmetry —
the plan only showed spell/item as bindings but didn't forbid a
literal.
- <icon> requires exactly one source INCLUDING zero (not just two);
an icon with no source is not a coherent element.
- The button/list icon draw math (icon column extent, padding) lives
in the widgets themselves (UiSimpleButton/UiMarkupList), not in
MarkupDocument, keeping the parser only responsible for wiring
Func<(tex,w,h)> sources.
Tests: PluginIconsTests (Normalize table), MarkupIconTests (icon/button/
list resolver dispatch via a fake IMarkupIconResolver, plus draw-level
pins via the RecordingGpuDevice/TextRenderer apparatus already used by
UiAncestorClipTests/UiRenderContextDrawStringDatOutlineTests — "draws
nothing when unresolvable" and "button/list icon shifts the text"),
and AppAutomationSurfaceIconInstalledDatTests (Lane=InstalledDat: a
known spell's IconId matches the real installed SpellTable's own Icon
field exactly). Verified every new test fails to COMPILE without this
change (git-stashed the src/ changes, rebuilt the test project: CS0246
on IMarkupIconResolver) before restoring. Full App suite: 7331 passed /
97 skipped / 36 failed (identical pre-existing failure set/count to the
7306/97/36 baseline; the +25 passes are exactly the new tests).
AcDream.Plugins.MossTank.Tests (the main consumer of the touched
Plugin.Abstractions records) passes 337/337 unchanged, confirming
API-v1 binary/source compatibility. Full solution builds green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Opus re-review of 4fada238e (Slice A review-fix round) left seven residuals.
All seven addressed here, in one commit per the residual-round contract:
NEW-1 (test-coverage): no test pinned the Draggable:true->false change
itself. Added Drag_StartingOnShelfPadding_DoesNotMoveTheShelf, pressing the
shelf's own PADDING (left of the first button's Left=4, below the 12px grip)
and dragging 100px through the real UiRoot press/move/release path. Proven
to fail against the pre-4fada238e shelf: temporarily setting Draggable=true
in the ctor and re-running this test moved the shelf from Left=760 to 764
(clamped to parent.Width-Width) instead of leaving it in place — reverted
after confirming.
NEW-2 (correctness): RetailWindowLayoutPersistence.ClampAllToScreen clamps
generically to screen.Width-handle.Width, 4px (OuterPadding) off what the
shelf's own dock formula (parent.Width-Width-OuterPadding) would produce for
the same new parent size. A screen shrink that triggers that clamp used to
flip _userPositioned permanently even though nothing a user would call
"dragging" happened. PluginSidePanel.OnHandleMoved now re-derives "still
docked" at comparison time as a function of the CURRENT parent size: either
the live dock formula's own result, or what ClampAllToScreen's clamp would
produce from the PREVIOUS docked position — only a position matching
NEITHER flips _userPositioned. New test
ClampAllToScreen_AfterShrinkingTheRoot_DoesNotFlipAnchoring_ButARealDragStillDoes
shrinks the root, runs ClampAllToScreen, confirms the shelf still anchors
top-right through a later reflow and a collapse, then confirms a genuine
grip drag afterward still flips anchoring.
NEW-3 (decision, documented): KeepWindowReachable's per-tick clamp and
ClampAllToScreen's screen-resize sweep both touch plugin windows. Evidence
gathered and recorded as a doc comment on KeepWindowReachable:
RetailWindowManager.MoveTo (line 178) already short-circuits an unchanged
position before ever raising Moved; persistence's ScreenSize and Host.Root's
size derive from the same d.Window.Size and are reconciled every frame
(UiHost.Draw sets Root.Width/Height from screenSize); and because
RetainedGameplayUiFrame.Render ticks BEFORE it draws in the same frame,
ClampAllToScreen always resolves a screen-resize's clamp before
KeepWindowReachable ever observes the new size next tick, making
KeepWindowReachable's own pass a structural no-op for that case (never a
second write). KeepWindowReachable is NOT dropped, though: it is the only
reachability guarantee for (a) a plugin window whose geometry is mutated
directly rather than through MoveTo — pinned by the pre-existing
FullWidthPluginWindowStartsAndStaysReachableAtMinimumCanvas test, which
exercises exactly that with no screen resize at all — and (b) a host wired
with no RetailWindowLayoutPersistence at all. Decision: keep both; they do
not race.
NEW-4 (robustness): RetailWindowLayoutPersistence._attached is mutated
mid-session by WindowRegistered/WindowUnregistered (a callback invoked from
inside one of these loops — e.g. Apply -> Show()/Hide() -> a controller
unregistering another window — could otherwise mutate the list being
enumerated). All five bare `foreach (... in _attached)` loops
(RestoreAllCore, ClampAllToScreen, SaveAll, SaveNamed, RestoreNamed) now
snapshot with .ToArray(), matching Dispose's existing pattern.
NEW-5 (retail-faithfulness): the plugin-shelf hide message hard-coded
"Shift+Ctrl+F1" even though InputAction.TogglePluginManager is rebindable
through Configure Keyboard. RetailUiRuntime.PluginShelfHiddenMessage now
looks up the CURRENT binding via the live InputDispatcher and formats it
through Layout.RetailKeyNames.Describe — the exact formatter Configure
Keyboard's own row captions already use
(Layout/KeyboardConfigController.cs:284) — falling back to an honest
"bind it in Configure Keyboard" message when the action is unbound or no
dispatcher is wired.
NEW-6 (bookkeeping): added divergence-register row IA-27 for the plugin
shelf's repurposing of retail's plugin-manager chord (Shift+Ctrl+F1),
its ASCII </> toggle glyphs, its two acdream-authored system messages, and
its Draggable=false grip-only drag model — citing PluginSidePanel.cs and
the RetailUiRuntime.cs TogglePluginManager case. Header count bumped
23->24 active IA rows.
NEW-7 (correctness): the one-time dock wrote Left directly, so the
first-run docked position went unsaved until some later, unrelated event
happened to trigger a save. PluginSidePanel.OnTick now routes the one-time
dock through the retained-window handle's MoveTo when registered (falling
back to the direct field write when unregistered, as before) —
_dockLeft/_dockTop are set to the TARGET position BEFORE calling MoveTo so
the synchronous OnHandleMoved re-entry it triggers (NEW-2's logic) reads the
new dock position and does not flip _userPositioned. New test
FreshShelf_OneTick_SavesTheDockedPositionImmediately confirms the store has
the docked X/Y after exactly one tick and that a subsequent collapse still
preserves the right (not left) edge.
Verification: dotnet build src/AcDream.App (Release) green; targeted filter
(PluginSidePanel|RetailWindowLayout|Markup|UiRootInput|KeyboardConfig) 145
passed / 2 pre-existing Lane=Manual failures (unrelated, gated on
ACDREAM_PROBE_LIVE_MOUNT=1); full tests/AcDream.App.Tests suite: 7306
passed / 97 skipped / 36 failed — same 36 pre-existing failures by name
(installed-DAT/Cathedral/Linux/Lane=Manual probe tests), +3 passed vs the
7303 baseline (exactly the three new tests this round added).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two independent Opus reviews of 01b98ca30 (Slice A - movable, collapsible
plugin shelf) found blocking design and behavior issues plus several
should-fix gaps. All addressed in this commit:
1. Grip/toggle are now REAL children instead of a drawn band + a
cursor-position-dependent HandlesClick override + an OnEvent toggle
hack: ShelfGripPanel (WindowMoveHandle=true) spans the top band minus
the toggle width; a UiSimpleButton toggle sits beside it (HandlesClick
already wins at UiRoot.OnMouseDown before the Draggable-window
fallback). The shelf's own Draggable is now FALSE - verified against
UiRoot.FindDragHandleWindow, which never reads a window's own
Draggable flag at all (it walks for a WindowMoveHandle ancestor-or-self
then climbs to the nearest child of UiRoot), so Draggable=true was
never required for the grip to work and only armed the whole-window-
drag fallback for clicks on the shelf's own padding - exactly the
behavior the review asked NOT to have. The two Assert.Single(shelf.
Children) test sites now filter by the (now internal) PluginShelfButton
type instead of asserting child count.
2. Deleted the per-tick "always highest ZOrder" raise in OnTick. It fought
RetailDialogFactory.Tick's own per-frame dialog re-raise, so a dialog
opened while the shelf was visible could never end up on top of it.
Registration's ordinary press-to-raise (a grip press calls
BringToFront before the drag starts) remains.
3. Collapse and hide/show intent are now persisted through their own
channel. RetainedWindowState gained a nullable RequestedVisible;
RetailWindowHandle gained an internal StateChanged event that
RetailWindowLayoutPersistence subscribes to (alongside Moved/Resized/
Shown/Hidden) and that the shelf raises after a collapse toggle or
Show/Hide. Capture() now persists state.RequestedVisible (the panel's
own intent) instead of the derived IsVisible, so an availability hide
(last plugin window unregistered) is never mistaken for a user hide.
WindowNames.PluginShelf is now one of RetailUiRuntime's state-managed
visibility windows, so Apply() restores the intent through
RestoreWindowState directly rather than via Show()/Hide().
4. RetailWindowLayoutPersistence now subscribes to
RetailWindowManager.WindowRegistered/WindowUnregistered so a window
(a plugin window loaded after startup, or the shelf on any path that
constructs persistence first) attaches even when it registers after
persistence already exists.
5. Reflow()'s default height argument is now nullable and falls back to
the last height OnTick actually measured (or unbounded if none yet),
instead of always forcing a single-column layout - every call site
OTHER than OnTick's own row-wrap (Add, unregister, Show/Hide, the
collapse toggle, RestoreWindowState) used to collapse a wrapped
multi-column layout to one column for a frame.
6. _userPositioned is now flipped only when the handle's position differs
from the recorded dock placement, not on every RetailWindowHandle.
Moved (which fires unconditionally on any completed window-drag
release, including a zero-movement grip click, and on any
ClampAllToScreen reachable-clamp).
7. New tests cover: a press+drag starting on an entry button does not
move the shelf; the removed per-tick raise (a sibling with higher
ZOrder keeps it after a tick); TogglePluginManager's hidden-and-
collapsed -> shown-and-expanded / visible -> hidden transitions at the
shelf API (no RetailUiRuntime construction harness exists in this test
suite to exercise the action-routing switch itself - the "no plugin
windows registered" message branch is therefore not covered here).
8. The hide branch of TogglePluginManager now displays "Plugin shelf
hidden. Press Shift+Ctrl+F1 to show it again."; the show branch stays
silent.
9. The collapse toggle now draws ASCII '<'/'>' instead of the DAT-font-
dependent '«'/'»' glyphs (the only use of those code points in the App
UI, silently dropped by UiDatFont when absent), and gets the same
DatFont + bitmap fallback the shelf's entry buttons already have
through UiSimpleButton. A new installed-DAT test pins that the default
font actually carries both ASCII glyphs.
10. ResizeX/ResizeY are false on the shelf so a restored layout's saved
dimensions can never stomp the derived Width/Height via ResizeTo.
11. WindowNames.PluginShelf replaces the "plugin-shelf" literal at every
site (RetailUiRuntime, docs comments, tests).
12. The grip dims to half opacity while RetailWindowManager.IsLocked, the
same visual cue every other retail window gets (the shelf's grip has
no DatElementId, so RetailWindowLockPresentationController's authored-
chrome dimming does not reach it on its own).
Every new test was verified to fail against the pre-fix source: reverting
src/AcDream.App/UI/PluginSidePanel.cs, IRetainedWindowStateController.cs,
RetailWindowHandle.cs, WindowNames.cs, and RetailUiRuntime.cs to their
01b98ca30 state makes the whole PluginSidePanelTests.cs file fail to even
compile (missing WindowNames.PluginShelf, the now-internal
PluginShelfButton type, and RetainedWindowState.RequestedVisible);
reverting RetailWindowLayoutPersistence.cs alone (fixed source elsewhere)
makes WindowRegisteredAfterConstruction_StillRoundTrips fail at runtime
with a null saved layout, confirming finding 4 in isolation.
Verified: dotnet build src/AcDream.App (Release) green; dotnet test
tests/AcDream.App.Tests (Release) targeted filter
(PluginSidePanel|RetailWindowLayout|Markup|UiRootInput) 105/105 green;
full suite 7303 passed / 97 skipped / 36 failed (identical failure set to
the pre-fix-round baseline - installed-DAT live-mount probes, Linux-only
pacing/credential tests, and alpha-flush COUNT-only conformance
divergences, none touching plugin UI; +9 tests, all passing, over the
prior 7294/97/36 baseline).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner request (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md, Slice A):
the plugin shelf was pinned to the right screen edge every tick, had no drag
or hide affordance, and never persisted position. It is now a registered
retained window ("plugin-shelf") that gets drag, the UI lock, and
RetailWindowLayoutPersistence position/visibility/collapsed persistence for
free, the same way every other retained window does.
Design decisions where the plan left room:
- Grip + collapse toggle are drawn, not child elements. A real child for the
toggle would have to live outside any WindowMoveHandle grip subtree (nesting
it inside lets UiRoot's drag-handle promotion swallow the press before the
button ever sees a click - `handleWindow is not null` outranks
`HandlesClick` in UiRoot.OnMouseDown), and a grip element as a plain sibling
changes UiElement.Children's shape, which the pre-existing single-button
shelf tests assert directly (`Assert.Single(shelf.Children)`). Keeping the
whole shelf Draggable=true and excluding just the toggle's pixel rect from
an overridden HandlesClick (computed live from UiRoot.MouseX/MouseY, the
only call site) gets grip-drags/buttons-and-toggle-don't without adding any
child or touching the existing tests' shape assumptions.
- Availability (has plugin windows) vs the user's requested-visible intent
are split the same way PluginWindowVisibilityController already splits it
for individual plugin windows, but applied SYNCHRONOUSLY (not via
VisibleSource) so Visible updates immediately after Add()/unregister with
no dependency on a Tick ever running - required to keep the pre-Slice-A
unregistered-shelf test (ShelfAndMinimizeButtonsHideAndRestoreWithoutUnregisteringWindow)
green, since it never calls root.Tick().
- "The shelf was moved" (drag or a differing restored layout) is tracked via
the shelf's own RetailWindowHandle.Moved event, captured through
WindowManager.WindowRegistered the moment MountPlugins registers it - so
unregistered/legacy use (the two other pre-existing tests) never sets this
and behaves exactly as before.
- The one-time right-edge dock (no saved layout) fires on the first OnTick
with a real parent width, replacing the old per-tick pin; Reflow's
anchor math then preserves the top-right corner while still docked or the
top-left corner once positioned, on any width change (entry add/remove,
collapse, or a parent-height-driven column rewrap).
Caption finding: the Configure Keyboard row for InputAction.TogglePluginManager
resolves its label live from the installed DAT's action-map string table
(KeyboardConfigController.BuildActionRow, RetailActionMapRow.LabelHash) -
there is no "Plugin Manager" string literal anywhere in our code to rename to
"Plugin Shelf". The row keeps showing retail's own authored name; only the
acdream-side action semantics changed.
Tests added to PluginSidePanelTests.cs (all 7 fail to even compile against
the pre-Slice-A PluginSidePanel, verified by temporarily reverting the source
files and re-running): default right-edge dock; top-right corner preserved
across a Reflow-driven width change while docked; grip drag moves the shelf
and top-left survives the next reflow once positioned; drag refused under
UiLocked; collapse via the real toggle click round-trips through
CaptureWindowState/RestoreWindowState; Show/Hide toggle sequence and a hidden
shelf staying hidden when a new plugin window registers; a full
RetailWindowLayoutPersistence round trip of X/Y/Visible/Collapsed onto a
fresh shelf instance. All 3 pre-existing tests remain green unmodified.
Verified: dotnet build src/AcDream.App (Release) green; the full App test
suite passes 7294/97 skipped/36 pre-existing unrelated failures (identical
failure set confirmed present on HEAD before this change - installed-DAT
live-mount probes, Linux-only pacing/credential tests, and known alpha-flush
COUNT-only conformance divergences, none touching plugin UI).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Issue #464 needs a repeatable tilted self-gate frame at the cathedral
stair-arch pose, but the automation's `drag at`/`mousemove` verbs only
move the retained-UI cursor — they never reach mouse-look, so every
scripted route replays the DEFAULT camera and can't reproduce the
owner's upward-tilted frame (docs/ISSUES.md #464, the 2026-09-03
22:07 transcript note: "the automation's `drag at` verb does not
drive mouse-look").
Add `mouselook <dx> <dy>`: it calls the SAME
GameplayInputFrameController.QueueRawMouseDelta the real mouse's Silk
MouseMove callback drives (CameraPointerInputController.
ProcessMouseMove), through an injected delegate threaded
RetailUiAutomationScriptRunner <- RetailUiProbeBindings <-
InteractionRetainedUiDependencies.GameplayInputFrame. That last seam
is resolved fresh on every call rather than captured once at mount,
since GameplayInputFrameController is created per live session
(SessionPlayerComposition), strictly after the retained UI composes
and across reconnects — the same never-capture-a-deferred-Func
discipline the secure-trade command-bus regression taught
(claude-memory/feedback_resolve_deferred_funcs_per_call.md).
The delta only takes effect while mouse-look is active (bracket with
`input down`/`up CameraInstantMouseLook`) and one call consumes
exactly one raw sample on the controller's next tick, so a route must
`sleep` between calls — documented on DoMouseLook and the class doc
comment.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
Two owner-reported defects at the Campaign AS connected gate on the
examination window (player targets): the animated paperdoll no longer
renders at all, and a "reserved black rectangle" appears at the window's
bottom with the character extras list clipped mid-row at default (310x400)
window size.
ROOT CAUSE — extras-list overflow (the "clipped mid-row" half of defect 2):
NOT a code bug. AS3 (armor-level trio) and AS4 (society/allegiance/
configurable extras) grew the extras list past its DAT-authored 87px region
(element 0x10000335) at the window's minimum size — a new hermetic
regression test proves the worst-case combination (every AS3+AS4 addition
at once) reaches 20 rows / 400px of content, a 4.6x overflow. But retail's
own LayoutDesc authors NO scrollbar for this listbox either
(ScrollbarElementId == 0, verified against both the committed fixture and a
fresh tools/LayoutDump read of the live installed DAT — no drift), and the
SAME test proves UiItemList's pre-existing, unmodified wheel-scroll handler
(OnEvent's UiEventType.Scroll branch) already reveals every row on the next
paint. A scrollbar-less, wheel-scrollable list clipped to its authored
region until the user scrolls or resizes IS retail's own already-correctly-
ported mechanism, not a regression — so no fix was made here.
ROOT CAUSE — paperdoll / "black rectangle" (defect 1): NOT ISOLATED despite
exhaustive investigation. Every file the Campaign AS diff touches
(AppraisalUiController.cs, RetailUiRuntime.cs, CreatureAppraisalRows.cs,
AllegianceRankTitleTable.cs, CharacterIdentityText.cs,
CharacterSheetProvider.cs, InteractionRetainedUiComposition.cs, plus two
unrelated mechanical PublicWeenieFlags-literal refactors) was reviewed in
full against the pre-Campaign-AS baseline. The same worst-case regression
test proves Apply/ApplyCreature/RebuildCreatureStats/BuildExtra never throw
and always leave ActiveView == Character, CurrentObjectId != 0, and the
viewport's full ancestor-visibility chain Visible == true — ruling out
RetailCreatureAppraisalFrameView.TryGetVisibleTarget's first three gates.
CreatureAppraisalPresentation.cs and LivePresentationComposition.cs (the
entire render-time viewport pipeline) are byte-for-byte unchanged across
the whole 974fe88a..87e98395 window. UiViewport.OnDraw draws NOTHING (not
black) when its TextureSlot is unassigned, and the creaturePanel's own
full-panel backdrop (0x10000141) is what would show through instead — the
most likely explanation tying both defects to ONE underlying condition, but
its exact trigger (TryGetVisibleTarget's CurrentObjectId check, or
TrySynchronize's live-entity/mesh-availability check) lies in code nothing
in Campaign AS touches, and could not be reproduced hermetically (needs a
live entity + a live examine exchange).
Filed #443 with the full investigation trail. Added a temporary,
state-change-gated diagnostic probe (ACDREAM_PROBE_CREATURE_APPRAISAL_
VIEWPORT=1, CreatureAppraisalViewportDiagnostics) at both
TryGetVisibleTarget and TrySynchronize so the next live repro pinpoints the
exact failing reason instead of another guess. Per CLAUDE.md's "no
workarounds without explicit approval" and the investigation mode's own
escape hatch ("if you cannot root-cause, say what runtime evidence you
need instead of shipping a guess"), no behavioral fix was shipped for
defect 1.
Tests: AcDream.App.Tests hermetic filter 6,337/0; full-solution hermetic
suite 15,612/0 (all 14 projects green, including the known #442 flake,
which did not trip this run).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The AS5 review verified the port exhaustively (170/170 strings, 20
dispatch arms, 17 bounds tests, both call sites) and found one docs-class
defect: five code comments plus the plan slice text claimed AP-109 was
RETIRED while the register correctly keeps the row ACTIVE-narrowed (CT4
FormatXp GetNumberFormatA approximation sliver survives). Comment-only
edits; compile-checked; no behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the 17-function heritage×gender AllegianceSystem::GetTitle
@0x005B8DD0 dispatch and AllegianceData::GetFullName @0x005B6950 as
AllegianceRankTitleTable (src/AcDream.App/UI/Layout/), then wires both
retail call sites: the examination window's title bar
(AppraisalUiController.BuildCharacterTitleBarName, from ApplyCreature's
character branch) and the character panel's name line
(CharacterSheetProvider.BuildSheet).
Census confirmed directly against the decomp (matches AP-109's
2026-08-25 correction exactly): 11 heritages -> 17 functions. Gearknight
and Tumerok author only a MALE Get*Title function, reused for both
gender dispatch branches; Lugian authors only a FEMALE one, reused for
both. Heritage id 0xA (Penumbraen) aliases to the Shadowbound (5)
functions on both branches. Olthoi/OlthoiAcid (12/13) are excluded by
GetTitle's own unsigned range check (heritage-1) <= 0xa. Every one of
the 17 functions shares an identical unsigned rank bounds test
(rank-1) > 9 -> no title (valid range 1..10; there is no "clamp to
rank-10 title" behavior for an out-of-range rank). All 170 title
strings transcribed verbatim from the decomp, including several
PE-byte-recovered data-literal indirections in the Sho/Gearknight/
Tumerok tables ("Kou", "Ou", "Dux", "Ona", "Rea", "Tah") that match
published AC lore exactly.
GetFullName: title = GetTitle(rank, heritage, gender); when GetTitle
resolves nothing, the output is the plain name; when it resolves, the
output is "title" + a single ASCII space (PE-byte-recovered at
data_794098) + name.
Two call sites independently re-verified against the decomp for the
rank/heritage/gender property ids: CharExamineUI::SetAppraiseInfo's
local AllegianceData struct (proven by its ctor/dtor pair) never shows
an explicit field write for _rank/_hg/_gender in the decompile — a
Binary Ninja struct-flattening artifact, not a missing read — while
gmStatManagementUI::UpdateCharacterInfo shows the same three
CBaseQualities::InqInt(0x71/0xbc/0x1e) calls as plain, unambiguous
locals, confirming Gender=0x71/HeritageGroup=0xBC/AllegianceRank=0x1E
as the three inputs at both sites (ruling R8: read live off the
appraisal/qualities bundle, never RuntimeAllegianceState).
Register: AP-109 is NARROWED, not retired. Its stated risk (a ranked
character's Name line showing plain-name-only) is closed, but the same
CT4 narrowing also flagged FormatXp's non-byte-identical
GetNumberFormatA approximation as "this row's own residual sliver if
one is ever found" — that caveat is untouched by this slice and is now
the row's only surviving open item.
Tests: AllegianceRankTitleTableTests (per-function golden values,
gender-reuse rules, Penumbraen alias, Olthoi exclusion, rank/heritage/
gender bounds, GetFullName composition); AppraisalUiControllerTests
(title-bar prefix, plain-name fallback, monster-path regression pin);
CharacterSheetProviderTests (name-line prefix + plain-name fallback).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the last three retail extras-list row families from CharExamineUI::
SetAppraiseInfo @0x004B45F0 into CreatureAppraisalRows.BuildExtra, closing
the character-path extras list end to end (AS2 header + AS3 armor-level/
ratings/legend + AS4 here). All rows are CHARACTER-path only; the monster
path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) is unaffected and
pinned by a controller-level regression test.
Society row (gap G6, @0x004b49a1-@0x004b4c24): gated on PropertyInt 281
(Faction1Bits) being PRESENT — a literal reading of InqInt's found/not-
found return, not the ground-truth doc's informal "!= 0" value test.
Bit-priority if/else-if chain (Celestial Hand 0x1 -> Eldrytch Web 0x2 ->
"???" when Radiant Blood's 0x4 bit is ALSO clear -> else Radiant Blood)
comes straight off the decompiled branches. Rank-band suffix boundaries
(1-100 Initiate / 101-300 Adept / 301-600 Knight / 601-1000 Lord /
1001-1500 Master, no suffix outside every band) read directly off the
decomp's inclusive range checks @0x004b4ab9-@0x004b4b92 and match the
ground-truth table exactly. Color rule: green when the LOCAL player
shares the target's selected bit (checked first, so extra local bits
don't override a match), red when local has a different bit but not the
target's, normal when local has no society bits; the unrecognized "???"
branch never gets a color (retail's ebx_3 stays at its zero initializer).
The local player's own Faction1Bits comes from a new pure `localFactionBits`
parameter on BuildExtra — the composer never reads state directly.
Monarch/Patron/Followers cascade (gap G7, @0x004b4d97-@0x004b4f54): gated
by the caller on AllegianceRank (Int 30) >= 1, the SAME InqInt read AS2's
header AllegianceName binding already consumes. Four arms in retail's
exact order: MonarchsTitle (Str 21) absent -> "Alleg. Monarch:" + clamped
"%d Follower"/"%d Followers" (Int 35, singular only at exactly 1); present
+ PatronsTitle (Str 35) absent -> "Monarch:" only; both present and
ordinally equal -> one "Monarch/Patron:" row; both present and different
-> "Monarch:" then "Patron:".
Configurable extras (gap G8, @0x004b58be-@0x004b5c4d): Fellowship
(Str 10), Arrived in Dereth (Str 43 DateOfBirth), Time in Dereth (Int 125
Age via the ALREADY-PORTED RetailDurationText.Format — the same
ClientUISystem::DeltaTimeToString @0x00565E10 the decomp calls at
@0x004b59e0, reused rather than re-ported), Chess Rank (Int 181), Fishing
Skill (Int 192), Deaths (Int 43 NumDeaths, <= 0 -> "Has never died" with
the SAME "Deaths:" label, verified in the decomp), Titles Earned
(Int 262). Each row gates independently on its own property being
PRESENT (server already strips these per the target's visibility options
per ground truth §3) — no client-side option or success gating added. No
spacers separate these seven rows, matching the decomp exactly.
Seam: AppraisalUiController takes a new `Func<int> _localFactionBits`
dependency (per-call, never captured once — the secure-trade deferred-Func
lesson), invoked only on the character path. AppraisalRuntimeBindings
gained `LocalFactionBits`, wired in InteractionRetainedUiComposition from
`d.Character.LocalPlayer.Properties.GetInt(281)` — the SAME LocalPlayerState
instance CharacterSheetProvider already reads from, no new state path.
AS3 NIT 11: CharacterResponse_CombatRefreshRetainsArmorLevelRows now
applies a SECOND response with different armor-level values (proving the
refresh re-renders from fresh data) then a THIRD with armorLevels: null
(proving it clears). AS3 NIT 12: BuildExtra's XML doc now documents the
full authored row order with the @0x004b5d7d legend anchor. The test
Parsed() helper now ORs IdentifyResponseFlags.ArmorLevels into Flags
whenever armorLevels is supplied (realism sub-nit), applying uniformly to
every existing armor-level test in the file.
Ground-truth doc imprecisions found while verifying against the decomp
directly: (1) the Society gate is presence-of-property, not value != 0 —
InqInt's return is a found/not-found bool, the summary's "!= 0" phrasing
describes the common case but not the literal branch condition; (2) the
Society color rule's bit-priority (same-bit match checked before the
other-bits check) wasn't spelled out in the summary table, only "same/
different/none" — confirmed exact by reading all three branches
(@0x004b49fd/@0x004b4a49/@0x004b4a8b). No other disagreements found.
Register: docs/architecture/retail-divergence-register.md row AP-110
retires the "exhaustive character detail regions" clause from its
still-lacks list with a dated 2026-08-25 narrowing note (AS2+AS3+AS4
together closed the character-path extras list); the row's other
residuals (item-object preview, effective shield projection, cooldown-
remaining, augmentation-cost StringInfo, creature FontInfo-list
selection) are untouched.
Tests: 51 new rows-level tests in CreatureAppraisalRowsTests.cs (society
gate/bit-priority/band-boundaries/color-vs-local-faction, the full
allegiance cascade incl. follower singular/plural/clamp, each
configurable extra present/absent, "Has never died", monster-path
regression, one complete ordering-pin snapshot) plus 3 new controller-
level tests in AppraisalUiControllerTests.cs through the real LayoutDesc/
template binding (localFactionBits seam wired end to end, its default
fallback, and the strengthened monster-path regression). Hermetic
AcDream.App.Tests: 6253 passed. Full-solution hermetic run: 15,410
passed across all projects, 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plumbs Parsed.ArmorLevels into the extras composer and ports the retail
armor-level trio + unenchantable legend for the player examination window's
extras list (0x10000335), closing gap G4 and the legend half of G8 from
docs/research/2026-08-25-campaign-as-ground-truth.md.
Decomp evidence (docs/research/named-retail/acclient_2013_pseudo_c.txt):
- CharExamineUI::SetAppraiseInfo @0x004B45F0: the armor-level trio
(@0x004B4FD1-@0x004B5410) gates on ANY of nine base_armor_* fields > 0,
emits one leading spacer, then three rows "Head/Chest/Groin" (Head,
Chest, Abdomen), "Bicep/Wrist/Hand" (UpperArm, LowerArm, Hand),
"Thigh/Shin/Foot" (UpperLeg, LowerLeg, Foot) formatted "AL: %s/%s/%s"
with each part "%d" below 0x270f (9999) or "*%d" with (value-9999) at/
above it (data_794344 vs data_7b110c). The trio precedes the ratings
block and has no trailing spacer of its own.
- The "* = Unenchantable" legend (@0x004B5D7D-@0x004B5DED) is added
UNCONDITIONALLY after the whole `if (InqCreature)` block closes —
confirming ruling R3's "unconditional" reading directly from the raw
decompile, not just the BN flattening theory.
- CreatureExamineUI::SetAppraiseInfo @0x004B3FF0 (monster path): reads the
same nine ratings properties with the same gating/spacer logic, but
never touches base_armor_* or the unenchantable literal. Confirmed the
monster (character:false) path gains neither the trio nor the legend —
CreatureAppraisalRows.BuildExtra is character-gated for both.
- Ruling R4 (spacer discipline): CharExamineUI's own ratings-block leading-
spacer flag (ebx_13) is a known BN-decompiler artifact loss (call-
argument mangling instead of a clean `= 1` assignment). Cross-checked
against CreatureExamineUI's clean version of the identical algorithm:
one leading spacer before the FIRST ratings-family row that fires, one
trailing spacer if ANY fired. The existing BuildExtra ratings logic
(per-row gates 307|313|314, 308|315|316, 350|351; single leading/
trailing spacer) already matched this exactly — no functional change to
the ratings section, only the signature/threading change to make room
for the trio and legend around it.
Changed:
- CreatureAppraisalRows.BuildExtra now takes (properties, armorLevels,
character) instead of (properties) alone. Character-gated trio + legend
wrap the unchanged ratings logic.
- AppraisalUiController.RebuildCreatureStats takes the character flag and
threads appraisal.ArmorLevels through; ApplyCreature passes its own
`character` parameter. No caching needed for the combat refresh to keep
the AL rows: AppraiseInfoParser always parses ArmorLevels into the fresh
Parsed value Apply receives, so a re-Apply of the refreshed response
renders the same rows for free.
- Test signature updates only (no behavior pins changed) plus new
coverage: ArmorLevelTrioUsesRetailGroupingLabelsAndFormatPrecedingRatings,
ArmorLevelPartRendersUnenchantableSentinelAtOrAbove9999 (theory: 9998/
9999/10123), ArmorLevelRowMixesStarredAndPlainPartsIndependently,
AllNineArmorLevelsZeroOrNegativeEmitsNoTrioAndNoSpacer,
ArmorLevelTrioAbsentWhenArmorLevelsIsNull, EachRatingRowGatesIndependently,
LegendIsAbsentOnMonsterPathEvenWithRatingsShown,
LegendIsAlwaysLastOnCharacterPathEvenWithNoOtherExtras (rows-level);
CharacterResponse_ArmorLevelTrioPopulatesExtraListThroughRealBinding,
CharacterResponse_CombatRefreshRetainsArmorLevelRows,
CreatureResponse_NeverGainsArmorLevelTrioOrLegend (controller-level,
through the real LayoutImporter/FixtureLoader binding seam).
No existing pin was corrected — the pre-AS3 ratings gating/spacer
behavior already matched the decomp; only the call signature changed.
Full hermetic suite: AcDream.App.Tests 6208/0 skips; full-solution
15,483/0 skips. Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus's dual-lens review of the AS2 header-identity-block commit
(f8a22589) approved the retail port as exact and ordered six
mechanical fixes:
- F13: correct the plan doc's "static tinted preview" claim about
retail's examine-window paperdoll — the decomp shows retail's clone
IS animated (BasicCreatureExamineUI::Init @0x004AB9C0 ->
CPhysicsObj::makeObject -> MorphToExistingObject +
play_script_internal, CreatureMode::Render driving update_position
every frame), just decoupled from the live target. AD-114 already
records the correct deviation; only the plan doc's rationale text
was wrong.
- F14: add a regression test pinning the monster path (character:
false, success:false, no String 5 / Int 0x105 marker) so element
0x1000053A stays cleared — the exact case that used to emit the
invented "Assessment incomplete" literal AS2 deleted. This is the
test coverage the AS2 commit message over-claimed already existed.
- F15: dedup the PK/PKLite PWD bit constants that were copy-pasted
across three files into named PublicWeenieFlags.PlayerKiller
(0x20) / PlayerKillerLite (0x02000000) values, routing all three
consumers (AppraisalUiController, CharacterSheetProvider,
LocalPlayerTeleportController) through them. Pure mechanical
substitution; the write side (PlayerKillerStatusBitfield.Apply) is
untouched.
- F16: file register row AD-115 for a real, previously-unrecorded
deviation the AS2 commit introduced: BuildCharacterTitleDisplay
clears element 0x10000151 when neither source resolves, where
retail (CharExamineUI::Show, BasicCreatureExamineUI::Init) never
clears it and would show the previous target's stale title
instead. Marked intentional — ours is deliberately better.
- F17: narrow BuildPlayerKillerDisplay's doc comment — retail's
`cur_weenobj != 0` guard @0x004b482f skips only the PK line, not
the whole response; the whole-response drop on a missing local
object is separate, pre-existing, and out of scope here.
- F18: ledger row for AS2 now reads "review fix round" with the land
commit and a note that the fix SHA is recorded at review-close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail's CharExamineUI::SetAppraiseInfo (@0x004B45F0, player-target
examine subview) binds four fixed header lines that acdream was feeding
from the wrong appraisal properties:
- 0x10000150 (Heritage) got raw string prop 4 verbatim. Retail composes
"<Gender> <Heritage>" via InqGenderHeritageDisplay (@0x005B5AE0) from
Int 113 Gender + Int 188 HeritageGroup, falling back to the assessed
object's creature-type name only when HeritageGroup == 0. Reuses the
Campaign CT gender/heritage tables in CharacterIdentityText (new
GenderHeritageDisplay helper) and the controller's existing
CreatureDisplayNameResolver for the creature-type fallback arm.
- 0x10000151 (Profession/title) got AllegianceName (string 47) — the
title line was never shown at all. Now resolves the CURRENT display
title from Int 261 CharacterTitleId through CharacterTitleResolver
(CharacterTitleTable::GetCharacterTitleFromID @0x005C6ED0), falling
back to String 5 Template verbatim when the id is absent or
unresolvable. RetailUiRuntime.MountAppraisal now wires the SAME
CharacterTitleResolver instance the D.2b Character panel already
owns (_bindings.Character.TitleResolver), resolved per call under
DatLock — never captured once at mount time, per the secure-trade
deferred-Func lesson.
- 0x10000152 (PlayerKiller) got MonarchsName (string 11) — never shown.
Ruling R7: retail reads the LOCAL weenie's PWD bits
(ACCWeenieObject::IsPK/IsPKLite @0x0058C8B0/@0x0058C8A0), never the
appraisal payload. Now reads the assessed ClientObject's
PublicWeenieBitfield directly (bit 0x20 -> "Player Killer", bit
0x02000000 -> "Player Killer Lite", else "Non-Player Killer").
Apply()'s existing bail-out when the object has left the local table
already matches retail's "weenie is gone, leave the line cleared".
- 0x1000053A (AllegianceName) invented a literal "Assessment
incomplete" on failed assess — zero retail provenance, deleted
outright. Retail clears the element first (ClearCreatureText already
does this every ApplyCreature call) then sets String 47
AllegianceName only inside the Int 30 AllegianceRank >= 1 gate.
Scoped to the character branch since retail's CreatureExamineUI
(monsters) never binds this element at all.
None of the four lines are success-gated — ACE sends the int/string
tables even on a failed assess, matching retail's own composition.
First-ever AppraisalView.Character controller test coverage (gap G10):
header composition, title/allegiance fallback and gating, all three PK
variants plus the missing-object clear case, failed-assess rendering
with a repo grep confirming the invented literal is gone, and a
regression pin proving the monster (character: false) path is
untouched.
Register: files AD-114 for the examination preview's animated clone
(mirrors the assessed target's live current pose via
CreatureAppraisalFramePresenter) versus retail's independently
animated private CreatureMode clone (BasicCreatureExamineUI::Init
@0x004AB9C0) — owner-ruled intentional deviation, 2026-08-25
("we animate it, and I like it").
Full hermetic suite green: 15,469 tests passed, 0 failed, 0 skipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CT7 gate regression (owner report): all MossTank plugin text vanished
except button captions. A markup <label> authors position only, so
UiLabel's box was degenerate (0x0) and CT-GF1's completed self-clip
(UIRegion::DrawHere @0x0069FA30 shape) cropped its glyphs to nothing;
markup buttons author w/h, which is why their captions survived.
UiLabel now opts out of the self-clip — it is ClickThrough pure text
whose real containment is its ancestors (the plugin panel/window, which
are properly sized), the effective retail behavior for a text region
whose box hugs its glyphs — and keeps a truthful box by measuring its
current text each draw. Mechanism pin: an unsized label's subtree must
render inside its sized parent (probe-child draw-capture test).
Gate note recorded by the owner in the same round: the Titles-page
divider IS visible inside the window in retail while scrolling — a
retail quirk our clipped rendering now reproduces exactly. CT7 gate
PASSED apart from this regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applies all 11 items from the Opus dual-lens review of 989f6652 (0
blockers, 7 SHOULD-FIX, 4 NOTE):
- S2: UiElement.DrawSelfAndChildren now pushes the ambient clip right
after PushAlpha and wraps OnDraw + the children walk +
OnDrawAfterChildren in ONE block — the literal UIRegion::DrawHere
@0x0069FA30 shape, which clips an element's OWN DrawSelf too, not
just its children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph
blits to its own clipped surface rect; UIRegion::DrawSelf
@0x0069F1A0 blits per clip rect). Deleted the two now-redundant
ad-hoc self-clips this supersedes: UiText.DrawText and
UiField.DrawMultiLine both pushed their own (0,0,Width,Height) —
exactly what the new ambient clip already provides one level up.
Kept UiButton.DrawBlockLabel's clip: it clips to LabelBox/ValueBox,
an authored INNER sub-rect that can be smaller than and offset from
the button's own full rect — a genuine narrower viewport, not a
redundant duplicate.
- S3: deleted UiItemList's `ClipsChildren => CellWidth > 0f` override
— correct under the old opt-in-false default, inverted under the
new default-true (an unconfigured list would stop clipping instead
of clipping like everything else).
- S4: pinned the escaped-popup input path end to end. New
UiAncestorClipTests test mounts a menu inside a short window on a
real UiRoot, opens it, and proves a click in the escaped popup
region reaches the menu through UiRoot.PopupHit (a plain top-down
walk is proven to reject the same point first). UiRoot.WantsMouse
now also checks PopupHit — it previously only checked Captured/
HitTestTopDown, so a game action could fire underneath an open
dropdown's escaped region. OnMouseDown/OnScroll already routed
through PopupHit first (#374); unchanged.
- S5: strengthened the Titles-divider regression test's positive
half. The old assertion only checked SOME quad's Y fell in a band —
vacuously true given other same-band content. Now asserts the
divider's exact rect (X and Y), then diffs against the same rect
with the divider hidden (Visible=false) to prove the quad was
actually attributable to it.
- S1: added UiWindowDrawCaptureSweepTests — Character/Chat/Vendor/
Options mounted through their real production Bind entry points
with a non-zero sprite resolver, drawn via RecordingGpuDevice,
asserting a per-window vertex floor (~40-45% of this session's
observed baseline: Character 588, Chat 162, Vendor 54, Options 240)
plus one key sprite id read LIVE off the bound controller/element
(never hardcoded). Character's key sprite (RetailChromeSprites.
TopEdge) specifically exercises OnDrawAfterChildren, the exact path
S2's caution note flagged. Inventory/Paperdoll/social/map-house
skipped — no single fixture-driven top-level Bind entry point.
- S6: added the CT-GF1 subsection to the campaign plan's ledger
(989f6652 + this fix round; CT7 re-gate still owed).
- S7: UiRenderContext.PushClipUnbounded now resets to the CANVAS rect
(0,0,ScreenSize), not null — retail's own popup region is
SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region
bounded by the screen), not truly unbounded. AD-113 amended.
- N1: UiRoot overrides ClipsChildren => false — the root's own region
IS the screen (the viewport already scissors it), so this is a
safety net against a momentarily zero-sized root silently blanking
the whole UI tree under the new ancestor-clip default.
- N2: added the empty-clip subtree cull (retail's var_24 gate
@0x0069FB8E) to DrawSelfAndChildren only — DrawOverlays is a wholly
separate traversal untouched by this change. New test proves a menu
inside a fully-clipped (zero-width) window still draws its open
popup via the overlay pass while the main pass draws nothing.
- N3: CT7 script §5 now names the collapsed-toolbar check and the
four highest-overflow windows (combat/vitals bar, Options
bottom-button row, map/house page, floaty chat) as explicit
eyeball items for the re-gate.
- N4: verification below covers both the working tree and the clean
committed tree.
Decomp anchors: UIRegion::DrawHere @0x0069FA30 (var_24 gate
@0x0069FB8E); UIElement_Text::DrawSelf @0x00467AA0 (self-clip);
UIRegion::DrawSelf @0x0069F1A0; UIElement_Menu::MakePopup (screen-
clipped popup region).
Verification (both runs green, --filter "Lane!=InstalledDat&
Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&
Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&
Status!=KnownFailure"): full Release solution build green; working
tree 14,900+ tests across every project (one LandblockPresentation
PipelineTests flake reproduced ONLY under full-solution parallel
load, passes standalone and on rerun — unrelated to this change,
streaming domain); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT
_TESTS=1, Status!=KnownFailure, 205+34+3+172 App/Content/Bake/Core
tests). Clean committed tree (git stash push -u the uncommitted
owner probe + docs files, rerun, stash pop) reported in the session
summary.
src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing
uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) — staged selectively
(git add -p) so only this commit's own two hunks (ClipsChildren
override, WantsMouse) landed; the probe hunk is untouched and stays
uncommitted, same as before this fix round.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the CT7 gate finding: on the Titles tab, the authored divider
0x10000530 escapes the Character window above its top edge at the
CT6-correct 372px mounted default (computed Y ~ -178, matching the
owner's screenshot). Retail clips child rendering to the intersected
ancestor clip-rect chain -- UIRegion::DrawHere @0x0069FA30 takes the
element's screen Box2D plus a SmartArray<Box2D> of inherited clip
rects, intersects them (the min/max clamp loop @0x0069FAA7..0x0069FB82),
and draws EraseSelf/DrawChildren/DrawSelf with the intersected rect
only when non-empty (the var_24 gate @0x0069FB8E). Our UiElement draw
walk rendered children unclipped by default, so any authored element
relying on clipping -- this divider, and the chat input row at small
window sizes (the owner's earlier "text input sticks out on resize"
report) -- became a visible artifact.
Mechanism (element-level, reusing the existing clip-rect-stack
infrastructure in UiRenderContext.PushClip/PopClip):
- UiElement.ClipsChildren now defaults to TRUE for every element
(was an opt-in used only by UiScrollablePanel/UiItemList). Each
element's children draw AND hit-test clipped to the intersection
of its own rect with the inherited ancestor clip; an element
positioned outside its parent's box silently disappears, matching
retail's non-empty-intersection gate. HitTest's existing early
bounds check already implemented this shape for ClipsChildren=true
elements -- flipping the default aligns hit-testing with the new
draw-clip default in one property, per the plan's own point 4.
- UiElement.ExpandsClipForPopup (default false) is the one opt-out:
retail spawns a menu popup as a SEPARATE top-level region
(UIElement_Menu::MakePopup), clipped only by the screen; acdream
draws UiMenu's popup inline from the owning button in a second
traversal (OnDrawOverlay, pre-existing -- its own doc comment
already says "regardless of this element's position in the tree").
DrawOverlays now resets the accumulated clip to unbounded
(UiRenderContext.PushClipUnbounded, sharing the existing clip
stack) for exactly the OnDrawOverlay call of an opted-in element.
UiMenu overrides ExpandsClipForPopup=>true, paired with
ClipsChildren=>false so its own out-of-bounds OnHitTest union (the
popup occupies ly<0 or ly>=Height depending on open direction)
stays reachable through the same early-bounds gate that now
defaults on for every other element.
Opt-out audit (grep for OnDrawOverlay overrides + negative/overflow
OnDraw coordinates across src/AcDream.App/UI): UiMenu's popup is the
ONLY OnDrawOverlay override client-wide, so it is the only element
needing ExpandsClipForPopup. RetailTooltipPresenter's popup and
UiRoot's drag ghost both already escape structurally -- the tooltip
mounts as an ordinary UiRoot CHILD (sibling of every window, clipped
only by the canvas), and the drag ghost is drawn directly by UiRoot
outside the tree entirely -- neither needed a code change, both are
covered by new tests proving the invariant. UiResizeGrip and
UiNineSlicePanel's frame/bevel draw entirely within their own
[0,Width]x[0,Height] (grip flush at the window's own edges; the
window's own Width/Height already represents the OUTER frame
including its 5px bevel, so its ClipsChildren push already covers
the frame's own content children correctly -- no negative insets
found). UiScrollbar draws entirely within its own bounds (confirmed
by reading OnDraw).
Hit-testing: aligned with the new default via the single
ClipsChildren flip (see above); UiMenu's own opt-out override keeps
its popup hit-test union working, verified by the full UiMenuTests
suite staying green.
Divergence register: AD-113 filed for the ExpandsClipForPopup
adaptation (inline popup drawing vs retail's separate top-level
region).
Fixed two pre-existing test-harness gaps the new default surfaced
(both real bugs in the harnesses, not workarounds around the fix):
- ChatLayoutConformanceTests' bottom-right-grip grow test read a
STALE (pre-shrink) grip screen position because it drove two resize
gestures back-to-back with no intervening Draw pass -- the only
place UiElement.ApplyAnchor/LayoutPolicy.Apply run. A real frame
draws every tick, so production never hits this; the test now
inserts a real DrawSelfAndChildren pass between the two gestures,
matching a real frame boundary.
- VendorUiControllerTests' hand-built Items/Buying/Selling page
containers were left at their bare 0x0 UiElement default (the
harness never runs a real DAT-driven layout pass) -- harmless
before ancestor clipping existed, but now hides every child of an
unsized page. Sized them to the window's own content root, matching
production's shape (a tab page fills the window body).
Tests (all confirmed as genuine regression pins by temporarily
reverting the relevant default/override and observing the exact
predicted failure, then reverting back):
- CharacterTitlesControllerTests.TitlesPage_Divider_ClipsAwayAtThe
CT6Default_AndAppearsWhenTheWindowGrowsTaller: the literal gate
repro against the real character_2100002E.json fixture through
RetailWindowFrame.Mount at the CT6 372px default -- the divider
renders nothing (computed Y ~ -173, matching the owner's ~-178);
growing the window to 600px renders it at its authored spot.
- ChatLayoutConformanceTests.ResizingTheWindowSmall_NoInputRowQuad
RendersOutsideTheWindowRect: no input-row quad escapes the chat
window rect at three small sizes (300x100 sanity control,
120x40/80x30 genuine pre-fix overflow -- verified failing without
the fix at Y=38/55 past the window edge).
- UiAncestorClipTests (new file): the core mechanism against plain
synthetic elements (culled-outside / clipped-at-the-edge / hit-test
parity), UiMenu's popup escaping a tiny owning window (and staying
clipped while closed), and the tooltip's structural immunity
(mounts as a UiRoot sibling, unaffected by a tiny ancestor window).
Verification: full solution build green; hermetic suite green
(--filter "Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&
Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&
Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure",
14,000+ tests across every project); InstalledDat lane green
(ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Status!=KnownFailure,
205+34+3+172 tests). CharacterTitlesControllerTests' existing suite
and the full UiMenuTests/UiScrollbarTests suites are unaffected.
src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing
uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) -- untouched by
this change and deliberately left out of this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus dual-lens review of CT6 (ec50455a) found 1 blocker, 4 should-fix, 5
notes. All applied:
BLOCKER B1 — the shared gmPanelUI host (0x100005FE) IS retail's own
outer window frame, not a content element: its authored 310/372/310/1000
already include the 5px bevel on every side. RetailWindowFrame.Mount was
adding the NineSlice wrapper's OWN 10px chrome inset on top of that
already-chrome-inclusive source, clamping MinWidth to 320 while the
window's actual mounted outer width stayed 310 — silently below its own
minimum until RetailWindowManager.ResizeTo forcibly widened it despite
ResizeX=false. Fixed with a new
RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame opt-out
(chrome inset = 0 for constraint resolution only, value stays
DAT-sourced); MountCharacter sets it true. Mounted clamp is now exactly
the host's four raw values: width fixed 310, height 372..1000. Added a
mount-time invariant (throws if the mounted outer extent falls outside
its own just-computed clamp) that would have caught this at the first
test run.
S4 (campaign-lead ruling) — the window must MOUNT at retail's authored
default, outer 372 (content 362, matching the host's own content parent
0x10000180), not 0x2100002E's own 300x600 content-authoring canvas
(which produced a stale 610px default pre-fix: 600 + 10 chrome inset).
372 is exactly the host's own authored MinHeight — retail opens at its
resize floor and can only be dragged taller. MountCharacter now sets
ContentHeight=362f explicitly. At this default the 9 attribute/vital
rows (180px) overflow the 160px list immediately — retail-correct, not
a regression.
S2 — 0x1000023E and 0x10000533 both author property 0x79
(HideWhenDisabled) TRUE (fixture-verified: BoolValue=true on both). A
fitting list HIDES the scrollbar entirely; it does not draw a full-track
"disabled" thumb. The code was already correct; four wrong descriptions
(plan ledger, CharacterStatController comment, CT7 script, test comment)
are corrected, plus a new IsPresentationVisible assertion pair in the
resize test.
S3 — CharacterTitlesController's `if (listBox.LayoutPolicy is null)`
Anchors fallback was unreachable on both the real DAT and the fixture
(0x10000532/0x10000539 both author HasOriginalParentSize=true, so
LayoutPolicy is always assigned). Deleted; added an InstalledDatFact pin
guarding the deletion against DAT drift.
N4 — renamed NineSlice_ChatShapedConstraints_... to
NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize
(it tested inset arithmetic on a content-shaped source, not chat's real
contract) and added a true chat-contract pin mounting Chrome=Imported
with chat's real 300/100/2000/2000 constraints, asserting no inset
applies.
N5 — corrected the "nothing inferred, no register row" sentences in the
ground-truth doc and plan ledger: they were false pre-fix (the mounted
clamp WAS an inferred double-counted composition); true now that B1
removes the composition.
CT7 script §4 rewritten with exact clamps (no "≈"), the corrected
default-overflow scrollbar behavior, and an absolute starting-height
statement.
Verified: full hermetic solution suite green (15,441 tests, Release,
Lane exclusions per the release gate), InstalledDat lane green across
the whole solution (414 tests, ACDREAM_RUN_INSTALLED_DAT_TESTS=1,
Status!=KnownFailure) including two new pins
(TitlesListAndPage_AuthorHasOriginalParentSize,
Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CT6 (Campaign CT slice 6): the resize clamp source is the SHARED
gmPanelUI host (0x100005FE in LayoutDesc 0x2100006E), not 0x2100002E's
own root and not the Character/Skills slot 0x1000018E either — live
probe confirmed the host authors MinWidth=MaxWidth=310 (fixed — no
horizontal Resizebar authored), MinHeight=372, MaxHeight=1000, and that
the bottom Resizebar (0x10000660) and top Dragbar (0x1000065C) are
direct children of the host, not the content parent. Decomp chain:
UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls
UIElement::StartResizing(this->GetParent(), ...), stashing drag state on
that parent; UIElement::MouseResizeElement @0x00461130 then reads
GetAttribute_Int(this, 0x3C..0x3F) off that same element every
mouse-move.
RetailUiRuntime.MountCharacter now imports the host element and passes
it as RetailWindowFrame.Options.DatConstraintSource, matching the
existing MountSideVitals pattern.
CharacterStatController.RebuildActiveList now wraps BOTH the Attributes
and Skills tabs' rows in the same UiScrollablePanel viewport (previously
only Skills got one; Attributes rows had no clipping/scrolling and the
shared scrollbar was force-hidden — owner report item 2). The shared
scrollbar is now always bound + visible; UiScrollbar's own
IsPresentationVisible/IsModelDisabled already draw the correct
full-track "disabled" thumb when content fits. This surfaced and fixed
a real #372/#412-class anchor-baseline bug: the viewport's
Left|Top|Bottom anchor was capturing its baseline margins lazily on its
own first ApplyAnchor call, which happens AFTER the ListBox has already
grown from its raw DAT height to its mounted height, permanently
capping the viewport short on every later resize. Fixed with an eager
CaptureCurrentAnchorBaseline() call, mirroring UiTemplateListBox
.Viewport's own lazy getter.
CharacterTitlesController.Bind gained the same defensive
Anchors = Left|Top|Bottom fallback for the Titles ListBox that
CharacterStatController already had (a no-op on the real DAT — both the
Titles page and its ListBox already carry a real authored LayoutPolicy
that stretches correctly).
Standardization audit: UiElement.MinWidth/MinHeight/MaxWidth/MaxHeight,
set once at RetailWindowFrame.Mount, are the ONLY clamp fields — read
identically by interactive drag, RetailWindowManager.ResizeTo, and
RetailWindowLayoutPersistence's restore clamp. No gaps found; no
register row (every number is a live-probed authored DAT value or a
structural correctness fix, nothing inferred).
Tests: CharacterStatControllerTests
.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar,
CharacterTitlesControllerTests
.TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips,
RetailWindowFrameTests
.NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds
(shared-mechanism regression pin), CharacterPanelLiveDatTests
.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract (InstalledDat
pin). Existing attribute-row tests updated from list.Children to
Descendants(list) for the new nested-viewport shape (the pattern skill
rows already needed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus review of CT5 (f532f28c) found 0 blockers, 4 SHOULD-FIX, 6 NOTEs; all
applied here.
SHOULD-FIX 1 (visible retail gap): unselected attribute/skill rows now draw
the row template's Normal-state media (0x06004CC2 — pinned by
CharacterPanelLiveDatTests.AttributeRowTemplate_...) instead of drawing
nothing. Independently decoded against the installed DAT: PFID_A8R8G8B8,
48x48, uniform (0,0,0,175) — a ~69%-opaque black tile the native-size
copy-or-tile blit repeats across the row. Wired at all three sites
(HandleRowClick, ApplySkillSelectionVisuals, AddRow). Selected rows keep
0x06000F93 (RowHighlightSprite) unchanged.
SHOULD-FIX 2: added Bind_AttributeRow_/Bind_VitalRow_/Bind_SkillRow_
geometry tests asserting the authored template pixels against BUILT rows
(not DAT pins) — width, height, icon/name/value column positions. The
skill-row case reproduces the real production scrollbar (X=281, per
CharacterPanelLiveDatTests.StatListBox_AuthorsFiveRowTemplatesInSharedLayout)
to prove the documented 281px clamp (scrollbar.Left - list.Left), one pixel
short of the attribute/vital rows' 282px ceiling.
SHOULD-FIX 3: AddRow's name-column Padding corrected from 1f to 0f — the
authored template carries no margin on 0x1000012A; Padding=1f re-created
the X=26 glyph-start bug this slice existed to fix.
SHOULD-FIX 4: reworded both UiPanel.BackgroundSprite doc comments — the
draw is a native-size copy-or-tile blit (UV-repeat), never a stretch.
Decoded 0x06000F93 as exactly 282x20 (matches the row natively, draws as a
plain copy) vs 0x06004CC2's 48x48 tile. Retail's UIRegion::SetImageByDID
(@0x0069F960) decompiles to a pure BlitMode selector switch — param_2==2 ->
Blit_3Alpha, ==3 -> Blit_4Alpha, else Blit_Normal — with no width/height
touched anywhere in the function, answering CT1's open "draw mode 3"
question: it's an alpha-blend selector, not a resize flag.
NOTEs:
a. AddRow's nameEl now sets OneLine=true so the authored VJustify=Center
takes the same single-line vertical-centering path the value column
already uses.
b. Tempered the "row width is 282" wording in the SkillContentWidth /
RowContentWidth doc comments — that's a ceiling attribute/vital rows
land on, not a fact true for skill rows (281, via SkillViewportWidth's
scrollbar-gutter measurement).
c. Reworded the section-header (RowPadX) comment — CT1 verified only the
four header SPRITES; the caption label's own authored margins were
never checked. Recorded as an open residual, not a cleared divergence.
d. AttrRows/VitalRows are now internal (InternalsVisibleTo("AcDream.App.Tests")
already covers AcDream.App.Tests); CharacterPanelLiveDatTests iterates
them directly instead of a re-typed duplicate array, and now also
asserts the vitals 2/4/6 current-enum aliasing claim the doc comment
made but never enforced.
e. Deleted the stale pre-CT5 0x06001397 narrative in
CharacterPanelLiveDatTests; the pin's comment now describes the
post-CT5 state (a regression guard, not an open bug).
f. Unified ApplySkillSelectionVisuals' selected-branch SpriteResolve
wrapper closure with HandleRowClick's direct assignment.
Build green; full hermetic solution suite green (Release,
Lane!=InstalledDat&...&Status!=KnownFailure filter); InstalledDat lane
green (ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Lane=InstalledDat&Status!=KnownFailure).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>