Adds the independent Campaign AR review (docs/research/2026-08-22-campaign-
ar-review.md): architecture, technical correctness, performance, coverage.
Findings F1-F8; F1 (default-path invariance proven only on a 2x2 recording-
device oracle) blocks merge; F2 (10 m/50 m detail fade has no retail anchor);
F3 (the port is retail's two-pass fallback; single-pass stage path
undetermined); F4 (post stack runs in gamma space); F5 (four overclaiming
sentences, one premise error the reviewer owns).
Campaign VM plans the closure: VM0 masked pixel + production perf A/B vs
6c79d35c; VM1 removes the fade (retail attenuates by mips); VM2 one cdb read
of m_caps.bCanDoSinglePassDetailing; VM3 linear-light post stack with a
numerically neutral preset; VM4 truthful docs; VM5 volumetric jitter; VM6
weather-driven foliage wind - procedural-scenery (bit-31 id namespace) cutout
subsets only, three motions (lean/branch/flutter) with gusts and per-tree
phase, shadows share the displacement include; VM7 closeout + merge.
Roadmap gains the Campaign VM pointer beside Campaign AR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Imported from the codex worktree's uncommitted work
(C:/Users/erikn/.codex/worktrees/bd98/acdream on
codex/atmospheric-rendering-campaign), on top of its 8b7b601b.
The remote-player shadow gate ran two clients on fixed sleeps, so the
observer could move before the primary had taken its 'before' screenshot,
or the primary could take its 'after' shot before the observer had moved.
Now the harness publishes named signal files into each client's artifact
directory and the routes block on them:
- IRetailUiAutomationRuntime.TryIsAutomationSignalPublished, implemented
by WorldLifecycleAutomationController over <artifactDir>/signals/
<name>.signal (names validated by AutomationArtifactName, so no path
escape).
- 'wait signal <name> [timeoutMs]' in RetailUiAutomationScriptRunner.
- run-connected-render-pack-remote-player-gate.ps1 publishes
'primary-before' to the observer after the primary's before-shot
completes, then 'observer-moved' to the primary after the observer's
remote-observer-moved checkpoint.
- Both routes teleport with an explicit heading and wait 12 s to settle.
Build green; the three touched App test classes pass 58/58 including the
two new signal tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The roadmap line read "122 EmoteType x 39 Trigger mini-VM", which is the
SERVER's job — the retail client stores no quest flag, evaluates no emote, and
is never told a flag changed. Reading that line as client work is what made
H.3 look far larger than it was; measuring what we already had narrowed the
remaining scope to the contract tracker alone.
That, plus the Journal notebook and its index, shipped user-accepted today. The
two stale "what you cannot do yet" rows are corrected: the quest tracker exists,
and NPC dialogue works — the user confirmed it mid-campaign, which is what
prompted measuring the scope in the first place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Journal panel is complete: contracts from the server, a per-character
notebook, and its searchable index.
The gate ledger is recorded rather than smoothed over, because the pattern is
the point — four rounds, and every defect was one mistake wearing different
clothes: an element bound as the wrong thing, or a binding never tested. One
reported failure was not a defect at all; the character was indoors, where
retail records nothing either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "1d 2h 51sh m" — the running countdown drawing straight through
the d/h/m labels. The readout is authored across the same strip as the three
number boxes, so the strip has to be one thing or the other; I hid the boxes
and left their labels behind.
Retail's ShowEditableTimer @0x00495770 toggles SIX elements, not three:
m_pDaysEditBox AND m_pDaysStaticText, and the same for hours and minutes, plus
the readout inverse. Reading the swap as "hide the inputs" instead of "hide the
input ROWS" is what produced the overlap.
Also settles the Record question the same round raised. Nothing was broken:
indoors, retail's own gid_to_lcoord fails and nothing is recorded, and
UpdateLocation @0x004958F0 only ever formats coordinates already stored — there
is no "you are indoors" message in that function to port. The silence is
faithful, and it is now commented as such rather than left looking like a gap.
JournalPanelLiveBindTests is new and is the test that should have existed
first: it builds the panel from the real DATs, constructs the controllers, and
asserts every button actually receives an OnClick. Every other test so far
checked either the layout or the logic — none of them proved the controller
finds its elements in the real tree, which is where an id typo or a subtree
assumption produces a panel where nothing responds and nothing fails.
The temporary ACDREAM_PROBE_JOURNAL instrumentation is removed; the question it
was added for is answered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes from the first connected round.
The location readout is authored EDITABLE (0x16), so it builds as a UiField —
not the UiText its "00.0S, 00.0W" placeholder suggests. The controller resolved
it as text, got null, and threw every write away in silence: Record reached the
model and reached the FILE, and never reached the screen. That is exactly what
was reported, and it is a whole class of bug, so the sweep that found it is now
a test over every element all three controllers bind.
The handlers mutated the model and left redrawing to the next frame's Tick.
Retail's ListenToElementMessage @0x004968D0 ends every one of them in Update()
instead — at the moment of the click. The deferred version happened to work in
the client and made the behaviour untestable and a frame late; the notes-page
tests I had not written until now fail against it.
Abandon is wired. "Retail's abandon path is a contract-registry command we have
not ported" was wrong — it is game action 0x0316 with a single contract id, and
ACE replies with the 0x0315 delete QT3 already handles. Nothing is removed
locally, so a refusal leaves the quest visibly intact rather than vanishing it
optimistically and having it reappear on the next full table.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Built from the REAL authored layout, because that is the only place property
0x0D exists — the hermetic UiButton tests construct their own ElementInfo and
so could not have caught this class of bug at all.
Asserts the two things the player actually experiences, separately: the button
builds enabled, and a click at its centre reaches IT rather than falling
through. Enabled alone would not have been enough to call the fix proven.
Verified to have teeth rather than assumed: restoring the previous UiButton
and re-running fails both tests, naming all ten buttons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported symptom: Abandon, New, Record, Start, First and Last all unclickable.
That Abandon was in the list is what identified it — Abandon is deliberately
unwired, so if it behaved the same as the others the cause could not be wiring.
UiButton read authored property 0x0D as "starts disabled" (Enabled = !0x0D).
It was the one property read in that file with no citation, and it was wrong.
Every button on the Journal panel authors 0x0D, so every one built disabled:
visible, because drawing never consults Enabled, and unclickable, because
UiElement.HitTest skips disabled elements. Exactly the reported shape.
The evidence is a sweep of every installed layout (LayoutDump gained --ghosted
for it): 85 elements author 0x0D and ALL 85 author it TRUE — not one False
anywhere in the client — and no panel ever clears it, the only four
SetAttribute_Bool(.., 0xd, ..) sites in the binary being chargen appearance,
the keymap option and the barber. A flag that is only ever true, never cleared,
and sits on New, Record, Start, Delete and Reset cannot mean "dead button";
under the old reading 85 elements were permanently dead in a shipping game.
It is not a pure ghosted LOOK either, which is why this ignores it rather than
moving it to appearance: the same 85 mix live buttons with inert column headers
("Contract", "Status", "Title", "Timer", "Label", "#"), and one appearance
cannot be right for both. Registered as QJ-2 with the measurement, so the open
question is recorded rather than quietly decided.
The test that asserted the old behaviour carried no citation either — it
encoded the same assumption. It now asserts the evidenced behaviour, with a
companion test proving the state machine's own Ghosted transition still
suppresses a click: that mechanism is separate and did not change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The Journal tab is not a quest feature: it is a per-character notebook with no
wire, no server and no dat content. The player writes it, and it persists to a
tagged text file recovered whole from LoadPages/SavePages.
Retail refuses a journal file that does not OPEN with <NEWP>, with its own
message. That strictness is ported rather than softened — accepting such a file
would scatter the first page's text into no page at all. An ABSENT or empty
file is the opposite case and must not error: that is simply a character who
has never written a page.
Three things the format does not say out loud, each with a test:
<PNUM> is written but page order IS file order, so a reader that trusted the
number would reshuffle a hand-edited file. A recorded location of (0, 0) is a
real place, so the location tags are written on a HasLocation flag rather than
on the numbers being non-zero. And the notes box is multi-line while the file
is line-oriented — an embedded newline would read back as a tagless line and
silently truncate the notes, so they are folded to spaces at the write.
The countdown belongs to the page it was started on, and what belongs in the
file is what is LEFT rather than what it started at — saving the start value
would resurrect the full duration on every reload.
Deleting the last remaining page empties the journal instead of leaving a blank
one behind; inventing a replacement would make the journal impossible to empty.
An out-of-range page is refused rather than clamped, because clamping moves the
player somewhere they did not ask to go.
Campaign QJ slices 1 and 2 of 5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Journal tab is not a quest feature at all: it is a per-character notebook,
entirely client-side, with no wire and no dat content. Each page holds a label,
a title, notes, a recorded location and a countdown timer, and the Page List
tab is a searchable index over them. It shares the panel with Contracts and
nothing else — retail's own naming, not a mix-up here.
The file format is recovered whole, including the detail that a journal file
which does not open with <NEWP> is REFUSED with its own error string, and the
authored max lengths (16 label / 32 title / 2048 notes) that the edit boxes
enforce.
One authored fact worth recording before anyone reads the layout as a bug: the
running-timer readout shares x=84 with the three day/hour/minute fields. That
overlap is the data form of ShowEditableTimer versus ShowRunningTimer — the
strip is either three editable numbers or one running readout, never both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last piece of QT6's own scope: r10-quest-dialogs.md §11.6's contract half.
IGameState.Contracts exposes what the client structurally knows about quests,
which — per that same research — is the tracker and nothing else. The rest of
§11.6 (chat stream, tells, give, use, confirmations) is other features and
stays out of this campaign.
A pull-through source rather than a pushed mirror. Contracts change rarely and
are already owned canonically, so a second copy would only be a thing to keep
in step; reading through means a plugin cannot observe a stale list.
Both hosts implement it. The headless one carries contract id, stage and
progress but no names — a bot has no dat access — because losing the TEXT is
expected while losing the QUEST would leave a bot silently unable to see what
it is on. Same rule covers a contract the installed dat has never heard of: it
still projects, with empty text and a correct status, rather than vanishing.
The interface member is defaulted so a host predating this campaign still
satisfies IGameState.
Two lazy catalog loads exist (the panel's and this one) rather than one shared
instance. That is deliberate: threading a shared ContractCatalog through three
composition records to avoid reading a 322-row immutable table at most twice
per session would be plumbing for no correctness or performance gain, and the
comment at the call site says so.
Campaign QT is complete; the connected user gate is 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>
Measured, not theorised. Records for the next session:
- #226 detail overlay is NOT implemented at 255b0aae, and the
BuildingDetailTextures Options checkbox is wired to NOTHING (settings +
UI exist; no consumer in Rendering/). The port must consume that
existing setting.
- Retail authors detail per terrain entry; Dereth has 33 entries but only
3 distinct detail textures, one 64x64 backing 88% of them.
- The detail pass is a framebuffer blend, not DOT3: environment uses
DSTCOLOR+INVSRCALPHA under BLENDOP_ADD, landscape uses SRCALPHA. Decoded
texture means give factors 1.03-1.20, so retail's own pass BRIGHTENS
rather than roughens - a real design decision for the port.
- EnvironmentDetailTextures gates buildings+interiors, NOT landscape
(ChangeRegion passes landscape=0).
- Refuted and recorded so nobody re-chases: highres is NOT an override dat
(20,684/2,294 ids, zero overlap - we already use it); the detail
textures are NOT normal maps (24%/8%/15% unit-length); but the engine
DOES have BumpMap/DotProduct3 machinery.
- Terrain normals are flat per-face with no averaging - whether retail
smooths is the open parity question.
- Atmospheric rendering want captured: opt-in shader packs, tier table,
and the shadow constraints (alpha-tested casters, animated casters via
the N.5 SSBO, indoor gating, streaming-bounded cascades, the #129 bias
trap, and the shadow_objects naming trap).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measuring the host layout rather than assuming changed what this slice is.
gmContractsUI is not a panel of its own: it is tab 1 of a THREE-tab "Journal"
panel at gmPanelUI slot 25, beside a notes page and a page list. Building it as
a standalone window would have produced something retail does not have, and
the mistake would only have surfaced at a visual gate.
The other two tabs are out of scope, so the expected intermediate state is a
panel with two dead tabs — recorded here so it is not filed as a defect.
Everything else the page needs is now measured out of the dats: every authored
label, the per-row child ids RefreshContractListbox writes, and the list's
scrollbar link. The list is a UiTemplateListBox, the widget OP2 already built,
so the page is binding work rather than new widget work.
LayoutDump --props now resolves StringInfo through DatStringResolver instead of
printing the type name, which is how the labels were read at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wire carries an id, a stage and two timers. Every word the player reads
lives in portal.dat's ContractTable, which nothing in the tree had ever
opened — the only reference counted its entries in a CLI diagnostic. Chorizite
does decode it (322 contracts installed), which was a real question given it
declares TabooTable without decoding it.
FillProgressString @0x00498DE0 is the one real algorithm in this panel, and it
is now ported whole. Its x87 compares are the usual fcom/sahf pattern, so the
(status & 0x41) tests decode as "<= 0" rather than "< 0" — the difference
between a cooldown that expires and one that never does.
Three readings recorded as tests because each looks like a mistake:
TimeWhenDone is on the wire and is never read; an EMPTY QuestflagRepeatTime is
the entire difference between "Done" and "Available"; and DescriptionProgress
is a printf format taking stage-4, not a literal — rendering it verbatim shows
the player "%d/20 Tuskers".
DeltaTimeToString @0x00565E10 emits every part with a trailing space and then
overwrites the last one. That truncation is invisible in the decompiler output
(the instruction reads as pointer noise), so it was settled by decoding the
bytes: mov byte ptr [esp+eax+0x1b], cl with cl == 0 and eax == strlen writes
the terminator over buffer[len-1]. Guessing either way was a coin flip that
decides whether every repeat timer reads "Done (1h 30s to Repeat)".
The single-%d substitution is a MEASUREMENT, not a convenience: 89 of the 322
installed contracts author a progress format and every one uses exactly one
specifier. An installed-DAT test asserts that, so a future dat that ships two
fails there rather than silently rendering a raw specifier.
LayoutDump gained --contracts, which is how all of the above was measured.
Campaign QT slices 2 and 4 of 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fourth sibling J-owner, built to the shape the other three established. It
borrows nothing, because the retail client stores no quest state of its own —
everything here is a projection of what the server pushed.
Clearing at generation reset is safe for the same reason: a fresh session opens
with a full 0x0314 replacement, so the reset cannot lose anything the next
login will not immediately restate, while NOT clearing would show a previous
character's quests.
Three readings of the wire that would each lose contracts silently, one test
apiece: a 0x0314 REPLACES rather than merges (merging resurrects contracts the
server dropped); an empty 0x0314 clears rather than being ignored (it is how
the server says "you have none", and ignoring it strands the last quest on
screen); and a delete carries a full tracker struct, so it looks exactly like
an add apart from one flag.
Adding a teardown stage exposed a genuine trap: TeardownStageCount bounds the
drain loop while GameRuntimeTeardownStage.Complete defines what the ledger
demands, and nothing tied them together. Leave the constant behind and the new
owner is never disposed at all, while the ledger goes on waiting for its flag —
the runtime hangs in teardown rather than failing anywhere near the edit. The
stage-ledger test now reads the constant by reflection and asserts it against
the flag list, so the next owner fails at the edit instead.
Campaign QT slice 3 of 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both opcodes have been named in GameEventType since the wire-catalog work with
nothing behind them, so every contract the server has ever sent us arrived and
was discarded.
Three details that a reimplementation from the enum alone would get wrong, and
each has a test:
The two trailing flags on 0x0315 are widened bools, not bytes, and they sit
OUTSIDE the struct writer — ACE's ContractTracker.Write has them commented out
precisely because the event appends them itself. Reading them as bytes decodes
the delete flag from the wrong four bytes and silently drops contracts.
The stage is not a dense enum. Retail encodes N completed steps as
ProgressCounter + N, so a switch over the four named values sees stage 9 as
unknown and shows nothing. Progress/HasProgressCounter do that arithmetic once
here rather than leaving every caller to remember it.
The countdown anchor is not on the wire. FillProgressString @0x00498DE0 counts
down from CContractTracker::_time_of_server_update, which the server never
sends — so arrival has to be stamped at parse time or the repeat timer has
nothing to tick against.
An empty table is a valid answer rather than a decode failure: it is how the
server says "you have no contracts", and confusing the two would leave stale
quests on screen permanently. A truncated one is rejected outright instead of
decoding to its prefix, which would drop quests just as silently.
Campaign QT slice 1 of 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
H.3's roadmap line ("122 EmoteType x 39 Trigger mini-VM") describes the
SERVER's job. The retail client never stores a quest flag, never evaluates an
emote, and is never told a flag changed — so most of H.3 was never client work
at all. Measuring what we already have narrows the remaining scope to one
thing: the contract tracker, the only structured view of quest state a client
ever gets. The user confirmed NPC dialogue works live.
LayoutDump could only dump a layout you already knew the id of, but the decomp
hands you a CLASS id with no layout attached (UIElement::RegisterElementClass),
so the gap between the two was crossed by guessing. --find closes it, and it
searches the element's TYPE as well as its id because registration keys on
Type — searching only the id finds a real element with the same number and
quietly answers the wrong question, which is exactly what it did on the first
run here.
The plan records the wire layout, the panel's authored children, and
FillProgressString in full, including the three things a reimplementation
would get wrong: TimeWhenDone is never read, the countdown anchor is not on
the wire, and DescriptionProgress is a printf format rather than a string.
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>
Two dump modes, both added because a chat bug turned on authored data rather
than code.
--props prints each state's property ids WITH VALUES. It is what settled the
unseen-text indicator regression: element 0x1000048C authors dat property 0x3B
("Invisible") per state —
state 13 Ghosted 0x3B = True -> hidden
state 1 Normal 0x3B = False -> shown
— which is what actually puts that element on screen. No amount of reading the
decomp would have produced those two booleans.
--media prints the RAW media sequence per state off the LayoutDesc, because
ElementInfo keeps only the FIRST image of a state (LayoutImporter.cs, the
`!imageRead` guard) and an animation is therefore invisible above that level.
It reports what it CANNOT do rather than implying an element has no media: raw
descriptors only carry what an element overrides, and states usually come from
a base element — for the indicator, base 0x10000527, which lives in a different
layout entirely. Following that chain means reimplementing LayoutImporter's
Resolve, so the mode says so instead of printing a misleading empty list.
What the two modes established between them, for whoever picks up the blink:
the media vocabulary includes MediaDescImage, MediaDescPause and MediaDescJump,
so an authored animation is a SEQUENCE with waits and a loop — a small program,
not a frame array. Supporting it means keeping the sequence through import and
running a media player, not just widening StateMedia to a list.
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>
Campaign CT Group D, closing the campaign.
D1. The ImGui-era ChatPanel has not been constructed anywhere in src/ since
Campaign V deleted AcDream.UI.ImGui. Verified that directly rather than on the
audit's word, then removed it with its three panel-only test files. Those tests
passed, which is exactly the problem: they made the real input surface look
better covered than it is.
ChatVMCombatTests was KEPT — three of its four tests are genuine ChatVM
coverage and only one exercised ChatPanel, so just that method went. Deleting
the file would have quietly dropped real coverage along with the dead kind.
Three doc comments referencing the deleted type were rewritten rather than left
as dangling crefs.
D2. docs/ISSUES.md turned out to be ACCURATE already — #358 and #363 are
recorded CLOSED there, contrary to the audit's summary. What was stale was the
chat DIGEST's "Open" section, which still named four closed issues and claimed
Campaign CH's connected gate was owed. Corrected against ISSUES: genuinely open
are #359, #360, #361 and #366.
The digest also gained a Campaign CT section (the tag mechanism, the MEASURED
tag colour, and what shipped) and three DO-NOT-RETRY rows earned this session:
- Do not model authored state media with one image per state — the unseen
indicator's Normal state carries SIX frames and that IS retail's blink.
- Do not read an element's role from a Binary Ninja field NAME — the names in
ChatInterface's binder are shifted badly enough to assign a UIElement* into
a float field.
- Do not assume our side has a gap because retail has a mechanism. That cost
this campaign twice in one session: the transcript was claimed unbounded
when ChatLog has always capped at 500 entries, and C1's auto-scroll was
planned as a port when UiScrollable already did it.
CT-C4 is deferred and marked so: pure test coverage over behaviour the audit
confirmed already works, changing nothing a user can see.
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>
User report: "ESC is hardwired to Freefly which it should not be ... the
freefly should really be discarded. Should not be in the client."
Two separate things were true.
Escape ran a priority chain — cancel target mode, else EXIT FLY MODE, else
leave player mode, else close a window — so in a session that had reached the
free-fly camera, Escape spent itself on that rung instead of doing what the
player expected. The rung is gone; a session somehow in fly mode now falls
through to the next one.
And free-fly was still bound: Ctrl+Shift+F in RetailDefaults (the table
production actually loads) and plain F in AcdreamCurrentDefaults (dead since
K.1c, removed anyway so it cannot be revived by accident). The comment on the
live binding advertised two other ways in — the ImGui View menu and the Debug
panel's "Toggle Free-Fly Mode" button — but BOTH went away with
AcDream.UI.ImGui at Campaign V, so the shortcut was the last route in. It is
now unbound, and a test pins that across both default tables.
This makes free-fly unreachable rather than deleted. The implementation still
spans 25 files (CameraController, FlyCamera, the dispatcher capture, pointer
controller, composition, and a streaming observer source), and ripping that out
at the end of a long session is how a regression lands in the camera. Scoped as
its own follow-up; unbinding is what fixes the reported behaviour today.
The Escape priority test was updated rather than deleted: its middle row now
asserts the fall-through, so the removed rung is documented by a passing test
instead of by its absence.
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>
"I do not want any censoring." Recording it as a KNOWING divergence (register
row CT-2) rather than leaving it as an implicit gap, because retail does filter
and someone reading the code later would otherwise read our silence as an
oversight.
The option itself stays: FilterLanguage still stores and ships its bit to the
server exactly as retail does, so anything the SERVER gates on it behaves
normally. What acdream does not do is substitute words in the transcript.
The attempt before the decision is kept in the plan, since it establishes two
things that would otherwise be rediscovered:
- The taboo table's dat id is not readable from the decomp —
TabooTableAdaptor::CheckCensorsW @0x00682A30 reaches it through
DBObj::GetByEnum with the arguments elided by Binary Ninja. Measured the
portal master enum map (0x25000000) instead: 22 categories, with category 3
(0x0E010001 / 0x0E010002) and single-entry categories 8 and 11 as the
plausible candidates.
- Chorizite.DatReaderWriter declares a TabooTable DBObj type but does not
decode it: only DBObjType and HeaderFlags. The format would need decoding
here first.
Also sharpened CT-B4's status to research-blocked — the chat log file's path
and rotation are not in the decomp either.
Group B therefore ships B1 and B2 and is complete as scoped.
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 A3. Still nothing visible; this removes the blocker.
ChatEntry carried Sender and SenderGuid the whole way through ChatLog, and
RecentLinesDetailed dropped both one step before the transcript. That is why a
speaker's name could never be coloured or clicked apart from the sentence
around it — by the time anything could draw it, there was no name left, only
prose.
FormattedLine now carries a Spans sidecar: the line split into stretches, one
of which may hold a retail text tag. Text remains the VISIBLE line with markup
consumed, so wrapping, selection, hit-testing and the caret are untouched, and
Spans is null for the ordinary single-colour line, which is most of them.
FormatEntryTagged shares its format strings with FormatEntry through a sender
decorator rather than duplicating retail's wording. Two copies would be two
things to keep in step, and these two renderings MUST show the same characters:
the transcript selects against the flat text, so any drift would mean clicking
one character and selecting another.
Tagging is gated on AC1's player id range (0x50000001..0x6FFFFFFF), read off
the guard in Handle_Communication__HearSpeech @0x005712A0 — so monsters and
NPCs never become clickable, which a "has a name" test would get wrong.
One real bug came out of a defensive test rather than a report. A sender name
containing '<' re-parsed as a marker and SWALLOWED characters from the visible
line ("Od<d says" rendered as "Od says"). The markup has no escape mechanism,
and retail's has none either because AC name validation makes the case
unreachable there — but sender names are server data. Such a name is now simply
not tagged, so the line renders plain like any other untaggable sender instead
of rendering corrupted.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>