acdream/docs/plans/2026-08-21-contract-tracker-campaign.md
Erik fac2dc7248 docs(quest): QT5 is specified — and it is the Journal panel, not a contract one
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>
2026-08-21 14:56:46 +02:00

175 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Campaign QT — the contract tracker (H.3's client half)
**Status:** ACTIVE 2026-08-21.
**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 so far
QT1 `ab3934e2` (wire), QT3 `f629ce7f` (state + routing), QT2/QT4 `ef6b7310`
(catalog + progress string). QT5 and QT6 are open.
## 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.