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>
9.2 KiB
Campaign QT — the contract tracker (H.3's client half)
Status: CODE-COMPLETE 2026-08-21. All six slices landed; the connected user gate is owed.
Why now. M4's demo scenario is "talk to an NPC, accept a quest, ... complete
the quest." Everything in that sentence works today EXCEPT the player's ability
to see what they have accepted. NPC dialogue, emote text, soul emotes, tells and
the quest-failure strings all render; Campaign CT (closed 2026-08-21) added the
<Tell:IIDString:…> markup those dialog lines carry. What is missing is the
only STRUCTURED view of quest state a retail client ever gets.
What H.3 is not. The roadmap line reads "122 EmoteType × 39 Trigger
mini-VM", which describes the SERVER's job. Per r10-quest-dialogs.md §1.3 the
retail client never stores a quest flag, never evaluates an emote, and is never
told a flag changed. It learns about quests three ways: dialog strings the
server already formatted, generic error toasts, and the contract tracker. Two of
the three ship. So H.3's remaining client scope is this campaign, and the emote
VM is explicitly out of it.
Measured ground truth
The panel
LayoutDump --find 0x1000004B (the UIElement::RegisterElementClass id from
gmContractsUI::Register @0x00499C80 — registration keys on the element's
Type, not its id) finds the class in six layouts. 0x21000069 holds it as a
standalone 300x500 root (0x100005CD); the rest embed it at 300x575 inside
window chrome.
Authored children of 0x100005CD:
| Element | Type | Rect | Reading |
|---|---|---|---|
0x100005CE |
1 | 8,8 80x18 | header button |
0x100005D6 |
1 | 160,8 80x18 | header button |
0x100005CF |
5 | 8,30 270x298 | the contract list |
0x100005D0 |
11 | 278,30 16x298 | its scrollbar |
0x100005D8/0x100005DF |
12 | y=332 | label / value |
0x100005D9/0x100005E0 |
12 | y=352 | label / value |
0x100005DA/0x100005E1 |
12 | y=372 | label / value |
0x100005DB/0x100005E2 |
12 | y=392 | label / value |
0x100005DE |
12 | 8,418 270x52 | description block |
0x100005DD, 0x100005E3, 0x100005DC |
12/12/1 | y=468 | button row |
The wire
Both opcodes are already NAMED in GameEventType.cs and nothing parses them —
the bytes arrive and are dropped.
0x0315 SendClientContractTracker — one tracker plus two flags:
uint32 Version
uint32 ContractId
uint32 Stage
double TimeWhenDone
double TimeWhenRepeats
uint32 DeleteContract (bool widened)
uint32 SetAsDisplayContract (bool widened)
0x0314 SendClientContractTrackerTable — a full replacement, as a packable
hash table (PackableHashTable<unsigned long, CContractTracker> in the decomp
at 0x00497C10): the familiar u16 count / u16 numBuckets header, then
u32 key + the 28-byte tracker per entry. NO trailing flags on this path.
Source: ContractTrackerExtensions.Write, GameEventSendClientContractTracker,
ContractManager.Write in ACE; cross-checked against the retail decomp's own
PackableHashTable<unsigned long,CContractTracker> instantiations.
ContractStage: 1 Available, 2 InProgress, 3 DoneOrPendingRepeat,
4 + n ProgressCounter with n steps done.
gmContractsUI::FillProgressString @0x00498DE0 — the one real algorithm
Recovered whole. The x87 compares are the standard fcom + sahf pattern;
(status & 0x41) != 0 tests C0|C3, i.e. <= 0.
stage 1 -> "Available"
stage 2 -> "In Progress"
stage 3:
if TimeWhenRepeats <= 0
-> QuestflagRepeatTime empty ? "Done" : "Available"
remaining = TimeWhenRepeats - (now - timeOfServerUpdate)
if remaining <= 0 -> "Available"
else -> "Done (" + DeltaTimeToString(remaining) + " to Repeat)"
stage >= 4:
if DescriptionProgress empty -> "In Progress"
else -> sprintf(DescriptionProgress, stage - 4)
Three things a reimplementation would get wrong:
TimeWhenDoneis never read. OnlyTimeWhenRepeatsdrives the text.timeOfServerUpdateis not on the wire. The client stamps arrival and counts down from its own clock, so the countdown has to be anchored at parse time, not recomputed from the server value each frame.DescriptionProgressis a printf format taking one integer,stage - 4. It is not a literal string.
It is not a "contract panel" — it is tab 1 of the JOURNAL panel
Measured from the installed dats. Host 0x2100006E, gmPanelUI slot
0x10000559, whose own authored 0x10000029 is 0x19 = 25 — the same
slot-key recipe RetailPanelCatalog already uses for Options (10), the social
panel (12) and Map/House (16). Three tabs:
| Tab | Caption | Page | Page type |
|---|---|---|---|
0x100005D3 |
Contracts | 0x100005D4 |
0x1000004B = gmContractsUI |
0x10000560 |
Journal | 0x10000563 |
0x10000048 — notes: "Title:", "Notes:", "First" |
0x10000561 |
Page List | — | — |
0x10000562 (type 1, at 276,0) is the panel's own corner button.
Only the Contracts tab is in scope. The Journal notes page and Page List are their own feature and are NOT part of Campaign QT — mounting the panel with two dead tabs is the expected intermediate state, not a defect.
The contracts page, resolved
Authored text read out of the dats (LayoutDump --props, which now resolves
StringInfo rather than printing the type name):
| Element | Role |
|---|---|
0x100005CE / 0x100005D6 |
list column headers — "Contract" / "Status" |
0x100005CF (type 5) |
the list, scrollbar 0x100005D0 via property 0x72 |
0x100005D1 / 0x100005D2 |
per-ROW children: contract name / progress text |
0x100005D8 → 0x100005DF |
"Status:" → value |
0x100005D9 → 0x100005E0 |
"Contact:" → value |
0x100005DA → 0x100005E1 |
"Contact Location:" → value |
0x100005DB → 0x100005E2 |
"Quest Location:" → value |
0x100005DE |
description block (270x52, wrapping) |
0x100005DD → 0x100005E3 |
"Timed:" → value |
0x100005DC |
"Abandon" button |
gmContractsUI::RefreshContractListbox @0x00499830 walks the tracker list and,
per row, sets 0x100005D1 from the contract's name and 0x100005D2 from
FillProgressString, caching the result back into the row. The list is a
UiTemplateListBox here — the same widget OP2 built for the Options panel — so
the page is binding rather than new widget work.
Slices
- QT1 — wire. Typed records + parsers for
0x0314/0x0315, arrival stamp included. Pure; no UI, no state ownership. - QT2 — dat. Read
ContractTable/Contract(name, description, progress description, NPC names, the three positions). Nothing reads it today; the only reference in the tree counts them in a CLI diagnostic. - QT3 — state.
RuntimeContractStateas a session-scoped J4-style owner: full replace, single add/update, delete, and the display-contract selection. Clears at generation reset. - QT4 — the progress string. Port
FillProgressString+ the retailDeltaTimeToStringit calls. Table-driven tests over every stage arm. - QT5 — the panel. Register slot 25 in
RetailPanelCatalog, mount the Journal panel by the OP3/FA recipe, and bind the Contracts page: rows fromIRuntimeContractViewxContractCatalog, progress from QT4, selection driving the detail pane. The other two tabs mount empty. - QT6 — open/close. The open path (no toolbar button authors slot 25, so
it is keyboard or menu — to be measured the way FA's F3/F4 was), plus the
plugin-visible read surface from
r10-quest-dialogs.md§11.6.
Landed
QT1 ab3934e2 (wire), QT3 f629ce7f (state + routing), QT2/QT4 ef6b7310
(catalog + progress string), QT5/QT6 (the panel and its open path).
The open path needed no new keybind. Toolbar button 0x1000055A authors
0x10000029 = 0x19 and has been in ToolbarController.PanelButtonIds since
the toolbar was ported — it simply had no panel registered behind it, so
clicking it did nothing. Registering slot 25 completed a wiring that was
already three-quarters present.
The plugin surface (r10-quest-dialogs.md §11.6's contract half) ships as
IGameState.Contracts, projected through ContractPluginProjection — a
pull-through view of the canonical tracker, never a mirror. Both hosts
implement it; the headless one carries the numeric fields without the authored
text, since a bot has no dat access. The rest of §11.6 (chat stream, tells,
give, use, confirmations) is other features and stays out of Campaign QT.
Owed
- The connected user gate: accept a quest against live ACE, open the Journal panel, confirm the list, the progress column and a repeat countdown.
The Abandon button is deliberately unwiredWIRED 2026-08-21. The claim that it had no wire message was wrong: it is game action0x0316carrying one contract id, and ACE answers with the0x0315delete QT3 already handles. Nothing is removed locally, so a refused abandon leaves the quest visibly intact.- The Journal notes page and Page List tabs mount inert, by design.
Definition of done
- Accepting a quest against live ACE shows it in the panel; completing it updates the stage; a repeatable one shows its countdown.
- Every ported algorithm cites its retail address.
- Every slice has a test that would catch its regression.