diff --git a/docs/plans/2026-08-21-contract-tracker-campaign.md b/docs/plans/2026-08-21-contract-tracker-campaign.md new file mode 100644 index 00000000..a02b7a12 --- /dev/null +++ b/docs/plans/2026-08-21-contract-tracker-campaign.md @@ -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 +`` 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` 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` 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. diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index ac6336dd..9f819ed6 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -18,6 +18,7 @@ using SysEnv = System.Environment; if (args.Length == 0) { Console.WriteLine("usage: LayoutDump [rootElementId] [--states]"); + Console.WriteLine(" LayoutDump --find "); 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().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]);