The Journal panel now has all three tabs working: contracts from the server,
and a per-character notebook with its searchable index.
Two ported details that a reimplementation would get wrong in a way nobody
notices until they lose work:
Every navigation button commits the current page FIRST. Retail's
ListenToElementMessage @0x004968D0 calls SaveThisPage on the way out of all
five of them, which is why paging away never eats what you just typed. And the
file is written when the notes page is HIDDEN, not only at exit — a crash then
costs at most the page in front of you.
The search is CASE-SENSITIVE across label, title and notes: retail compares
with wcsstr and lowercases neither side. Making it insensitive would be
friendlier and would be a divergence, so it is ported as-is with a test naming
the reason. The double-click window is a full SECOND (m_LastClickTime + 1.0,
@0x00493158) rather than the 500 ms the item-interaction path uses, and firing
it clears the tracker so a third click does not re-open.
Two unlabelled buttons on the notes page turned out to be prev/next: retail
switches on (idElement - 0x10000565), which names them without a caption. The
running-timer readout is authored at the same x as the three day/hour/minute
boxes, so the strip is one or the other — that overlap is the data form of
ShowEditableTimer versus ShowRunningTimer, not a layout bug.
DeltaTimeToString moved out of the contract code into AcDream.Core.Ui. It is
ClientUISystem's, not gmContractsUI's — the journal timer and the contract
repeat countdown both call it, and it only lived under Quests because that was
its first caller. A bridge class to reach it across features would have been
the wrong answer to the same observation.
The journal file lives in the client's data directory rather than beside the
executable, for the same reason the chat log does. Register QJ-1.
Campaign QJ slices 3, 4 and 5 of 5 — code-complete, connected gate owed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects in the panel as committed, both found by checking the code against
the authored data rather than by a test.
The authored row template is a Type-3 generic container, which resolves through
DatWidgetFactory's fallback arm to UiDatElement — whose constructor sets
ClickThrough = true ("generic decoration; behavioral widgets opt back in").
Binding OnClick without clearing that compiles, reads correctly, and produces a
list in which nothing can be selected: every click sails past the row. The
skills page had already met this and left the precedent; I did not follow it.
And there was no selection highlight at all, so even once clicking worked the
player could not tell which row the detail pane was describing. UiTemplateListBox
has no selection mechanism of its own, so the page opts in directly and
re-PAINTS the highlight after a rebuild — a rebuild discards the row objects, so
remembering the selection is not enough to keep it visible.
The tests for both initially passed while the bugs were live, because the
fixture's row root was a UiPanel and its text started white. A UiPanel is not
click-through, so the first test was vacuous; white-on-white made the highlight
unobservable. The fixture now builds the same UiDatElement production does and
authors a non-white colour. This is the third time this campaign a fixture that
did not match the real widget hid a real defect.
Live mount confirmed against the installed dats in this session's client run:
"[UI] retail journal panel from LayoutDesc importer (0x2100006E slot
0x10000559)" with no bind failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The quest log is on screen. Rows come from the live tracker joined to the
authored catalog, the Status column runs QT4's port of FillProgressString, and
the detail pane shows contact, locations, description and the other timer.
Two things measured rather than assumed, each now pinned by an installed-DAT
test rather than left to the commit message:
The tab pairing is read from the authored 0x2E table, not inferred from
x-order — the FA campaign had to correct exactly that mistake, and Contracts
turns out to be the authored DEFAULT tab (0x32 = True), so opening on the
wrong one would have looked like an empty panel.
The open path needed no keybind at all. Toolbar button 0x1000055A authors
0x10000029 = 0x19 and has been sitting in ToolbarController.PanelButtonIds
since the toolbar was ported — it just had no panel behind it, so clicking it
did nothing. Registering slot 25 finished a wiring that was already
three-quarters present.
The list rebuild is revision-gated while the repeat countdown is not: nothing
on the wire changes as a cooldown runs down, so a rebuild-gated timer would
freeze on screen, and a per-frame rebuild would reset the player's scroll under
them. Both directions have a test.
Deliberately inert: the Abandon button (retail's abandon path is a
contract-registry command this campaign did not port — authored and visible,
but wiring a no-op handler would look responsive and lie), and the Journal
notes and Page List tabs, which are their own feature.
Campaign QT slices 5 and 6 of 6 — code-complete, connected gate owed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CT-B4 was filed as "the plain-text session chat log, path and rotation
UNKNOWN, needs a live check." Both unknowns dissolve once you read the
handler: there is no automatic session log. Retail's @log is a COMMAND.
DoSetOutput @0x0057E4F0 takes a filename, StartCopyOutputToFile @0x0057C8A0
does the fopen(name, "a+"), and running it again with no argument closes it.
Nothing rotates because it appends forever, and nothing has a fixed path
because the player names the file.
The path question that DOES exist — where a bare name lands — was answered
all along by retail's own help text, which CH4 extracted verbatim into our
help table a fortnight ago and nobody read: "a log file named Aclog.txt in
your Asheron's Call directory." A blocked question sat on top of a committed
answer.
We cannot use the install directory: the launcher replaces it atomically on
update, so a log written there is wiped by the next update or blocks it. The
client's own log directory is the equivalent that survives. Rooted paths are
honoured verbatim, as retail's fopen would. Register CT-5.
The verb was registered in the help table but NOT in the command catalog, so
/log printed help and did nothing — and the CH4 conformance registry recorded
it as a "server passthrough" precisely because that shape is indistinguishable
from an unimplemented client command. It never went on the wire at all. Both
are corrected, with the totals moved in the same commit rather than left to
drift.
Moving it into the catalog also moves which help table answers for it, so
retail's real text moved to the catalog-verb table in the same change. Without
that, /help log would have silently started printing acdream's own invented
one-line summary — caught by the coverage test, and now pinned by a test that
names the text.
All five replies are byte-decoded from the PDB-paired binary rather than read
off Binary Ninja's previews, which truncate at ~33 characters and would have
lost the second half of every one of them (including the two spaces retail
puts after "Copying chat to %s.").
The writer attaches on OPEN, not at startup — retail's help is explicit that
only what appears after the command is copied — and detaches from the
transcript it actually attached to, so a session teardown cannot leave a live
handler writing into a file the player believes is closed. What gets written
is the composed display line with the shared timestamp, because retail's
fprintf sits inside AddTextToScroll: downstream of composition, upstream of
glyph layout. Logging the raw entry text would have produced a file of bare
fragments with no speakers.
acdream's logs carry no inline tag markup where retail's do, since tags live
beside the text as spans here rather than inside it. Registered as CT-6 rather
than reconstructed purely to write it to a file.
Register: CT-5, CT-6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The blink is not code. It is data, and we were throwing it away.
A retail UI state's media is a small program: images interleaved with timed
pauses, branches, and a terminal hand-off to another state. Our importer kept
the FIRST image per state and dropped the rest, so nothing authored could ever
animate — the indicator was correct in every other respect and simply sat
still.
Measured from the installed dats (LayoutDump --media 0x1000048C), the chat
unseen-text indicator's Normal state authors thirteen steps: two frames
alternating every half second, three times, then `State 13` — Ghosted, whose
authored 0x3B is Invisible.
So retail's indicator is a three-second attention FLASH that hides itself, not
a badge that stays lit until you scroll to the bottom. Nobody would guess that
from the code, because there is no blink code anywhere; the behaviour lives
entirely in the authored sequence. Our shipped version stayed lit, which is
the one thing the data says it must not do.
Sampling is a pure function of (steps, elapsed) rather than a playback object
holding a cursor, so an element only has to remember WHEN its state began and
the whole thing is testable without a clock, a GPU or a frame loop. One shared
UiMediaClock is advanced once per frame by RetailUiRuntime; a UI element has
no tick of its own.
The controller change is the other half: it starts the flash on the rising
edge ONLY. Re-setting Normal every frame would pin the sequence on frame zero
and it would never blink at all — which is the failure mode the second new
test exists to catch, and which no "is it visible?" assertion would notice.
When the sequence reaches its terminal step the controller follows it down
instead of re-lighting it.
Two guesses are refused rather than made, and both are registered: a Pause's
max duration (every sequence measured sets min == max, and what the range MEANS
is not in the decomp) and a sub-1 branch probability (falls through, the
direction where a malformed sequence stops rather than animates forever).
A jump-cycle with no elapsed time is bounded so a bad sequence cannot spin
inside a frame.
Kept `Other` steps in the list rather than filtering them, so a jump's authored
index still lands on the entry it names.
Register: CT-3, CT-4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Regression from cbab79d7, which I introduced: the indicator stopped showing at
all. Switching it from Visible to state-driven was half a correction — right
about retail's mechanism, wrong about what makes this element appear.
Measured, rather than reasoned about (LayoutDump gained --props for it):
0x1000048C state 13 Ghosted 0x3B = True -> hidden
state 1 Normal 0x3B = False -> shown
state 3 pressed 0x3B = False
Dat property 0x3B is "Invisible", authored PER STATE, and it is what puts this
element on screen. UiDatElement applies 0x3B on a state change; UiButton does
not, and this element builds as a button — so driving the state alone left it
hidden forever. The original Visible toggle was, by coincidence, exactly what
the authored data prescribes.
So the property is applied here rather than left unhonoured. That is the
authored data, not a visibility hack layered over the state machinery.
The state is still set, for the media it selects, but only on the way IN:
TrySetRetailState(Ghosted) means Enabled = false, and disabling the button
would also refuse the click that scrolls to the newest text — a second bug
waiting behind the first.
The test now pins VISIBILITY across the transitions instead of ActiveState.
The previous test passed while the feature was broken because the fixture
element carried no 0x3B, so the assertion could never see the property that
actually decides this. It fails now if the state is driven without the
visibility.
Proper fix noted for later: UiButton should honour per-state 0x3B the way
UiDatElement already does. That is a wider change than this regression wants.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User report: "the Unseen indicator shows, but not blinking. I thought it was
blinking in retail." They were right, and CT-C1 had the mechanism wrong.
The dat settles it. Element 0x1000048C authors:
1:Normal media=13/6 <- SIX image frames: the flash
3:Normal_pressed media=2/1
13:Ghosted media=0/0 <- the authored DEFAULT, draws nothing
and retail's own click handler ends in SetState(0xD) — Ghosted. So the
indicator is driven by authored STATE, never by visibility, and the blinking is
a multi-frame media list in the DATA rather than anything in code.
CT-C1 toggled Visible instead. That looks almost right — the thing appears and
disappears at the correct moments — and can never blink, because visibility has
no frames. Now switched to Normal/Ghosted, which is both the retail mechanism
and the thing the animation hangs off.
STILL NOT BLINKING, and honestly so: our importer keeps ONE image per state
(ElementInfo.StateMedia is a single file), so multi-frame media is not modelled
anywhere in the UI layer. That is a capability rather than a tweak — the same
shape as the tagged-runs work in Group A — and the state machinery here is
correct either way, so it gains the animation for free once that lands. Recorded
in the method's own doc rather than left as a mystery.
The test fixture gained the element: it was absent, so the whole binding path
had never been exercised by any test — which is why a visibility-based
implementation passed everything. The test now asserts the state TRANSITIONS
(Ghosted at rest, Normal when a line arrives while scrolled up, Ghosted again on
returning to the bottom), not merely that something was bound.
Two notes on reading the decomp here, since both nearly misled me. Binary
Ninja's field names in this function are demonstrably shifted — it assigns a
UIElement* into m_fCurrentOpacity, a float — so the element's ROLE was
confirmed from its id and its click handler, not from a name. And the blink was
found by measuring the dat, not by reading code, because there is no blink code
to read.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice C1, completing Group C.
The authored element was already in the layout and simply never bound:
0x1000048C, a 16x16 button at the transcript's bottom-left. It now lights when
a line arrives while the transcript is scrolled up, and clicking it jumps to
the newest text.
Half of this slice turned out to be done already, and checking rather than
assuming is what kept it that way. The plan called for porting retail's rule
that IsAtVerticalEnd is sampled BEFORE the new line lands, so a player reading
back is not yanked to the bottom. UiScrollable.SetExtents already does exactly
that via preserveEnd, and chat gets it by default — so the scroll behaviour was
untouched and only the indicator was missing. Rewriting it would have been
churn on correct code.
The flag clears on reaching the bottom by ANY means, not only by clicking the
indicator. Clearing only on the click would leave it lit over text the player
had already scrolled down and read, which is worse than not having it.
Detection samples the scroll position before the rebuild, at the one moment we
know new content arrived (the revision advancing). The first build after bind
is deliberately excluded — a fresh window has not "missed" anything.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slices C2 and C3.
**C2 — Escape in the chat input did nothing at all.** Not "did the wrong
thing": nothing. Two independent facts had to hold for that. UiField has no
Escape case, AND a focused field reports IsEditControl, which makes UiRoot skip
its own fallback and the input dispatcher withhold game actions — so the player
had no way out of the bar except the mouse.
Retail maps Escape to input action 0x0B, which runs
ChatInterface::DeactivateChatEntry @0x004F2FC0: RelinquishFocus, then
Deactivate. It does NOT clear the field. That is worth stating because the
obvious guess — "Escape clears the input" — is wrong and would have looked
perfectly reasonable; a half-written message survives stepping away from the
bar, and the test pins that rather than just pinning "handled".
**C3 — the timestamp took the message's colour.** Retail appends it as its own
run at a FIXED colour index (0x0C, which BuildChatColorLookupTable @0x004F31C0
fills with colorGrey) rather than the line's, so it stays grey whether the
message is red combat text or white speech.
Most of C3 was already done and stayed untouched: the DisplayTimeStamps option
is polled, and FormatTimestampPrefix already matches retail's "%#H:%M:%S ".
Only the colour was wrong, and it was only fixable now because A1/A4 made a
line able to carry more than one colour.
The stamp is a span ROLE rather than a second tag type: it is not clickable and
carries no payload, so modelling it as a tag would have made it hit-testable
for no reason. Its colour comes from the same runtime table every message
colour comes from, unlike the tagged-name colour, which is authored per element
(0x1D) and deliberately lives elsewhere.
One consequence worth naming: a timestamped line now needs runs even when its
sender is not tagged, because the stamp alone is reason enough. Before this,
only tagged lines got runs.
Also verified and NOT changed, having checked rather than assumed: C1's
auto-scroll half is already retail-faithful — UiScrollable.SetExtents samples
"was at the end" BEFORE applying new extents and only re-sticks if so, which is
exactly retail's IsAtVerticalEnd rule, and chat gets it by default. C1 reduces
to the unread indicator, which does not exist yet.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice B2, and the autocomplete the user asked about directly.
Typing "/r " now rewrites the chat entry to "@tell {LastTeller}, " the moment
the space lands, matching ChatInterface::HandleTextReplacements @0x004F50D0 ->
SetReplyTextInChatBox @0x004F4760.
This is display sugar rather than routing: "/r hello" already SENT correctly
through ChatInputParser's reply aliases. What was missing is that the player
could not SEE who they were about to reply to before pressing enter.
The trigger strings came out of the constant pool, not the decompiled listing —
Binary Ninja renders them as bare data_* references with no preview:
data_7C4C70 = "r " data_7C4C68 = "rp " data_7C4C58 = "reply "
Retail stores them WITHOUT the leading prefix and tests the first character
separately against '/' (0x2F) or '@' (0x40), which is why both prefixes work.
The research summary for this area listed the triggers as "/t ", "/tell " and
"reply " — reading the pool corrected that.
Three boundaries, each pinned by test because each is a way to get this subtly
wrong:
- The trailing space is PART of the trigger. "/r" alone must be left alone —
the player may still be typing "/roleplay", and expanding early would
hijack a different command mid-word.
- Only on space. Running the replacer per keystroke would rewrite text out
from under someone mid-word; retail keys on 0x20 specifically.
- Only with the caret at the end. Otherwise the player is editing existing
text, and expanding would corrupt a sentence they are part way through
fixing.
With nobody to reply to, nothing is rewritten — retail leaves the text alone
rather than producing a tell addressed to nobody, and the ordinary submit path
still reports "Someone must @tell you first!".
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice B1.
CORRECTION TO THE PLAN: this slice was written as "the transcript grows for the
life of the session — a slow leak". That was wrong, and the plan said it
because I read the retail-side finding and inferred our side without checking.
ChatLog has always been bounded (ConcurrentQueue, maxEntries default 500, with
a dequeue loop in Append). There was no leak.
The real gap is the UNIT. Retail bounds the rendered transcript by CHARACTERS —
0x2710, beheaded toward 0x1D4C at a newline boundary — while we bounded the
model by messages. Two different things: a window of 500 messages is far more
scrollback than 10,000 characters, and the message cap is a safety limit on the
log rather than a display rule.
So the budget is applied where retail applies it: on the rendered window, not
the model. ChatLog's entry cap stays as the model-level bound.
Two deliberate simplifications, both registered as CT-1 rather than left
implicit:
- ONE threshold, not retail's two. The hysteresis exists to stop retail
re-trimming an accumulating buffer on every append; we rebuild the visible
list each time, so there is nothing to damp, and a second threshold would
only make the oldest visible line jump around as messages arrive.
- Whole-line cutting rather than a newline search near an offset — our unit
already IS the line, which is what retail's newline preference is for.
Filtered-out lines deliberately do not consume budget: a line this window
filters out is not in retail's buffer at all, so counting it would mean turning
a filter OFF silently shortened the visible history.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking a name filled the entry with "@tell Name, " correctly but parked the
caret at column 0, so the player had to click the chat bar to get behind their
own prefix before typing — which defeats most of the point of the affordance.
Self-inflicted in d32ef388. SetText already places the caret at the end, and I
stacked an explicit "move to the end" on top of it. MoveCaret takes a DELTA, so
int.MaxValue overflowed _caret + delta to negative and the clamp landed at
column 0. The redundant call was not merely redundant; it was the bug.
Removing it is the whole fix. The test now pins CaretPos as well as the text,
and reintroducing the call reproduces the reported symptom exactly (expected
11, actual 0).
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice A5, closing Group A. Retail's
gmMainChatUI::RecvNotice_TextTag_IIDStringClick @0x004CCE10 ->
ChatInterface::StartTell @0x004F41F0 writes "@tell {Name}, " into the chat
entry and takes keyboard focus; clicking a green name here now does the same.
The trailing space is deliberate — without it the first character the player
types joins the comma.
Three seams, each narrow on purpose:
- UiText.OnCharClick is offered the character under a left click before the
element-wide OnClick, and consuming it suppresses that. Kept separate
because a tag click is POSITIONAL and an element click is not; folding
them together would make every text element with an OnClick swallow tag
clicks.
- TaggedRangesForFragment returns tagged column ranges relative to the
FRAGMENT, because that is what a click resolves to — UiText.HitChar gives
a line index into the WRAPPED list plus a column within it. Line-relative
ranges would land every click on a wrapped line at the wrong characters.
- The controller caches those ranges alongside the runs it already caches,
so the per-click lookup reads the same cache the draw does.
The hit test is half-open: a caret slot sits BETWEEN glyphs, so clicking just
past a name's last letter belongs to the space after it, not the name. Pinned
by theory rather than left to chance, since off-by-one here means clicking a
name sometimes does nothing.
StartTell uses the tag's NAME, not its object id — retail carries the id but
this handler never reads it, so the tell still addresses correctly for someone
who has since moved out of range.
Group A is complete: names are green (A4) and clickable (A5). Ready for the
user's visual gate.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice A4, and the first slice of Group A that shows on screen: a
player's name in a chat line now draws green while the rest of the line keeps
its message colour.
The colour is AUTHORED, not computed. Retail keeps two parallel index-selected
colour arrays on the text element and refreshes both from the same caller index
on every append (UIElement_Text::AppendStringInfoWithFont @0x00469DE0):
property 0x1B for ordinary glyphs, 0x1D for glyphs under an open tag. Property
0x1D is read exactly the way 0x1B already was, carried on ElementInfo, and
seeded onto UiText beside DefaultColor. Measured on the chat transcript
(0x2100006F / 0x10000011) as RGB(0,178,0).
It deliberately does NOT go into RetailChatColorTable. That table is the
runtime-built per-LogTextType mapping; the tag colour is per-element authored
data, and filing it there would put it somewhere it would look right in tests
and be wrong in principle.
RunsForFragment is the load-bearing piece and is pure. Wrapping can drop the
space it broke on, so a fragment is NOT simply the next N characters of the
line — BuildLines locates each fragment in the source text to keep the span
offsets honest, and the mapper clips spans to the fragment window. A tag
straddling a wrap break is therefore split across both fragments and stays
green on both, instead of changing colour mid-word.
Two guards worth naming. A fragment containing no tag returns NULL rather than
a single-run list, so the overwhelming majority of lines keep the existing flat
draw path untouched. And an element authoring no 0x1D falls back to the line
colour, so a name never renders in a colour nobody chose.
The run/fragment contract is property-tested across every substring of a tell
line, because CT-A1's RunsMatchLine refuses mismatched runs by silently falling
back to flat text — a mapping bug here would degrade quietly rather than fail.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice A1. No behaviour change: this is the capability the rest of
Group A needs.
UiText already drew several differently-coloured runs on one line
(TextRun/RunsProvider, used by the character stat panel) but the path was gated
to OneLine == true, and the chat transcript is multi-line — so a chat line
could only ever be one colour. Retail's is not: a tagged glyph run takes the
element's TAG colour (property 0x1D) while the rest of the line takes the
ordinary one (0x1B), per UIElement_Text::InqGlyphs @0x00468EA0.
LineRunsProvider is a SIDECAR keyed by line index rather than a field on Line.
Roughly fifty files construct Line, and widening its shape would put every one
of them in the blast radius of a chat feature; a line with no runs draws
exactly as before.
The runs fold into the existing datLines list as extra entries at advancing
pen-X, so the S1 outline-then-fill batching is untouched — a multi-colour line
still submits its whole outline pass before any fill, and cannot notch the
descender of the line above.
Two things are deliberately load-bearing:
- RunsMatchLine. Selection, hit-testing and the caret all index into the FLAT
line text, so a run list that disagrees with it would draw one thing and
select another. The draw path verifies the runs say exactly the same
characters and falls back to the flat line if not, rather than trusting the
caller.
- LayoutRuns is pure. The pen-advance is the part that silently mis-renders
if it drifts, so it is testable without a font atlas or a GPU — which also
keeps its tests in the ordinary gate rather than the SystemFont lane.
Solution builds clean; full hermetic gate green, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both entries were deliberate no-ops — the code said so — and both showed a
static label where retail shows the selected player's NAME.
Retail builds them in gmMainChatUI::InitTalkFocusMenu @0x004CDC50 and rebuilds
their labels every time the menu opens, substituting the selection through
StringInfo::AddVariable_String (@0x004CD91C / @0x004CD982). So they now read
"Tell to Dww" / "Squelch (ignore) Dww", rebuilt on open from a live selection
provider, and grey out with nothing selected — retail arms the tell slot only
for a talkable target (SetTalkFocusEnabled(2, 1) @0x004CD9B0).
Picking "Tell to X" aims the chat bar at X. That needed one piece of plumbing:
the parser's plain-speech fallthrough returned a null target, so a line typed
under a Tell focus was dropped by the router for having no one to send to.
Parse/Submit now carry an optional default tell target for exactly that case.
"Squelch X" publishes the ALREADY-REGISTERED /squelch verb rather than
reimplementing the request — the ModifyCharacterSquelch wire builder
(CM_Communication::Event_ModifyCharacterSquelch @0x006A42D0) has been there all
along; only the menu path to it was missing.
UiMenu gains an OnOpen seam, because a menu whose Items are fixed at Bind can
only ever say "Tell to Selected". It fires before _open flips so the rebuilt
rows are measured and drawn in the same opening.
Solution builds clean; 14,480 tests pass on the standard hermetic lane filter,
0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "Beneficial Spells in Effect" window rendered its rows only in the top
249px and painted the rest of the list as empty black background, with a
scrollbar thumb sized for a viewport far smaller than the visible one. It did
not depend on window size, and the last visible row was sliced mid-height --
a clip boundary, not a missing row.
Root cause is the #412 class again. The authored list element (0x10000123) is
a UiTemplateListBox, not a UiItemList, so EffectsUiController creates the item
list itself and attaches it as a child with fill anchors. That baseline is
captured lazily on the child's first ApplyAnchor -- which lands AFTER the host
has already been resized to the restored window height in the same frame. The
capture then measures a bottom margin of (hostH - 249) and ComputeAnchoredRect
preserves it forever: h = hostH - (hostH - 249) = 249, at every subsequent
size. Rows past 249px fail LayoutCells' cull test and never draw.
Capturing the baseline at creation, while the list's extent still exactly
equals the host's, makes the margins (0,0,0,0) so it tracks the host from then
on. Identical fix and reason to UiTemplateListBox's own viewport seed. The
spellbook's component list is built by the same pattern and had the same
latent defect; it is fixed alongside.
Why it shipped: every existing test in EffectsUiControllerTests supplies a
synthetic UiItemList as the list element, so `host is UiItemList` is true and
the controller uses it directly -- the create-and-attach branch that actually
runs against real dat was never exercised. The new test binds the real
fixture, which builds the real UiTemplateListBox. Neutralising the fix makes
it fail with the exact production numbers (expected 547, actual 249).
Measured, not guessed. tools/LayoutDump grew --resize, which reproduces
retail's raw-edge policy (UIElement::UpdateForParentSizeChange @ 0x00462640)
offline, and it ruled out the authored geometry, the import, the layout policy
and the window frame in turn -- all four are faithful. The 4px gap between the
scrollbar and the window's inner edge is likewise authored: the user confirmed
retail shows the same gap, so it is deliberately left alone.
Solution builds clean; 14,465 tests pass on the standard hermetic lane filter,
0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four reports from one gate round. Three were mine; the fourth I first
mis-explained, and the correction is the useful part.
**The vital regeneration rates were never cast.** Regeneration (health),
Rejuvenation (stamina) and Mana Renewal (mana) all landed in the catch-all
Other bucket, which is off by default. Retail words each of the three
differently and two of the six phrasings do not begin with "Increases the
caster's" at all:
Increase caster's natural healing rate by 10%. <- and note "Increase"
Increases your Health Regeneration Rate by 50%. (Empyrean)
Increases the rate at which the caster regains Stamina by 10%.
Increases the caster's natural mana rate by 10%.
They are matched per vital, on by default, and ranked at the very tail of the
Life group so they finish the pass. The mana line had to be checked BEFORE the
generic "Increases the caster's X by N" match, which would otherwise read it as
a buff to a stat named "natural mana rate".
**Aura of Hermetic Link was the sixth aura line and the only one missed.**
"a magic casting implement's" is reached by none of the other alternatives, so
the wand's mana-conversion buff was silently in Other too.
**Right-clicking a spell in the spellbook did nothing.** I claimed this had
never worked; the user said it used to, and they were right -- I had checked
one file's history and concluded from it. The regression is 3e31b0ac, which
gave UiCatalogSlot its own RightClick case returning true unconditionally. On
any list that had not wired the examine seam -- the spellbook among them -- the
event was reported handled and UiRoot stopped bubbling. Two fixes: the row now
reports an unwired right-click UNHANDLED so bubbling continues, and the
spellbook wires the seam to the same appraisal window the spell bar uses.
Retail does this generically in the list rather than per window
(UIElement_ItemList::ListenToElementMessage @ 0x004E4F1F -> ExamineSpell
@ 0x00564A70), which is exactly why a per-controller seam could be forgotten
for one window and not another.
**No green flash when pressing an indicator.** Every indicator button authors
a full-size 0x100000F2 child whose DirectState is a draw-nothing File=0 image
and whose only other state, Normal_pressed, carries the green selector sprite
0x06004CE8 -- and the buttons author Normal_pressed with PassToChildren. But
UiButton.ConsumesDatChildren drops dat children at import, so the cascade had
nothing left to reach. The child is re-attached through the same repair the map
hotspot's rollover highlight already uses.
**tools/LayoutDump** is new, and is why the last two are diagnoses rather than
guesses: it prints an authored LayoutDesc tree -- geometry, edge modes, state
sets, PassToChildren, per-state media -- straight from the installed DATs.
"Does this button even have a pressed state?" was being answered by reading our
own importer and inferring; now it is read from the data.
Solution builds clean; 14,464 tests pass on the standard hermetic lane filter,
0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four defects from the first in-world look, three of them with a definite root
cause rather than a plausible one.
**The Buff button did nothing.** Not a hit-testing problem -- the pointer found
the button perfectly. UiRoot's press handling asks the pressed widget whether
it owns the pointer; a widget that does not claim the press falls through to
"move the ancestor window", and a window drag returns early on release without
ever emitting a Click. UiButton and UiClickablePanel both override
HandlesClick for exactly this reason; UiSimpleButton never did. Latent since
that class was written, and invisible until it was put inside a draggable
window -- which is precisely what a markup plugin panel is.
Found by reproducing it headlessly through the real UiRoot dispatcher rather
than by reasoning about it: MarkupPanelClickTests drives press-and-release over
the button and asserts the bound action ran, with a separate test asserting the
pointer finds the button at all, so a future failure says which half broke.
My earlier guess -- that a modal at character select was swallowing the click
-- was wrong, and the screenshot of the panel live in world disproved it.
**"0 trained skills".** The skill-name table was read in OnLoad *before*
GameWindowCompositionPipeline.Run, which is what publishes the DAT collection,
so _dats was still null, the whole block was skipped, and the surface reported
an empty skill list with nothing to explain it. Bound in PublishDatCollection
instead -- the moment the data exists -- so it cannot run early again whatever
the phase ordering does, and a genuinely missing SkillTable now says so.
**Plugin text used the development bitmap font.** UiLabel and UiSimpleButton
gained a DatFont, and MarkupDocument now takes the retail interface font from
the host, so plugin panels render through the same glyph path (including
retail's two-plane outline) as authored panels.
**MossTank now writes to chat.** New BCL-only IPluginChat routes to retail's
ClientLocal log type (0x1A) -- the channel the client uses for its own notices,
local to this client, so a plugin cannot speak in the player's name. MossTank
announces the start, the finish with a cast count, and a stall.
Not addressed here: the cursor showing blue rather than amber. Traced but not
fixed -- CursorFeedbackController picks the cursor family from combat mode, and
CombatMode.Magic selects the blue Magic cursor where Default is amber. That is
a combat-mode question, unrelated to this change, and worth its own look rather
than a speculative fix folded in here.
Solution builds clean; 14,437 tests pass on the standard hermetic lane filter,
0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First consumer of acdream's plugin automation surface, and the first slice of
the VTank-class plugin milestone
(docs/research/2026-07-29-vtank-plugin-automation-requirements.md).
MossTank shows a panel with a Buff button; clicking it casts every self-buff
the character is missing, skips what is already in force at an equal or higher
tier, and refreshes what is nearly expired.
The host/plugin line is the load-bearing decision here. The host publishes
spell DATA -- family, tier, difficulty, mana, duration -- plus a cast
primitive with a preflight gate. The plugin owns the POLICY. That is the
architectural conclusion the requirements research reached: VTank's engine
lived in plugin-land, built on Decal's primitives, and baking "best buff for
skill X" into the host would start pulling the engine inward one convenience
at a time.
Why the plan is driven off the spellbook rather than off trained skills, which
is the obvious reading of "buff every trained and specialised skill": the
client cannot honestly make that mapping. The link between a spell and the
stat it modifies arrives from the SERVER in the enchantment message and is
absent from the client's own spell table. What the client does know is which
spells the character has learned -- and a character only learns buffs for the
skills they use, so the spellbook reaches the same set without inventing a
mapping the client has no grounds for.
Surface added, all BCL-only so Plugin.Abstractions keeps its zero project
references:
* ICharacterInfo, ISpellCatalog, IMagicCommands, grouped behind one
IAutomationSurface so IPluginHost grows by one member rather than three.
* IEvents.Tick. Automation is sequences, not single calls -- a buff pass casts
several spells and must wait between them. Without a host tick a plugin
would need its own timer thread re-entering the host off its update thread.
* NoOpAutomationSurface for hosts with no live session, so a plugin keeps one
code path and checks IsAvailable.
Markup gained <button> and <label>; it previously supported only <meter>, with
a comment promising the rest. Buttons bind onclick to an Action property and
FAIL THE PANEL LOAD if it does not resolve -- a silently dead button is worse
than a panel that refuses to load, because the user clicks and there is
nothing to diagnose. Labels bind through a Func so a status line tracks its
binding instead of freezing at build time.
Enchantment reads use EnchantmentsInEffectSnapshot rather than the raw active
set: retail leaves a weaker same-family enchantment in the registry while a
stronger one is in force, and a plugin asking "am I buffed?" means in force.
BuffPlan is a pure function of (known buffs, active enchantments) precisely so
it can be tested without a session; 9 tests cover tier supersede, the
family-0 no-stack bucket that must not be de-duplicated, expiry refresh, and
plan stability across the rebuilds the tick loop performs.
Solution builds clean; 14,421 tests pass on the standard hermetic lane filter,
0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run 170's Windows gate went red on 37 tests across four assemblies while the
same commit passed 14,370/0 locally. The failures were all one family:
Expected: "You have 1 500p" <- built with the machine's culture
Actual: "You have 1,500p" <- production, correctly invariant
The runner is Swedish; this dev box is not. These tests had been passing on CI
only because that machine's registry locale had been pinned by hand — machine
state, which came undone (almost certainly the reboot after today's hang).
Re-pinning it would be a workaround on one machine for a defect in the repo,
so this fixes the repo instead.
Two genuinely different bugs were hiding in that one symptom.
1. TESTS that build an expected string with the ambient culture and compare it
to invariant production output, and test-side recording sinks whose traces
are compared against literal golden strings. Those only ever passed on a
machine that happens to format like the invariant culture. Pinned to
InvariantCulture: the vendor purse/cost expectations, and the motion-funnel,
animation-sequencer, framebuffer-resize, resource-slot, and runtime-attack
trace sinks.
2. PRODUCTION that formats player-visible retail text with the ambient culture.
This one matters beyond CI: retail is a US client, so it shows "2.50",
"1,500p" and "(-20)" to everyone. On a Swedish machine acdream was showing
"2,50", "1 500p" and "(-20)" with U+2212 MINUS SIGN — the audience for this
alpha is literally Swedish. Converted 76 sites to InvariantCulture across the
item/creature appraisal formatters, the character stat panel's buff and vitae
parentheticals, the appraisal and link-status controllers, the chat
/framerate and /location output, the camera sensitivity toast, the
time-override toast, the F3 dump, the sky diagnostics, and the world-frame
invariant-failure message.
DATES are deliberately left on the current culture (CharacterController's
birth/login stamp, RuntimeHouseState's purchase expiry). Retail has no answer
for a non-US player's date format, and forcing "08/19/2026 7:00:00 PM" on
them is a UX decision, not a retail-fidelity one.
Apparatus, so the next occurrence is reproducible instead of mysterious:
tests/TestCultureInitializer.cs adds an opt-in ACDREAM_TEST_CULTURE knob to
every test assembly, linked in through a new tests/Directory.Build.props.
Unset — what CI and everyone runs — it changes nothing.
ACDREAM_TEST_CULTURE=sv-SE dotnet test ...
reproduced all 37 CI failures on this machine plus 6 more the runner's own
locale does not surface (the Unicode-minus family), and drove the fix.
Verified both ways on the full solution under the release-gate filter:
default culture 14,370 passed / 0 failed, and ACDREAM_TEST_CULTURE=sv-SE
14,370 passed / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every launcher-started play session on 2026-08-19 died a few seconds after
login. The user's own session evidence shows it three times in a row:
started -> connected -> characterList -> exited code 1 "crashed", with
client.err.log carrying
System.ArgumentNullException: Value cannot be null. (Parameter 'key')
at System.Collections.Generic.Dictionary`2.FindValue(TKey key)
at AcDream.App.UI.UiButton.OnDraw(UiRenderContext ctx)
UiButton allocated its per-face-segment media-state array as `new string[n]`,
leaving every element null, while the single-face sibling _faceMediaState was
correctly seeded to "" (DirectState). NextMediaState returns `current`
unchanged on three of its four arms — including retail's own "committed state
authored with an empty media array keeps the previous media playing" rule — so
on a multi-segment button whose committed state carries no media the null
survived the first SyncMediaStates and reached
ElementInfo.StateMedia.TryGetValue(null), throwing mid-paint and taking the
process down.
Seed the array with "" at construction. That is what the constructor's
existing comment already claimed the media machine did ("the media machine
begins on the element's BASE media"); only the segment array was left out.
Verified by reverting the one-line fix: the new regression test throws
ArgumentNullException from UiButton.ActiveFile, the same frame as the live
crash. AcDream.App.Tests UiButton filter: 41 passed, 3 skipped.
Found while investigating Campaign LU item 4 ("launching the selected
character doesn't work") — this is why nothing worked. Also lands the Campaign
LU plan doc, whose recon section records the mechanisms the remaining slices
build on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#416 (char-select roster highlight never cleared on hover-leave): three
decomp-grounded mechanisms replace the media-keyed _availableStates
approximation.
- UIElement_Button::UpdateState_ @0x00471CF0: the button machine commits
ONLY states authored on the button's OWN ElementDesc (AccessStateDesc
gate); unauthored requests no-op, preserving custom semantic states.
- UIElement::SetState @0x00464E70: an unauthored state id is coerced to
state 0 (the unnamed base state) and committed — ported into
UiDatElement.TrySetRetailState with the base-descriptor PassToChildren
cascade arm.
- The SetState media rule @0x004651c0: a committed state replaces the
playing media ONLY when its media array is non-empty. UiButton now keeps
per-face-segment media states under that rule (segments model retail's
PassToChildren children), and LayoutImporter records the raw MediaCount
including the File=0 draw-nothing images the drawable filter drops —
the roster bar children's base state is exactly such an image, and it is
what clears the bar.
The row template truth (probe, installed DAT): the row authors EMPTY
Normal/rollover/Highlight descriptors with PassToChildren; the three bar
children author rollover/Highlight media, NO Normal state, and a File=0
base image. An empty-media Normal_pressed still never blanks a Normal-art
button (the media rule keeps the previous art — the exact behavior the
old gate approximated), and the Appearance spins' property-only Highlight
now genuinely commits: label recolors, arrow art lingers — the retail
split AP-222 approximated with a requested-keyed label hack, now retired.
Live-verified at char select: hover +alex shows the grey bar, moving off
clears it, the selected row keeps its amber bar.
#415 (probe wait world-* verbs dead): the filed snapshot-reset diagnosis
was wrong — the automation bridge simply never bound without
ACDREAM_AUTOMATION_ARTIFACT_DIR. A facts-only
WorldRevealFactsAutomationRuntime now binds whenever the retained UI
exists; checkpoint/screenshot verbs still require the artifact directory
and now report that instead of a generic timeout.
App tests 5568/3 skips, Runtime 1756/0, UI.Abstractions 926/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The exit-world confirmation (ID_Client_EndCharacterSessionConfirm, table
0x23000001 key 0x0EB1C41D) rendered its literal two-character "\n" escapes
because escape decoding lived in individual consumers — Batch E centralized
it for authored captions only (DatWidgetFactory.ResolveAuthoredString), and
each new string surface had to remember its own copy. The installed DAT
carries the escape in 4,365 of 7,050 strings; per-consumer normalization
was structurally guaranteed to keep leaking.
Retail's placement is the SOURCE, not the widget: every public StringInfo
resolution ends in StringTableMetaLanguage::UnescapeString @ 0x0067BDC0
(StringInfo::InqString @ 0x0042E490, GetLiteralValue @ 0x0042CA50), the
write side escapes (SetLiteralValue @ 0x0042C980; AddVariable_String
@ 0x0042E6C0 for template variables), and widgets receive decoded text.
Ported exactly:
- NEW RetailStringEscapes: UnescapeString/EscapeString + the
GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0 tables
(\n \t \r \q + the ten metalanguage self-escapes []!{}#\|^$,
byte-verified against the PDB-paired 2013 binary at 0x3FE178;
unrecognized pairs stay verbatim).
- DatStringResolver.Resolve/ResolveAll unescape at the source;
ResolveTemplate escapes each variable on insert and unescapes the
composed whole — retail's round trip, so variable content (player
names) can never be corrupted by the final decode.
- RETIRED the consumer copies (double paths would corrupt an authored
"\n" into a line break): DatWidgetFactory.NormalizeEscapes + BuildText's
inline replace, RetailUiRuntime.NormalizeRetailNewlines + the
OpenCaptureInstructions inline replace, DatRichText.Compose's replace,
IndicatorDetailText.Shape's replace. ItemAppraisalTextLayout's replace
stays — WIRE-domain (server strings never pass the DAT source; retail's
ItemExamineUI::AddItemInfo @ 0x004AC050 appends wire text verbatim), now
documented as such.
- Consumer CR-strips retired with them: the installed DATs contain ZERO
real CR characters (sweep-measured) and UiText.WrapWords already drops
strays.
Tests: RetailStringEscapes conformance (escape set, unknown pairs,
round trip), DatStringResolver source-decode pins (including the exact
user-reported exit-world text shape and a backslash-carrying variable),
the installed-DAT escape sweep (7,050 strings; every resolution must equal
the retail unescape of the raw entry; inventory printed), and the existing
caption/rich-text/live-DAT pins relocated to the source contract.
App 5550/3 (live-DAT), Runtime 1747/0, complete Release solution green
across all suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The login tunnel now covers from the first world-facing frame (the
sky-void backdrop can never present pre-tunnel) and holds through an
atomic tunnel-to-world swap at reveal completion — the void is
structurally unreachable on both edges, pinned by frame-sequence tests
across WorldSceneRenderer/WorldRevealCoordinator/LocalPlayerTeleport-
Controller/RuntimeWorldTransitState. Vitals detail icons draw at their
authored centered offsets in both stacked and side-by-side layouts.
Implemented and live-probed by the fix agent; finalized by the lead
after the agent parked post-verification (gates re-run green:
App 5512/3, Runtime 1747/0).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two end-to-end additions to the fixture toggle suite: a body press through
UiRoot.OnMouseDown's actual hit test + bubble (left toggles, right toggles),
and a press on the authored top drag bar (0x1000063C) arming the window
move WITHOUT toggling — the retail Dragbar-consumes-the-press semantics
proven against the real input path, not just injected OnEvent calls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of retail's SideBySideVitals character option, derived end-to-end:
retail authors TWO complete vitals windows and swaps their VISIBILITY on
the option bit — nothing is rearranged in place.
- gmFloatySideVitalsUI (0x10000056, Register @0x004D0490) is a second
full vitals window from LayoutDesc 0x21000075: 460x26, the same three
meters (0x100000E6/EC/EE), cur/max labels (0x100000EB/ED/EF), and
detail-icon overlays authored id-for-id with the stacked window — so
the same VitalsController.Bind and the inherited UiVitalsRoot click
toggle apply unchanged. Authored constraints ride the root's
0x3C..0x3F (fixed 26 height, width 360..3000) through
DatConstraintSource.
- Visibility ownership: gmFloatyVitalsUI::UpdateFromPlayerModule
@0x004CF140 shows the stacked window iff PlayerModule::SideBySideVitals
== 0; gmFloatySideVitalsUI::UpdateFromPlayerModule @0x004D0810 shows
the side row iff set; gmGamePlayUI::RecvNotice_PlayerOptionChanged
@0x004E9DA0 flips both live on option id 0x13.
- The bit: PlayerModule::SideBySideVitals @0x005D3070 =
(options_ >> 0x15) & 1 — CharacterOptions1 0x00200000, ACE-confirmed;
CharacterOptionTable already carried the exact row (PlayerModule-blob
group, not a 0x0005 auto-save id).
acdream shape: MountSideVitals mounts the second window hidden;
VitalsSideBySideController polls the borrowed J4 option bit once per
frame from RetailUiRuntime.Tick and applies BOTH windows' visibility on
the edge — covering the mount default, the PlayerModule blob arriving
after mount, and the Character tab's live checkbox with one mechanism.
Both window names join stateManagedVisibilityWindows so the saved layout
never restores a visibility the option owns. The Character tab's
SideBySideVitals row un-dims (StoreOnly → Live) with a real reader —
33 dimmed / 17 live.
4 new controller tests (initial apply both directions, live edge swap
both directions, steady-bit non-reassertion). App suite Release live-DAT
5499 passed / 3 skips; Runtime 1744/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of gmVitalsUI's press toggle, derived end-to-end from the named
retail decomp + the authored DAT data (installed-DAT probe 2026-08-17):
- gmVitalsUI::ListenToElementMessage @0x004BFC00: mouse press (msg 0x1C,
dwParam1 7=left or 0xA=right — the same param pair the spellbook's
select/favorite handler @0x0048C033 disambiguates) flips
SetState(m_state == HideDetail ? ShowDetail : HideDetail). Both floaty
subclasses (gmFloatyVitalsUI 0x1000004D / gmFloatySideVitalsUI
0x10000056) inherit it verbatim.
- UIElement::SetState @0x00464E70 cascades through the authored
PassToChildren chain: root and meters author media-less
HideDetail/ShowDetail StateDescs with PassToChildren=true.
- HideDetail (0x10000006) = the NUMERIC mode: the cur/max labels author
{0x3B:false} (0x3B = invisible; UIElement::OnSetAttribute case 8
@0x00462DAE is SetVisible(value == 0)), the 0x100004A9 overlays author
File=0.
- ShowDetail (0x10000007) = the GRAPHICAL mode: labels author {0x3B:true}
(numbers hidden); each bar shows its authored icon pair — dim back icon
unclipped over the track, bright front icon clipped with the front
container to the fill fraction (UIElement_Meter::DrawChildren
@0x0046FBD0 clips the whole element-id-2 child; m_pcChildImage =
GetChildRecursive(this, 2) @0x0046F7E3). Health heart 0x06007490/91
(18x16 @66,0), stamina sword 0x06007492/93 (85x16 @32,0), mana scepter
0x06007494/95 (100x16 @25,0) — identical authoring in both 0x2100006C
and 0x21000075.
- Initial state is the authored Undef (numbers visible, no icons —
visually HideDetail); retail's first press lands on HideDetail, then
the pair toggles forever. NOT persisted: SaveScreenLayout @0x004EAD50
writes window rects only, and no PlayerModule option is touched — the
mode resets per session, per window.
- Presses on drag bars / resize grips do not toggle: retail's
UIElement_Dragbar @0x0046C850 and UIElement_Resizebar @0x0046B930
consume the press (return 2) before it can bubble to the root.
Implementation: new UiVitalsRoot behavioral widget registered for the
three gmVitals class ids (press handler + state flip over the existing
UiDatElement state machine); UiMeter absorbs the two 0x100004A9 overlays
(ConfigureDetailOverlay + ShowDetail-keyed draw, back unclipped / front
fill-clipped) and forwards the detail states to its absorbed text child;
UiText.ApplyDatState gains the same named-state-only 0x3B honor
UiDatElement already had (the DirectState 0x3B class stays gated — #408).
8 new fixture-driven conformance tests (toggle sequence, right-press,
label cascade, chrome exclusions, per-window independence, overlay
extraction). App suite Release live-DAT: 5495 passed / 3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User finding 3 (retail screenshot): hovering a town on the Map tab turns
its marker GREEN and shows the name on a special-font tooltip — clearly
not our generic 0x10000395 popup skin, and we had no hover highlight at
all.
Re-derivation (live-DAT probe + raw ElementDesc dump + surface
byte-decode; MapNoteLiveDatTests pins all of it):
- m_pMap (0x100001EC)'s P0x47/P0x48 = 0x100001F0 @ 0x21000026 are the
note CONSTRUCTION template (AddMapNote @0x004a1bb0's
CreateChildElement args) — that part we had right.
- The TEMPLATE's own DirectState authors the note's tooltip popup
locator P0x47=0x10000398/P0x48=0x21000041 — the FOURTH popup skin,
whose incorporated text child 0x10000396 fonts 0x40000015 where the
other three skins font 0x40000002 (the user's "special font") — plus
P0x50=0.0 (zero per-element tooltip delay: town tooltips fire the
instant the dwell arms; UiRoot already honors it), P0x4B TooltipOn,
and P0x13 RolloverEnabled. Batch C's "the template authors no locator
of its own" claim was WRONG, and BuildTownMarkers' hardcoded
shared-skin override was clobbering the authored values — removed.
- The hover highlight: the template's Normal/Normal_rollover states are
PassToChildren descriptors driving the swallowed highlight child
0x100001F1 (base 0x100002B7@0x21000042 — a four-piece frame all
drawing 0x06004CC9, byte-decoded PURE GREEN A=FF R=00 G=FF B=00) via
per-state P0x3B (Invisible): hidden at rest, green on rollover.
Port:
- UiButton.CascadeStateToChildren — retail UIElement::SetState
@0x00464E70's PassToChildren cascade, keyed off the REQUESTED state id
(properties commit unconditionally; only the sprite draw is art-gated,
the existing #382/AP-222 distinction).
- UiDatElement.TrySetRetailState honors per-state P0x3B for NAMED states
(OnSetAttribute @0x00462d80 case 8: SetVisible(value==0)). The
unnamed-DirectState case is explicitly excluded — honoring it would
un-gate ISSUES #408 (1,083 authored-invisible elements) through
BuildWidget's post-children state reapply; measured breaking the
spell-favorite drag tests before the scoping (note added to #408).
- MapPageController.BuildTownMarkers rebuilds the button-swallowed
highlight child per marker through the AD-108 IconBuilder seam
(Bindings.TemplateInfoResolver, backed by
RowTemplateResolver.ResolveInfo — same cache) and arms it with the
initial Normal cascade.
Register TS-85's Batch C paragraph corrected; RetailTooltipPresenter's
F10 shared-skin remark updated (MapPageController no longer a consumer).
Tests: 3 installed-DAT pins (locator/delay/rollover; per-state P0x3B +
green frame; the four-skin font sweep), UiButton cascade + UiDatElement
P0x3B units, MapHousePanel marker no-clobber + hover-highlight fixture.
App suite 5487 passed / 3 skips (5490 total, +11 over baseline);
Runtime 1744/1744.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User finding 1 (side-by-side vs retail): our world-object tooltips popped
the instant the found object changed; retail's "lag". The night round's
derivation from RecvNotice_SmartBoxObjectFound @0x004E5AD0 misread the
notice as edge-MOUNTING: its immediate StartTooltipAtMouse @0x004E5DFB is
inside `if (s_pInstance->m_dragElement != 0)` (@0x004E5D8E) — and
m_dragElement is a real, distinct PDB field in acclient.h's
UIElementManager (separate from the m_pTooltipElement family), so the
immediate mount is DRAG-AND-DROP ONLY. The ordinary hover path merely
STAGES the name (SetTooltip @0x004E5D74 + the |=0x20 TooltipOn bit) and
the display rides the SAME UIElementManager::CheckTooltip @0x0045B6E0
mouse-idle dwell as UI tooltips: 250 ms (m_tooltipDelay @0x0045f75d)
since m_lastMouseMoveTime (stamped on EVERY move, MouseMoveHandler
@0x0045e736). Found swaps under an IDLE mouse replace the popup the same
frame (SetTooltip's own text-change teardown @0x004617FF -> ResetTooltip
@0x0045C360 tail-calling CheckTooltip); the 10 s duration expiry
(@0x0045b78a) requires a fresh mouse move before re-arming
(SwitchMouseOver(null) @0x0045b7b2 clears m_pElementLastEntered).
Port: UiRoot gains the unconditional last-mouse-move stamp
(m_lastMouseMoveTime 1:1 — the existing _hoverStartedMs stamps are
deliberately conditional) exposed as MouseIdleMs/NowMs;
RetailTooltipPresenter.UpdateWorldHoverTooltip now stages text at the
notice edge (ShowTooltips gate + name resolve read there, @0x004E5D21/
@0x004E5D3B, empty-name SetTooltip skip @0x004E5D48 included) and mounts
via the CheckTooltip dwell block (no-capture gate @0x0045b715,
m_tooltipEnable via MouseHover @0x0046254C — which the drag-immediate
branch faithfully bypasses). Session reset also forgets the staged text.
Tests: the world-hover fixture section rewritten to the corrected model —
found edge stages but never mounts before the dwell; a continuously
moving mouse never mounts until it rests; idle found-swap replaces
same-frame without stacking; duration auto-hide needs a move + fresh
dwell to remount; drag-in-progress mounts immediately. 38/38 pass.
Register TS-85 and ISSUES item 2 corrected honestly: the "edge-fired
(no dwell)" conclusion is superseded by the user's retail evidence and
the m_dragElement branch read.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two mechanisms, both live-verified (register row AD-108 updated to match):
1. RESOLUTION. The player/house icons (0x100001ED/0x100001EE) are authored
as nested dat children of m_pMap (0x100001EC), itself a Type-1 button
whose UiButton.ConsumesDatChildren swallows them at build. The old
ResolveSwallowedIcon re-imported them standalone via
ImportInfos(hostLayout, iconId) — which returns null on the live DAT:
FindDesc walks the raw top-level Elements table (one entry for
0x2100006E) and never reaches them. Their ElementInfos only materialize
inside the full panel-slot resolve (ImportInfos(0x2100006E, 0x1000018C))
that MountMapHousePanel already imports — the pageInfo Bind already
receives. The fix finds each icon's info under m_pMap's own resolved
info subtree and BUILDS it through the new Bindings.IconBuilder seam
(production: LayoutImporter.Build under the DAT lock — the build half
of RowTemplateResolver's shape). An icon the normal walk DID build is
preferred (FindDescendant first), so a future ConsumesDatChildren
policy change cannot double-build.
2. POSITION. Found by this fix's own F1 live verification: the resolved
ring rendered pinned to m_pMap's top-left. PlaceMarker owns marker
position outright (retail's gmMapUI::Update re-places every tick;
retail's UpdateForParentSizeChange runs only on real parent resize),
but acdream re-runs ApplyAnchor per frame and the icon's compatibility
anchor had captured the authored (0,0) rect while the panel was still
hidden, re-asserting it over PlaceMarker's writes every frame.
PrepareIcon now sets Anchors=None (clearing any imported LayoutPolicy),
the established runtime-positioned-element convention.
Live numeric gate (session character +Acdream, cell 0xF07E003F):
independent computation (gid_to_lcoord -> display (90.8E, 0.5S) ->
byte-decoded PlaceMarkerOnMap formula, 17x16 icon, marker area
(6,8)-(247,258)) predicts local pixel (226,125); the connected client's
UI-tree dump shows the icon at screen (1166,195) under m_pMap (940,70) =
local (226,125) — exact match in both panel-open dumps. Coordinate text
"0.5S,90.8E", Holtburg town-marker tooltip (real-mouse hover), and the
House tab's "You may buy another house immediately." sentence all
confirmed on screen; ACE-confirmed graceful logout.
New pin: MapHousePanelLiveDatMountTests ([InstalledDatFact]) reproduces
the production mount recipe against the installed DATs — the test that
would have caught this at Batch C: pins the cold-import null, the
panel-slot resolution of both icons with non-degenerate extents, AND
that PlaceMarker's writes survive the per-frame ApplyAnchor pass.
Gates: Release build green; App suite (live-DAT mode) 5479/3 skips
(baseline 5478 + the new pin); Runtime 1744/0; full solution green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F11: logs when a Map tab town-marker's template resolves to something
other than a UiButton — that path previously silently skipped the
TooltipText write with no diagnostic, leaving a mounted-but-empty
tooltip popup indistinguishable from "no template configured".
F13: RetailSkillFormula.FormatFormula now reads Attribute1Multiplier/
Attribute2Multiplier/AdditiveBonus/Divisor through the SAME unsigned
reinterpretation TryCalculate already uses (this class's own doc
comment already stated the invariant; FormatFormula just didn't follow
it). A high-bit-set value would previously both mis-gate hasAttr1/
hasAttr2 and print a negative number, out of sync with what
TryCalculate actually computes with for the same formula. Added
regression tests, empirically verified to fail without the fix.
F14: documented the RefreshHouseMarker gap rather than guessing at the
byte-decode — Position::get_outside_cell_id @0x004527b0 is itself
BN-mangled (its `(eax_2 - eax_2) & objcell_id` return is the same
decompiler-obscures-a-real-conditional artifact class this round hit
elsewhere) and depends on LandDefs::adjust_to_outside, a genuinely
larger port than this round's other findings. HousePosition is wired
() => null in production today (ISSUES #413's remaining scope), so
this method is currently unreachable; left a TODO citing the retail
call chain for whenever that lands.
F15: fixed RefreshCoordinatesAndPlayerMarker's gate to AND-on-both-
present, matching gmMapUI::Update @0x004a2078's exact
`if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0)` condition.
The prior `_coordinateText is null && _playerIcon is null` check only
skipped when BOTH were absent (proceeding whenever EITHER was
present), letting coordinate text and the player marker update
independently instead of as the single gated unit retail treats them
as. Added a regression test (player-icon template resolution failure
must also skip the coordinate-text write), empirically verified to
fail without the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F9: filed register row AD-108 for MapPageController.ResolveSwallowedIcon
— the standalone re-import of the Map tab's player/house icons, which
m_pMap's own Type-1 UiButton authoring swallows as dat children
(UiButton.ConsumesDatChildren). This adaptation was implemented but
never had a register row.
F10: extracted the popup-locator pair (0x10000395/0x21000041),
previously duplicated as three separately-cited private constants
across UiItemSlot.cs, RetailTooltipPresenter.cs, and
MapPageController.cs, into ONE public pair on RetailTooltipPresenter
(SharedPopupSkinRootElementId/SharedPopupSkinLayoutDid) with a single
canonical citation. The other two sites now reference it instead of
carrying their own copy.
F12: fixed TS-85's SetTooltip-site arithmetic. The register (and a
mirrored ISSUES.md log entry) claimed "15 known sites, all accounted
for" — recounting the row's own enumerated list finds 17 distinct
sites (the tally had dropped gmPaperDollUI::UpdateItemSlotTooltip
@0x004A52EF and undercounted by one more), of which 16 are ported and
one — UIElement_Text::RecalculateTruncation @0x00466F80, the headline
highest-volume site sub-mechanism (1) itself named as deliberately
deferred — was never actually closed. The "all 15 accounted for"
close was wrong twice over: wrong count, and a site the row's own text
already scoped as open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F5: moved the unconditional RemovePopup() call into
RetailTooltipPresenter.TryBuildAndMountPopup itself so the single-
popup invariant (retail's own single m_pTooltipElement slot) is
enforced structurally rather than relying on every caller to have
already cleared a stale popup. Closes a real hole: UpdateWorldHoverTooltip's
own clear is gated on _worldTooltipShowing (only true when the WORLD
path itself mounted the current popup), and its "a UI popup cannot be
showing here" comment assumed the host's hover query is null whenever
that branch runs — an assumption that breaks the instant a modal opens
over a stationary cursor. UiRoot.Modal claims EXCLUSIVE hit-testing, so
Pick(MouseX, MouseY) can return null even though a UI-dwell tooltip is
still mounted underneath; UpdateWorldHoverTooltip would then mount a
second popup on top without ever clearing the first.
F6: fixed WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks to
actually exercise the transition with a follow-up presenter.Tick()
(the old test only proved OnTooltipShow's own clear worked, never
checked the world-side bookkeeping after). Added
UiDwellTooltip_ThenModalStealsHitTesting_WorldHoverReplacesRatherThanStacks
for F5's own case, using UiRoot.Modal to reproduce the exclusive-hit-
testing hole precisely — empirically verified this new test fails
(2 popups instead of 1) with the structural RemovePopup() reverted,
confirming it is a real regression test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F3: TS-85 had claimed the plain-spell branch's three SetTooltip format
strings were "genuine gmNoticeHandler vtable SLOTS" and unrecoverable
from the decomp dump. That was itself the artifact — Binary Ninja's
pseudo-C rendering of PStringBase::sprintf's second argument as
"&gmSpellcastingUI::`vftable'.RecvNotice_XXX" was a spurious symbol
match, not the true operand. A direct capstone disassembly of the raw
bytes at gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30's four
call sites (0x4c6e48/0x4c6ea4/0x4c6f18/0x4c6f5d) resolves the actual
pushed literals: "CAST %hs" @0x7b63a4 (untargeted/self-cast, and
targeted+compatible with " on %s" @0x7b6464 appended), "You must
select an appropriate target for %hs" @0x7b6348 (incompatible target),
"You must select a target for %hs" @0x7b63b8 (no target). %hs is the
spell's own name throughout.
Added RuntimeSpellCastState.EvaluateCastGate (SpellCastGate: NoTarget-
Needed/TargetCompatible/TargetIncompatible/NoTargetSelected/Unknown),
refactoring IsTargetReady to use it, and wired
SpellcastingUiController.ComputeSpellCastState to the four-state
tooltip text, replacing the bare-spell-name fallback.
F4: the endowment branch's "USE the %s" (and both select-target
strings) vararg is NOT the bare item name — retail composes
"%s (%hs)" @0x7b64d8 (item name, spell name) once at @0x004c6bb6-ef
and reuses it for all three format strings, byte-confirmed by all
three sprintf call sites (0x4c6c7f/0x4c6ca4/0x4c6d46) reading the
identical stack slot. Added ComposeEndowmentName and wired it in place
of the bare item name.
F7: added test coverage for the two genuinely NEW disabled states
(needs-target, needs-appropriate-target) neither branch had any
coverage for before, plus the enabled untargeted/targeted-compatible
states and both endowment-branch composed-name cases.
Corrected the register's TS-85 row (the "cannot be recovered" claim
and the endowment operand claim) with the byte-decoded findings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prior PlaceMarker() reading ("center at markerX0+x") was wrong.
Binary Ninja elides gmMapUI::PlaceMarkerOnMap @0x004a18b0's entire FPU
chain to bare, operand-less _ftol2() calls, so the pseudo-C
under-specifies the function. A capstone disassembly of the raw bytes
in the PDB-paired acclient.exe recovers the real formula: retail
projects the AC display coordinate (range ~-102.4..102.4) onto the
marker-area rect via a fixed-point-style transform, not a raw pixel
add:
X = m_x0 - w/2 - (int)((m_x1-m_x0+1) * (x*10+1024) * (-1/2048))
Y = m_y0 - h/2 - (int)((m_y1-m_y0+1) * (2047-(y*10+1024)) * (-1/2048))
Constants read directly from .rdata: 0x79bac8=10.0, 0x7aac78=1024.0,
0x7aac70=-1/2048, 0x7aac68=2047.0. The Y axis's FSUBR is retail's
north-up flip. w/h halve with truncating integer division (matching
retail's cdq;sub;sar idiom), not float division.
Extracted the pure math into MapPageController.ComputeMarkerPosition
so it's directly testable, and retargeted MapPageControllerTests to
GOLDEN PIXEL values computed independently from the formula (never
from the port's own output): the reviewer's canonical (0,0)->(122,128)
case, a far-west and far-north case, and a real town-table entry
(Arwic's landblock, cross-checked against RadarCoordinates). Applies
to the green ring, house pin, and all 53 static town hotspots, which
all resolve through the same PlaceMarker call.
Corrected the recon doc's "accepted as-is" note, which had mistaken
"the FPU argument-passing is BN-mangled" for a narrow issue instead of
the whole-formula elision it actually was.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Derived the mechanism from the decomp before writing code: neither
gmHouseUI::PostInit @0x004a2710 nor gmMapUI::PostInit @0x004a1c70 sends a
HouseQuery, and six of gmHouseUI's seven Display* builders early-return on
m_pHouseData == 0. The only text a houseless character's House tab shows is
gmHouseUI::DisplayPurchaseTimeText @0x004a3110's expired branch (it doesn't
gate on m_pHouseData) — the local player's PropertyInt.HousePurchaseTimestamp
plus HouseSystem::HasPurchaseWaitPeriodExpired renders exactly "You may buy
another house immediately." for a fresh character. Exhaustive search of the
2013 EoR decomp, ACE, and the live DAT found zero support for a second
"You do not currently own a house." line the task brief described — this
commit ports what the decomp actually shows.
Ships:
- RuntimeHouseState: a minimal (no disposal, no construction-transaction
Fault() point) Runtime owner per ISSUES #413's own sizing note, wired
through GameEventWiring's existing HouseData/HouseStatus delegate holes,
LiveSessionEventRouter, and GameRuntime.HouseOwner. Participates in
RuntimeGenerationReset (new House stage) since a fresh login must not
show a stale character's house state.
- HousePageController.Bindings.Lines/OnShown wired to real data; OnShown
fires WorldSession.SendHouseQuery() on tab-open (AD-107: an acdream
trigger, not a ported retail call site — filed in the divergence
register).
- Fixed a real bug found along the way: HousePageController.Bind never
wired UiTemplateListBox.TemplateResolver, so no row could ever render
regardless of Lines content. Now reuses the Map tab's generic hotspot
resolver.
Live-verified against a real local ACE server and the +Acdream character
(--session-config auto-select + a UI automation script): screenshot and
structural UI-tree dump both confirm the House tab renders exactly "You may
buy another house immediately." Graceful logout confirmed both launches.
ISSUES #413 narrowed to its one remaining piece: the six owned-house-only
Display* builders (DisplayBuyPayment/RentPayment/BuyTime/RentTimes/
Location/WarningText), unexercisable without a test character that owns a
house.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live verification (slice 5) found town-marker tooltips never appeared:
RetailTooltipPresenter.OnTooltipShow gates unconditionally on
AuthoredTooltipRootElementId == 0 -> return, with no fallback, but the
markers only set AuthoredTooltipText/Enabled (the DAT-authored P0x49
path). gmMapUI::AddMapNote's UIElement::SetTooltip call is retail's
RUNTIME m_TTText/SetTooltip mechanism, not the authored path — the
correct seam is UiButton.TooltipText (backing GetTooltipText()'s
override), which ResolveTooltipText consults before authored text.
The popup-skin locator (AuthoredTooltipRootElementId/LayoutDid) is
still required even on the runtime-text path with no built-in
fallback, so markers now hardcode the same shared popup skin
UiItemSlot already uses (0x10000395/0x21000041) — matching that
established precedent exactly.
Verified live: hovering a town marker (Aerlinthe Island) now renders
its tooltip correctly. 21/21 Map/House controller tests still pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mounts host 0x2100006E slot 0x1000018C (RetailPanelCatalog.MapHouse = 16)
as a two-tab UiTabPanel (Map default, House second) through the OP3/FA3
recipe (LayoutImporter.Build -> Bind -> ActivateTabBehavior). Toolbar
button 0x1000019A un-ghosts (added to both RetailPanelCatalog.Mounted and
.Toolbar). Combined into one commit because MapHousePanelController.Bind
depends on both MapPageController and HousePageController existing —
splitting them would mean landing dead code first.
Map tab (gmMapUI, MapPageController):
- Calendar formatter matching gmMapUI::Update's "Date: %s\nTime: %s"
shape, reusing WorldTimeService.CurrentCalendar (new
Func<DerethDateTime.Calendar> dependency threaded through
InteractionRetainedUiDependencies/GameWindow — a stable long-lived
service, not routed through the deferred-binding machinery Radar's
per-session state needs). MonthName enum values already match retail
display text; HourName's "AndHalf" suffix is rewritten to "-and-Half".
- Coordinate math + marker placement reuse RadarCoordinates/
LandDefs.GidToLcoord verbatim (both already byte-exact ports of
CPlayerSystem::InqPlayerCoords/LandDefs::gid_to_lcoord) — no re-port.
PlaceMarkerOnMap's centering math (m_x0 + x - w/2) ported from
gmMapUI::PlaceMarkerOnMap @0x004a18b0. Indoor gating clears the
coordinate text and hides the player marker, matching
gmMapUI::Update's else branch.
- 53-town s_rgLocations table ported verbatim into MapLocations.cs.
Markers built once at bind time via the panel's own RowTemplateResolver
against m_pMap's authored hotspot-template attrs (0x47/0x48), with
literal-string tooltips through AuthoredTooltipText/Enabled
(RetailTooltipPresenter) — closes divergence-register row TS-85's last
item, gmMapUI::AddMapNote @0x004A1C51.
- Structural finding: m_pMap (0x100001EC) is itself authored as a Type-1
BUTTON (the GM click-to-teleport hook at
gmMapUI::ListenToElementMessage), and the player/house icons
(0x100001ED/EE) are its own NESTED children, not siblings —
UiButton.ConsumesDatChildren swallows them from the normally-built
tree. Both are re-resolved standalone through the same template
resolver the town hotspots use and reattached under m_pMap.
House tab (gmHouseUI, HousePageController): mounts the ListBox
(0x100001E6) with its authored row template, wired to an empty Lines()
source by default — genuinely empty until Slice 4's wire lands, matching
retail's own PostInit (no Update call, no static content).
21 new tests (7 MapHousePanelControllerTests, 14 MapPageControllerTests):
tab table pairing, close button, town-hotspot count/tooltips, calendar
formatter golden values (Frostfell 27/119 P.Y., every HourName incl.
AndHalf), player/house marker placement and indoor-gating reproduced
against the real fixture via already-tested RadarCoordinates (no
re-derivation). Fixture map_house_2100006E_1000018C.json captured via
the shared RetailLayoutFixtureGenerator (other 34 fixtures deliberately
NOT regenerated — out of scope for this batch, would touch unrelated
panels' schema drift).
Full solution builds clean; App suite 5391/0 failed/71 skipped (non-live;
one earlier flaky streaming failure unrelated to this change, confirmed
pre-existing on the branch before these commits).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-DAT probe confirming the desk-verified facts before implementation:
host 0x2100006E slot 0x1000018C carries panelId 16 (gmMapUI::PostInit
signature children 0x100001EB-EF; gmHouseUI's ListBox 0x100001E6),
tabTableCount=2 with Map (button 0x100001F3 -> page 0x100001F6) as the
authored default and House (0x100001F4 -> 0x100001F7) second, close button
0x100001F5. Toolbar button 0x1000019A carries the matching panelId 16 —
the Map/House entry among the toolbar's three ghosted buttons. m_pMap's
own marker-area rect is (6,8)-(247,258); its hotspot template attrs
(0x47/0x48) resolve to element 0x100001F0 in LayoutDesc 0x21000026, a
10x10 Type-1 button with 3 states. The House ListBox authors exactly one
row template (LayoutDesc 0x21000025 element 0x100001E7, a bare
UIElement_Text row, no scrollbar) and zero static child rows — the box is
genuinely empty until the first server notice, refuting the recon's
"authored default content" hypothesis for the no-house case.
Kept as a permanent env-gated pin (ACDREAM_PROBE_LIVE_MOUNT=1), matching
FaPanelSlotProbeTests' precedent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TS-85 remainder batch (hover/UI overnight round, batch B). Audit found
three of the four listed spellcasting SetTooltip sites (endowment icon,
favorite, submenu) were already correct via UiCatalogSlot's pre-existing
Label-driven GetTooltipText; only the cast button (UiButton, no tooltip
wiring at all) was a real gap. Ports the verified literal states
("Select a spell to cast" / "You have no spells ready to cast" / the
full endowment-item USE-the-%s branch) plus a documented, narrower
fallback (spell name only) for the one sub-branch whose exact wording
sits behind a genuine gmNoticeHandler vtable-slot collision in the
pseudo-C dump rather than the unlabeled-string-pool class the rest of
this batch recovered.
Character panel: new UiClickablePanel.TooltipText seam (same pattern as
UiButton.TooltipText) carries the six hardcoded attribute descriptions
and three pair-shared vitals descriptions (byte-decoded from the retail
string pool) plus skill tooltips composed from the already-DAT-parsed
SkillBase.Description/.Formula — no hand-transcription needed for the
~30+ skill strings. The formula-to-text algorithm itself
(SkillSystem::InqSkillFormula) was recovered by byte-decoding six short
fragments Binary Ninja left completely unlabeled between two
gmSpellcastingUI vtable declarations.
Live-verified against the local ACE server: 34 real skills' composed
tooltips and both reachable cast-button states captured via a temporary
probe (stripped before this commit). Full solution suite green
(14,647 tests, 0 failures) both before and after the probe strip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Config tab's footer sat mid-panel with further rows drawing below the
window's bottom edge. Live-DAT measured: the mounted tab-host root is
authored 300x362 (retail's real default window size), but the Config page
slot underneath keeps its own larger design geometry (298x575 against a
300x600 canvas) until retail's real four-edge UiLayoutPolicy
(UIElement::UpdateForParentSizeChange @0x00462640) shrinks it on the first
ApplyAnchor pass -- verified stable, this part already worked.
The actual bug: UiTemplateListBox.Viewport (the UiScrollablePanel that
hosts + clips every row) is a programmatic C# element seeded at Bind time,
BEFORE the tree's first draw frame -- before the ListBox has ever shrunk.
Its legacy anchor baseline is captured lazily on its own first ApplyAnchor
call, which lands AFTER the ListBox has already shrunk earlier in that same
frame (parent-before-child draw order). That capture measures a negative
bottom margin the stretch math preserves forever: the viewport stayed
locked at its original 560px design height, clipping rows to a bound
retail never actually gave the window on screen.
Fix: force the viewport's anchor capture to happen immediately after
seeding it, while its Width/Height still exactly equal a zero-margin
baseline against the CURRENT (pre-shrink) parent, instead of lazily on the
first draw frame against an already-shrunk parent. This is #372's sequel --
#372 fixed the 0x0 collapse case; this is the "ListBox itself later
shrinks" case #372's own fixture never exercised.
Three new tests (UiTemplateListBoxViewportTests using the live-DAT-measured
298x575/276x560 numbers, plus two ConfigOptionsPageControllerTests against
the real production Bind path and the committed host fixture) all fail
pre-fix, confirmed by temporarily reverting the change. Scoped to
UiTemplateListBox's own viewport; UiScrollablePanel/ApplyAnchor/
ComputeAnchoredRect are untouched, so chat's transcript scrolling and every
other UiScrollablePanel/UiItemList consumer are unaffected.
fix#412
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>