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>
191 lines
8.6 KiB
Markdown
191 lines
8.6 KiB
Markdown
# 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 |
|
||
| `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.** `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.
|
||
|
||
### 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.
|