diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 83fc2af5..2a0d6875 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -1,4 +1,4 @@ -# Retail Divergence Register — current through 2026-07-31 +# Retail Divergence Register — current through 2026-07-31 **What this is.** The single auditable register of every known place acdream's runtime behavior can deviate from the retail client (Sept 2013 EoR build, @@ -490,6 +490,14 @@ equivalence argument (promote to AD/AP) or a fix. | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | | UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | +| CT-1 | Transcript truncation uses ONE character threshold (10,000) where retail uses two — it beheads to ~7,500 (`0x1D4C`) on passing 10,000 (`0x2710`), so its buffer oscillates between the two. acdream also cuts at whole LINES rather than searching for a newline near a byte offset | `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`MaxTranscriptCharacters`, `FirstLineWithinBudget`) | Retail's hysteresis exists to avoid re-trimming an ACCUMULATING buffer on every append; we rebuild the visible list from the log each time, so there is nothing to damp and a second threshold would only make the oldest visible line jump around as messages arrive. Whole-line cutting is what retail's newline preference is trying to achieve — our unit already is the line | acdream shows up to ~2,500 characters more scrollback than retail at the moment retail has just trimmed. Visible only as a slightly longer history; no state, wire or memory effect (ChatLog's own entry cap still bounds the model) | `ChatInterface::TruncateChatLog @0x004F4290`; threshold read at `RecvNotice_DisplayFinalStringInfo @0x004F4640` | +| CT-2 | No client-side chat word filtering. Retail runs every transcript line through a taboo table when the `FilterLanguage` option is on and SUBSTITUTES matches; acdream performs no substitution at all. The option itself is kept and still stores/ships its bit to the server exactly as retail does | `src/AcDream.Core.Net/GameEventWiring.cs` (no filter in the AddText path); option at `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | DELIBERATE PRODUCT DECISION by the user, 2026-08-21: "I do not want any censoring." Not an oversight and not a porting gap | A player who enables FilterLanguage expecting retail's behaviour sees unfiltered text. No state, wire or server-visible effect — the option bit is still sent, so anything the SERVER gates on it behaves normally | `PlayerModule::FilterLanguage` + `TabooTableAdaptor::CheckCensorsW @0x00682A30` inside `ClientSystem::AddTextToScroll @0x00563C50`; matching at `TabooTable::CreateCheckString @0x00681570` / `StringMatchesFilter @0x00681600` | +| CT-3 | A media `Pause` step holds for its `MinDuration`; retail authors a min AND a max and acdream ignores the max. Every sequence measured so far sets them equal, so nothing shipped is affected | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`, the `Pause` case) | Whether the range means a random hold, a ramp, or a min-with-a-frame-budget ceiling is NOT determinable from the decomp, and picking one would be a guess dressed as a port. Using the min is the one reading that is right in every interpretation for the equal-valued case we can actually observe | A sequence authoring min != max would animate faster than retail. None does in the elements dumped so far; if one is found, the reading has to be measured before it is implemented | `MediaDescPause` in the LayoutDesc dat; playback at `UIElement::AnimateMedia` | +| CT-4 | A media `Jump`/`State` step with a probability below 1 FALLS THROUGH rather than branching; retail rolls for it | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`) | The roll's distribution and its re-roll cadence (per visit? per state entry?) are not in the decomp. Falling through is the conservative direction: a sequence that ends early stops animating, where treating it as certain would animate forever and could pin a state that never hands off | A probabilistic sequence plays its deterministic tail instead of its branch. The chat indicator authors p=1 throughout, so it is exact there | `MediaDescJump{Probability}` / `MediaDescState{Probability}` in the LayoutDesc dat | +| CT-5 | A bare `@log` filename lands in the client's own log directory (`ApplicationPathSet.LogsDirectory`), not the install directory retail names ("a log file named Aclog.txt in your Asheron's Call directory"). Rooted paths are honoured verbatim, as retail's `fopen` would | `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (`_chatLogDirectory`); `src/AcDream.Core/Chat/ChatSessionLog.cs` | acdream's launcher replaces the install directory atomically on update, so a log written there is wiped by the next update or blocks it outright. Retail had no updater with that property. The client's own data directory is the equivalent that survives | A player following retail-era instructions looks for the file next to the executable and does not find it. The `/log` reply names the file, not the directory, so the path is discoverable only from this row and the code | `ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0`; help text at `DoSetOutputHelp @0x0057A950` | +| CT-6 | The `@log` file records the composed line WITHOUT retail's inline text-tag markup. Retail's `fprintf` runs before glyph parsing, so its logs contain literal `` markers around tagged names | `src/AcDream.App/UI/ChatTranscriptLogWriter.cs` | acdream never puts markup in the line: `ChatVM` carries tags as SPANS beside the text (CT-A2/A3), so there is no markup at that seam to preserve. Reconstructing it purely to write it to a file would be inventing a string the client does not otherwise produce | A log diffed against a retail-era log differs on tagged lines — acdream's are the clean ones. No in-client effect | `ClientSystem::AddTextToScroll` write at `@0x00563E5B`, upstream of `UIElement_Text::InqGlyphs @0x00468EA0` | +| QJ-1 | The per-character journal file lives in the client's own data directory (`{data}/journal/Journal-{server}-{character}.txt`), not beside the executable where retail's sits | `src/AcDream.App/UI/JournalPersistence.cs`; path composed in `InteractionRetainedUiComposition` | Identical reasoning to CT-5: acdream's launcher replaces the install directory atomically on update, so a journal written there is destroyed by the next update. The file NAME follows retail's own `"%s%s-%s-%s.txt"` pattern exactly | A player migrating a retail journal must copy the file rather than find it picked up in place. No in-client effect | `gmJournalUI::LoadPages @0x00496AC0` / `SavePages @0x00497270` | +| QJ-2 | Authored button property `0x0D` is ignored. Retail's `UIElement_Button::UpdateState_ @0x00471CF0` reads it and selects the Ghosted visual state when set; acdream reads it for neither input nor appearance | `src/AcDream.App/UI/UiButton.cs` (constructor) | It CANNOT mean input-disabled: measured across every installed layout, 85 elements author `0x0D` and all 85 author it TRUE, never False, and no panel clears it (the only `SetAttribute_Bool(.., 0xd, ..)` sites are chargen appearance, the keymap option and the barber). Reading it as "disabled" made every Journal-panel button visible-but-unclickable. Nor can it be a pure ghosted LOOK: the same 85 include live buttons (New, Record, Start, Delete, Reset) alongside inert column headers, so one appearance cannot suit both | If `0x0D` turns out to drive appearance, the affected elements render un-ghosted where retail greys them — 85 elements, mostly column headers. No input or state effect | `UIElement_Button::OnSetAttribute @0x00471F40` case 0; `UpdateState_ @0x00471CFC` | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 4f2cc180..b74034db 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -979,7 +979,7 @@ Research: R7 + R10 + R11 + UI slice 05. - **✓ SHIPPED — H.1 — Chat window.** UI panel + all 6 wire opcodes (Channel, Tell, System, HearSpeech, HearRangedSpeech, TurbineChat). Wire layer + panel + outbound input + holtburger inbound parity + combat translator all shipped across I.1-I.7 on 2026-04-25. Targets `AcDream.UI.Abstractions`; will be reskinned when D.2b's custom retail-look toolkit lands. - **H.2 — Allegiance.** Tree model + XP pass-up math + 5 allegiance chat channels + MOTD. See `r11-allegiance.md`. -- **H.3 — Emote scripts + quests + dialogs.** 122 EmoteType × 39 Trigger mini-VM. Contract tracker UI. NPC dialog rendered via chat with `` markup. See `r10-quest-dialogs.md`. +- **H.3 — Emote scripts + quests + dialogs.** **Client scope COMPLETE 2026-08-21** (Campaigns QT + QJ, both user-accepted). The "122 EmoteType × 39 Trigger mini-VM" in this line describes the SERVER's job: per `r10-quest-dialogs.md` §1.3 the retail client stores no quest flag, evaluates no emote, and is never told a flag changed. It learns about quests three ways — dialogue strings the server already formatted, generic error toasts, and the contract tracker. The first two shipped earlier; the tracker, plus the Journal notebook and its index, shipped as the three-tab Journal panel (`RetailPanelCatalog.Journal` = 25). Start at `claude-memory/project_quest_journal_panel.md`. - **H.4 — Character creation.** `0xE000002 CharGen` dat + 13 heritages + templates + appearance picker + preview renderer. See `r07-character-creation.md`. **Acceptance:** create a character from scratch, talk to an NPC, get + complete a quest, gain XP that passes up to the patron. @@ -2115,7 +2115,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen. | Sliding along buildings / walls feels wrong | **Phase L.2c + L.2d** | | Roof edge / cliff / precipice blocks or slides wrong | **Phase L.2c** | | Crossing outdoor cell seams reports the wrong cell | **Phase L.2e** | -| Can't talk to NPCs | Basic select/use/give interaction works; full emote conversation/dialog systems remain **Phase H.3** | +| Can't talk to NPCs | NPC dialogue works (user-confirmed 2026-08-21); the emote VM behind it is the SERVER's, not ours | | Can't open a door | **FIXED** ✓ — object-use, animation, fading hooks, and collision transitions shipped | | Portals render as a rotating black disk | **FIXED** ✓ — DAT particles/effects and portal-space presentation shipped | | Chimneys have no smoke | **Phase E.3 SHIPPED** ✓ | @@ -2135,6 +2135,6 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen. | No character creation — must use ACE admin | **Phase H.4** | | Sky is a flat color | **Phase G.1** (shipped; F7 cycles time, F10 cycles weather) | | Can't join allegiance | **Phase H.2** | -| No quest tracker | **Phase H.3** | +| ~~No quest tracker~~ | **SHIPPED 2026-08-21** — the Journal panel's Contracts tab (Campaign QT) | If you see something not on this list, add it here and assign a phase. diff --git a/docs/plans/2026-08-21-chat-text-tag-campaign.md b/docs/plans/2026-08-21-chat-text-tag-campaign.md new file mode 100644 index 00000000..d81c7871 --- /dev/null +++ b/docs/plans/2026-08-21-chat-text-tag-campaign.md @@ -0,0 +1,199 @@ +# Campaign CT — complete chat parity (system + GUI) + +**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated. +CT-B4 landed 2026-08-21 after the research block turned out to rest on a wrong +premise (see the slice). One item deliberately not shipped: CT-B3 (word +filtering — dropped by user direction, register row CT-2). + +**Carried forward:** ~~multi-frame state media~~ **DONE 2026-08-21.** The +importer now keeps the whole authored sequence and `UiMediaSequence` plays it. +Measuring the real data (`LayoutDump --media 0x1000048C`) corrected the +behaviour as well as enabling it: the indicator blinks three times over three +seconds and then hands off to `Ghosted`, hiding itself. Retail's is a transient +attention-flash, not a badge that stays lit until you scroll down. Register +rows CT-3, CT-4. + +**Goal, set by the user 2026-08-21: complete retail parity for the chat +system AND the chat GUI.** Not "fix the green name" — that was the symptom +that started the review. The bar is that a retail player sitting down in front +of acdream's chat window finds nothing missing and nothing behaving +differently. + +Campaign CH (2026-08-09, closed user-accepted) landed colours, side channels, +the 152-verb command registry, the window shell and verbatim `/help`. CT is +the pass that closes what CH did not reach. + +Research notes (all 2026-08-21): `chat-texttag-model.md`, +`chat-tagged-name-composition.md`, `chat-tag-click-dispatch.md`, +`retail-chat-window-ui.md`, `acdream-text-stack-audit.md`, +`acdream-chat-ui-audit.md`. + +## Definition of done + +1. Every retail chat behaviour is either implemented, or has a divergence- + register row saying why not. +2. Every user-visible chat surface has a test that would catch its regression. +3. The chat digest and `docs/ISSUES.md` describe reality (both are stale today). + +## What the review established + +### The green clickable name is a TEXT-STACK gap, not a chat gap + +The client sprintfs literal markup into the line — +`{name}<\Tell> says, "{text}"`, closing marker a +literal backslash — and `UIElement_Text::InqGlyphs @0x00468EA0` parses the +brackets while appending, calling `TextTagFactory::MakeTag @0x00478480`. Tags +attach **per glyph**; a "run" is emergent (adjacent glyphs with equal tag +pointers). A glyph takes the tag colour (property `0x1D`) only when a tag is +open AND its `m_type == 0x10000001`, else the line colour (`0x1B`). Only +senders with a GUID in `0x50000001..0x6FFFFFFF` are tagged. + +Colour **measured** from the installed dats (`LayoutDump --colors`), chat +`0x2100006F` / transcript `0x10000011`: `P0x1B` = RGB(204,204,204), +`P0x1D` = **RGB(0,178,0)**. The tag colour is per-ELEMENT and authored, while +the line colour on that same element comes from the runtime chat table — +filing "tag green" into the LogTextType table would put it in the wrong place. + +Click: `UIElement_Text::MouseUp @0x004694F0` → `DeterminePositionFromXY +@0x004688F0` → `GlyphList::InqGlyph @0x00473430` → virtual `HandleClick` at +tag-vtable `+0x14` → `gmMainChatUI::RecvNotice_TextTag_IIDStringClick +@0x004CCE10` → `ChatInterface::StartTell @0x004F41F0`, which writes +`"@tell {Name}, "`, takes focus, and shows the entry bar. Clicking a name +always opens a TELL — fellowship, allegiance, patron/vassal and named-channel +lines all embed the same markup. No hover effect. + +### Our side is closer than feared + +`UiText` **already** draws multi-coloured runs (`TextRun`/`RunsProvider`, used +by the character stat panel); it is gated to `OneLine == true`. The draw path +needs no renderer work — arbitrary pen X, substring measurement — and +`UiText.HitChar` already resolves a click to (line, column). The blocker is +that `ChatVM.RecentLinesDetailed()` drops `Sender`/`SenderGuid` one step before +the renderer, though `ChatEntry` carries them the whole way. + +### The command registry is already at parity + +All 13 verbs the CH3 research note lists as MISSING were closed by CH4 and +verified present 2026-08-21 (`cg`, `soc`, `o`, `co-vassals`, `fellows`, +`group`, `party`, `vassal`, `ab`, `guild`, `ct`, `clfg`, `crp`). `/g` correctly +resolves to Fellowship, confirmed against the live retail client. That note's +"acdream status" columns are stale and now carry a correction banner. + +## Slices + +### Group A — the tagged-text capability (strict chain, A1→A5) + +Nothing is user-visible until A4. + +- **CT-A1** Multi-line text elements carry coloured runs. Additive; the ~50 + files using `Line` are untouched. No behaviour change. +- **CT-A2** Parse the tag markup into runs with a tag payload, including + retail's rule that an unparseable bracket closes the open tag. Pure, unit- + testable, no UI. +- **CT-A3** Stop flattening: carry sender name + guid through `ChatVM` into + spans, and compose retail's markup in the speech handlers behind the + player-GUID-range gate. +- **CT-A4** Apply the authored `0x1D` tag colour when a tag is open and its + type matches. **Names turn green.** +- **CT-A5** Sub-line hit-testing and `StartTell`. **Names become clickable.** + +### Group B — chat SYSTEM behaviours + +- **CT-B1** Bound the transcript: 10,000 chars, trim to ~7,500 preferring a + newline boundary (`TruncateChatLog @0x004F4290`). Today it grows for the life + of the session — a slow leak, not only a fidelity gap. +- **CT-B2** Text-replacement macros: typing `/r `, `/t `, `/tell ` rewrites the + input to `@tell {LastTeller}, ` on the space keypress + (`HandleTextReplacements @0x004F50D0`). The commands already work; the + visible expansion does not exist. +- **CT-B3** ~~`FilterLanguage` word filtering~~ — **DROPPED by user direction + (2026-08-21): "I do not want any censoring."** acdream keeps the option + itself, which still stores and ships its bit to the server exactly as retail + does, but performs no client-side substitution. Registered as CT-2 rather + than left as an implicit gap, since it IS a knowing departure from retail + (`PlayerModule::FilterLanguage` + `TabooTableAdaptor::CheckCensorsW + @0x00682A30` inside `AddTextToScroll`). + + Worth keeping on record, because the attempt established two things that + would otherwise be rediscovered if this is ever revisited: + + 1. The table's dat id is not readable from the decomp — `CheckCensorsW` + reaches it through `DBObj::GetByEnum` with the arguments elided by Binary + Ninja. The portal master enum map (`0x25000000`) has 22 categories, with + category 3 (`0x0E010001`, `0x0E010002`) and the single-entry categories 8 + and 11 the plausible candidates. + 2. Chorizite.DatReaderWriter declares a `TabooTable` type but does NOT decode + it — only `DBObjType` and `HeaderFlags`. The format would have to be + decoded here first. + + And the matching is an algorithm, not a word list: + `TabooTable::CreateCheckString @0x00681570` normalises a candidate before + `StringMatchesFilter @0x00681600` compares it, which is how retail catches + obfuscated spellings. +- **CT-B4** ~~The plain-text session chat log~~ **DONE — and the premise was + wrong.** There is no automatic session log to have a path for. Retail's + `@log` is a COMMAND: `ClientCommunicationSystem::DoSetOutput @0x0057E4F0` + takes a filename, `StartCopyOutputToFile @0x0057C8A0` does the + `fopen(name, "a+")`, and running it again with no argument closes it. So + "path and rotation UNKNOWN" was asking a question the design does not have: + the player names the file, and there is no rotation because it appends + forever. + + The path question that DOES exist — where a bare name lands — is answered by + retail's own help text, which the CH4 help table already carried verbatim + without anyone reading it: "a log file named Aclog.txt **in your Asheron's + Call directory**". acdream cannot use the install directory (the launcher + replaces it atomically on update), so a bare name lands in the client's own + log directory. Rooted paths are honoured verbatim. Register row CT-5. + + Landed with it: the verb registered in the catalog (it had a help entry + since CH4 but no catalog entry, so `/log` printed help and did nothing), + retail's `.txt`-for-extensionless rule, all five reply strings byte-decoded + from the paired binary, and the writer attached at OPEN so only text after + the command is copied. The line logged is the composed display line with the + shared timestamp, because retail's `fprintf` sits inside `AddTextToScroll` + — downstream of composition, upstream of glyph layout. + +### Group C — chat GUI + +- **CT-C1** Auto-scroll vs unread: retail samples "was at bottom" BEFORE the + line lands; if you had scrolled up it leaves you there and lights the unread + indicator (`0x1000048C`), which scrolls to bottom and clears on click. +- **CT-C2** Escape in the chat input is a complete no-op — `UiField` has no + `Escape` case, and a focused field also suppresses the input dispatcher's + fallback, so there is no clear, no defocus and no hotkey passthrough. +- **CT-C3** Option-gated timestamp prefix (`%#H:%M:%S `, colour index `0x0C`, + grey), gated on `PlayerModule::DisplayTimeStamps()`. +- **CT-C4** Input-bar editing parity: clipboard and selection paths + (Ctrl+C/X/V, shift-selection) work but are untested; `ToggleMaximize` and the + floating-window Close button have zero coverage. **DEFERRED** — pure test + coverage over behaviour the audit confirmed already works, so it changes + nothing a user can see. Worth doing; not worth blocking the campaign on. + +### Group D — hygiene + +- **CT-D1** ~~Delete the dead ImGui-era `ChatPanel`~~ **DONE.** Verified never + constructed in `src/`, then removed with its three panel-only test files. + `ChatVMCombatTests` was KEPT — three of its four tests are real `ChatVM` + coverage; only the one `ChatPanel` render test went. +- **CT-D2** ~~Reconcile the chat digest and `docs/ISSUES.md`~~ **DONE.** + `docs/ISSUES.md` turned out to be ACCURATE already — #358 and #363 are + recorded CLOSED there. Only the chat digest's "Open" section was stale, and + it is corrected: genuinely open are #359, #360, #361, #366. The digest also + gained a Campaign CT section and three new DO-NOT-RETRY rows. + +## Research still owed before the affected slices + +- The tag-type roster behind `m_type == 0x10000001` — only "Tell" is + confirmed; the full set lives in the DAT `EnumMapper` category `0x18`. + Blocks nothing in Group A, but decides whether other tag shapes exist. +- Whether retail's transcript supports text selection distinctly from the + entry field (blocks CT-C4's scope). +- Whether a chat-specific sound cue exists — a grep came back empty, which is + weak evidence, not proof of absence. + +## Deliberately NOT in scope + +Item links and the other three tag shapes (`DID`, `IID`, `IIDEnum`). They have +no listener in the retail build we target, so porting them would be inventing +behaviour. CT-A5's dispatch is generic, so they cost nothing to add later. 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..7fa6bbe2 --- /dev/null +++ b/docs/plans/2026-08-21-contract-tracker-campaign.md @@ -0,0 +1,200 @@ +# Campaign QT — the contract tracker (H.3's client half) + +**Status:** CLOSED USER-ACCEPTED 2026-08-21. All six slices landed and the +connected gate passed, together with Campaign QJ's two tabs. + +**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. + +### 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~~ **WIRED 2026-08-21.** The + claim that it had no wire message was wrong: it is game action `0x0316` + carrying one contract id, and ACE answers with the `0x0315` delete QT3 + already handles. Nothing is removed locally, so a refused abandon leaves the + quest visibly intact. +- 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. diff --git a/docs/plans/2026-08-21-journal-campaign.md b/docs/plans/2026-08-21-journal-campaign.md new file mode 100644 index 00000000..0f1d9ddd --- /dev/null +++ b/docs/plans/2026-08-21-journal-campaign.md @@ -0,0 +1,121 @@ +# Campaign QJ — the Journal and Page List tabs + +**Status:** CLOSED USER-ACCEPTED 2026-08-21. All five slices landed and the +connected gate passed. + +The gate took four rounds, and every defect it found was the same mistake in a +different place — an element bound as the wrong thing, or a binding never +tested: + +1. **Button property `0x0D`** read as "starts disabled", which killed every + button on the panel (register QJ-2). +2. **The location readout** is authored EDITABLE, so it is a `UiField`; bound + as `UiText` it silently discarded every write — the value reached the model + and the file and never the screen. +3. **Handlers deferred their redraw** to the next frame's `Tick` where retail + redraws at the click. +4. **The timer's unit labels** stayed visible behind the running readout, + because retail's `ShowEditableTimer` toggles each box AND its label. + +Round 3's "Record does nothing" turned out not to be a defect at all: the +character was indoors, where retail's own `gid_to_lcoord` fails and nothing is +recorded. Faithful, and now commented so it does not read as a gap. + +The durable outcome is `JournalPanelLiveBindTests` — see +`claude-memory/feedback_test_the_binding_seam.md`. Completes the panel Campaign QT mounted: QT +shipped the Contracts tab and left the other two inert by design. + +**Scope:** retail's `gmJournalUI` (element type `0x10000048`, page +`0x10000563`) and `gmPageListUI` (type `0x10000049`, page `0x10000564`). + +## What this actually is + +A **per-character notebook**, entirely client-side. No wire, no server +involvement, no dat content — the player writes the pages. Each page carries a +label, a title, free-form notes, a recorded LOCATION, and a countdown TIMER. +The Page List tab is a searchable index over those pages. + +Nothing about it depends on quests; it shares the panel with Contracts and +nothing else. That it is called "Journal" while the panel is also called +"Journal" is retail's own naming, not a mistake here. + +## Measured ground truth + +### The file format + +`gmJournalUI::SavePages @0x00497270` / `LoadPages @0x00496AC0`. A plain tagged +text file, `fopen` mode `w+`. Both call sites pass the literal prefix +`"Journal"`; the path template is `%s%s-%s-%s.txt`, i.e. +`{dir}Journal-{server}-{character}.txt`. + +``` + begins a page (a file that does not open with one is refused) + %d page number + %s label (authored max length 16) + %s title (32) + %s notes (2048) + %d timer days + %d timer hours + %d timer minutes + %f recorded location + %f +