acdream/docs/plans/2026-08-21-contract-tracker-campaign.md
Erik fe1e68e5fe feat(quest): QT6 — plugins can read the contract tracker
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>
2026-08-21 15:22:22 +02:00

9.1 KiB
Raw Blame History

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:

  1. TimeWhenDone is never read. Only TimeWhenRepeats drives the text.
  2. timeOfServerUpdate is 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.
  3. DescriptionProgress is 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
0x100005D80x100005DF "Status:" → value
0x100005D90x100005E0 "Contact:" → value
0x100005DA0x100005E1 "Contact Location:" → value
0x100005DB0x100005E2 "Quest Location:" → value
0x100005DE description block (270x52, wrapping)
0x100005DD0x100005E3 "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. RuntimeContractState as 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 retail DeltaTimeToString it 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 from IRuntimeContractView x ContractCatalog, 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 unwired — retail's abandon path is a contract-registry command this campaign did not port. It is authored and visible; clicking it does nothing.
  • The Journal notes page and Page List tabs mount inert, by design.

Definition of done

  1. Accepting a quest against live ACE shows it in the panel; completing it updates the stage; a repeatable one shows its countdown.
  2. Every ported algorithm cites its retail address.
  3. Every slice has a test that would catch its regression.