docs(quest): plan Campaign QT, and add the layout search that found the panel
H.3's roadmap line ("122 EmoteType x 39 Trigger mini-VM") describes the
SERVER's job. The retail client never stores a quest flag, never evaluates an
emote, and is never told a flag changed — so most of H.3 was never client work
at all. Measuring what we already have narrows the remaining scope to one
thing: the contract tracker, the only structured view of quest state a client
ever gets. The user confirmed NPC dialogue works live.
LayoutDump could only dump a layout you already knew the id of, but the decomp
hands you a CLASS id with no layout attached (UIElement::RegisterElementClass),
so the gap between the two was crossed by guessing. --find closes it, and it
searches the element's TYPE as well as its id because registration keys on
Type — searching only the id finds a real element with the same number and
quietly answers the wrong question, which is exactly what it did on the first
run here.
The plan records the wire layout, the panel's authored children, and
FillProgressString in full, including the three things a reimplementation
would get wrong: TimeWhenDone is never read, the countdown anchor is not on
the wire, and DescriptionProgress is a printf format rather than a string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
0e0a77c9b1
commit
730662f819
2 changed files with 204 additions and 0 deletions
124
docs/plans/2026-08-21-contract-tracker-campaign.md
Normal file
124
docs/plans/2026-08-21-contract-tracker-campaign.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# 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.
|
||||
|
||||
## 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.** Mount `0x21000069` by the OP3/FA recipe; list, scrollbar,
|
||||
the four label/value rows, description, buttons.
|
||||
- **QT6 — open/close.** Whatever raises it in retail, plus the plugin-visible
|
||||
read surface from `r10-quest-dialogs.md` §11.6.
|
||||
|
||||
## 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.
|
||||
|
|
@ -18,6 +18,7 @@ using SysEnv = System.Environment;
|
|||
if (args.Length == 0)
|
||||
{
|
||||
Console.WriteLine("usage: LayoutDump <layoutId> [rootElementId] [--states]");
|
||||
Console.WriteLine(" LayoutDump --find <elementIdOrType>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +35,85 @@ string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
|||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
|
||||
int findAt = Array.IndexOf(args, "--find");
|
||||
if (findAt >= 0)
|
||||
{
|
||||
// "Which layout owns this element?" -- the question every panel port
|
||||
// starts with, and the one this tool could not answer. Retail registers a
|
||||
// panel class against an ELEMENT id (UIElement::RegisterElementClass), so
|
||||
// the decomp hands you an id with no layout attached to it; without a scan
|
||||
// the only way across that gap is guessing at 0x21xxxxxx ids.
|
||||
uint wantedElement = findAt + 1 < args.Length
|
||||
? Convert.ToUInt32(args[findAt + 1], 16)
|
||||
: 0u;
|
||||
if (wantedElement == 0)
|
||||
{
|
||||
Console.WriteLine("--find needs an element id");
|
||||
return 1;
|
||||
}
|
||||
|
||||
int scanned = 0;
|
||||
int hits = 0;
|
||||
foreach (uint layoutId in dats.GetAllIdsOfType<DatReaderWriter.DBObjs.LayoutDesc>().OrderBy(i => i))
|
||||
{
|
||||
scanned++;
|
||||
ElementInfo? candidate;
|
||||
try
|
||||
{
|
||||
candidate = LayoutImporter.ImportInfos(adapter, layoutId);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// A layout this importer cannot read is a finding, not a stop --
|
||||
// the whole point is to sweep every one of them.
|
||||
Console.WriteLine($" layout 0x{layoutId:X8}: FAILED TO IMPORT ({e.GetType().Name})");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate is null)
|
||||
continue;
|
||||
|
||||
if (FindElement(candidate, wantedElement, out string path))
|
||||
{
|
||||
hits++;
|
||||
Console.WriteLine($"layout 0x{layoutId:X8} {path}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"element 0x{wantedElement:X8}: {hits} hit(s) across {scanned} layouts");
|
||||
return hits > 0 ? 0 : 2;
|
||||
|
||||
// Matches an element's ID or its TYPE. Retail's
|
||||
// UIElement::RegisterElementClass keys a panel class on the TYPE field
|
||||
// (0xC = Text, 0x19 = WaitDialog, 0x1000004B = gmContractsUI), so a class
|
||||
// id out of the decomp is a type; an id out of a layout dump is an id.
|
||||
// Searching only one of them silently finds the wrong element, because
|
||||
// the two share a number space.
|
||||
static bool FindElement(ElementInfo e, uint wanted, out string path)
|
||||
{
|
||||
if (e.Id == wanted || (uint)e.Type == wanted)
|
||||
{
|
||||
string how = e.Id == wanted ? "id" : "TYPE";
|
||||
path = $"0x{e.Id:X8} (match on {how}; type 0x{e.Type:X}, "
|
||||
+ $"{e.Width}x{e.Height} at {e.X},{e.Y})";
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (ElementInfo child in e.Children)
|
||||
{
|
||||
if (FindElement(child, wanted, out path))
|
||||
{
|
||||
path = $"0x{e.Id:X8} > {path}";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
path = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
ElementInfo? root = ids.Length > 1
|
||||
? LayoutImporter.ImportInfos(adapter, ids[0], ids[1])
|
||||
: LayoutImporter.ImportInfos(adapter, ids[0]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue