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>
198 lines
9.1 KiB
Markdown
198 lines
9.1 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.
|
||
|
||
**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.
|