Merge remote-tracking branch 'origin/main' into codex/atmospheric-rendering-campaign

This commit is contained in:
Erik 2026-08-22 13:02:43 +02:00
commit 99cf26e00c
122 changed files with 14815 additions and 947 deletions

View file

@ -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 `<Tell:IIDString:…>` 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` |
---

View file

@ -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 `<Tell:…>` 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.

View file

@ -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 —
`<Tell:IIDString:{iid}:{name}>{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.

View file

@ -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
`<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~~ **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.

View file

@ -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`.
```
<NEWP> begins a page (a file that does not open with one is refused)
<PNUM> %d page number
<LABE> %s label (authored max length 16)
<TITL> %s title (32)
<NOTE> %s notes (2048)
<DAYS> %d timer days
<HOUR> %d timer hours
<MINU> %d timer minutes
<LOCX> %f recorded location
<LOCY> %f
<TIME> %f running-timer value
```
Retail's own load error, byte-decoded: `"Problem loading journal: Your journal
file does not create a new page!"`
### The Journal page (`0x10000563`)
| Element | Role |
|---|---|
| `0x10000567` | "New" button |
| `0x10000569` | label edit box (`0x1E` = 16) |
| `0x1000056A` / `0x1000056B` | "Title:" / title edit box (32) |
| `0x1000056C` / `0x1000056D` | "Notes:" / notes edit box (2048), scrollbar `0x1000056E` |
| `0x1000056F` / `0x10000570` / `0x10000571` | "First" / "~ 1 ~" / "Last" |
| `0x10000572` / `0x10000573` / `0x10000574` | "Location:" / "00.0S, 00.0W" / "Record" |
| `0x10000575` | "Timer:" |
| `0x10000576` `0x10000577` | days field, "d" |
| `0x10000578` `0x10000579` | hours field, "h" |
| `0x1000057A` `0x1000057B` | minutes field, "m" |
| `0x1000057C` | running-timer text — OVERLAPS the three fields at x=84 |
| `0x1000057D` | "Start" button |
| `0x10000566` | bottom-right button (65x32) |
`0x1000057C` sharing x=84 with the day/hour/minute fields is the authored form
of `ShowEditableTimer @0x00495770` vs `ShowRunningTimer`: the same strip is
either three editable numbers or one running readout, never both.
### The Page List page (`0x10000564`)
| Element | Role |
|---|---|
| `0x1000057F` `0x10000580` `0x10000581` `0x10000582` | headers "#" / "Title" / "Timer" / "Label" |
| `0x10000583` | the list, scrollbar `0x10000584` |
| `0x10000585` | "Delete" |
| `0x10000586` / `0x10000587` / `0x10000588` | "Search:" / search box / "Reset" |
`gmPageListUI::PageContainsString @0x00493B60` is the search predicate;
`CheckForDoubleClick @0x00493140` opens the page
(`gmJournalUI::GotoPage @0x00496430`).
## Slices
- **QJ1 — the page model and its file.** `JournalPage` plus a faithful
reader/writer for the tagged format, including retail's refusal of a file
that does not open with `<NEWP>`. Pure; no UI, no state ownership.
- **QJ2 — the owner.** `RuntimeJournalState`: the page collection, the current
page, new/delete/goto, and the timer. Per-character.
- **QJ3 — the Journal page.** Edit boxes, page navigation, Record, and the
editable/running timer swap.
- **QJ4 — the Page List page.** The list, the search, delete, and
double-click-to-open.
- **QJ5 — persistence.** Load on character enter, save on exit, under the
client's own data directory.
## Definition of done
1. A page written in one session is there in the next.
2. Every ported algorithm cites its retail address.
3. Every slice has a test that would catch its regression.

View file

@ -1,5 +1,20 @@
# Retail client slash-command registry — complete enumeration + acdream audit
> **CORRECTION 2026-08-21 — the "acdream status" columns below are STALE.**
> They record the state on 2026-08-09, BEFORE slice CH4 landed. Every verb this
> document calls MISSING has since been added and verified present in
> `ChatInputParser.ChannelVerbs`: `cg`, `soc`, `o`, `co-vassals`, `covassal`,
> `c`, `fellows`, `group`, `party`, `vassal`, `ab`, `guild`, `gu`, `ct`,
> `clfg`, `crp`. The DIVERGENT row for `g` was also corrected — acdream now
> maps `/g` to Fellowship, matching retail, confirmed against the live retail
> client on 2026-08-21.
>
> The RETAIL side of this document (the registered verbs, handler addresses and
> channel ids) remains accurate and is still the authority. Only the columns
> describing what acdream does are out of date; verify against
> `ChatInputParser.cs` before trusting them.
Date: 2026-08-09
Status: RESEARCH ONLY. No production code was changed.

View file

@ -0,0 +1,188 @@
# acdream chat UI audit — window controllers, input bar, menus, filters, window management
Scope: acdream's own chat UI implementation (window controllers, view models,
input bar, menus, filters, window management). Explicitly OUT of scope per
task boundary: retail's glyph tag system, tag click dispatch, and the text
rendering stack (owned by a parallel audit) — not covered here beyond
incidental mentions needed to explain routing.
All claims below are cited `file.cs:line` against the current worktree
(`C:\Users\erikn\source\repos\acdream\.claude\worktrees\objective-leavitt-0cbd10`).
This document supersedes nothing in `claude-memory/project_chat_digest.md`;
it verifies and extends it against the current code as of 2026-08-21.
---
## 1. Inventory — what exists and what each surface owns
| Surface | File | Owns |
|---|---|---|
| Main chat window | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Binds LayoutDesc `0x2100006F` (retail `gmMainChatUI`/`ChatInterface`, `m_eWindowID==8`). Transcript (`UiText`), input (`UiField`), scrollbar, talk-focus channel menu (`UiMenu`), Send button, max/min toggle, the four floating-window indicator LEDs (mirror + click), the 8 resize-grip locked/live cosmetic swap seed. |
| Floating chat windows ×4 | `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` | Binds LayoutDesc `0x2100005B` (retail `gmFloatyChatUI`, `m_eWindowID` 2-5) four times, one `FloatingChatWindowController` instance per `WindowId` 1-4. Transcript, input (channel hardcoded to Say), scrollbar, Send button, hardcoded `"Chat {windowId}"` title, Close button. |
| Chat view-model | `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` | Formats `ChatLog` entries to display lines (`RecentLines`/`RecentLinesDetailed`), owns `/framerate`/`/loc` client-side output, the `ShowSystemMessage`/`ShowInterfaceText` (0x1A→SpewBox) split, and `ChatCommandTargetState` (last-tell-sender/target) via `_commandTargets`. |
| Chat submit pipeline | `src/AcDream.Runtime/Chat/ChatCommandRouter.cs` | The one `Submit` chokepoint both `ChatWindowController.Bind`'s `Input.OnSubmit` (`ChatWindowController.cs:339-340`) and `FloatingChatWindowController.Bind`'s `Input.OnSubmit` (`FloatingChatWindowController.cs:157`) call. |
| Chat parsing / catalog | `src/AcDream.Runtime/Chat/ChatInputParser.cs`, `RetailClientCommandCatalog.cs`, `RetailCommandHelpTable.cs`, `RetailChannelTagTable.cs` | Verb resolution, 152-verb registry, `/help` text. |
| Per-window filter/open state | `src/AcDream.Core/Chat/ChatWindowState.cs` | The ONE canonical `ChatWindowState` (5 windows: id 0 main + 1-4 floaty) both controllers read live — filters, open/closed, `ShouldDisplay`/`TypeIsActive` (retail's `ChatInterface::TypeIsActive`/`RecvNotice_DisplayFinalStringInfo`). |
| Chat colors | `src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs` | 34-value `RetailLogTextType`→RGBA table, hard-coded (matches retail; no user config — confirmed still true, see §2). |
| Input widget | `src/AcDream.App/UI/UiField.cs` | Generic editable-field widget; the chat entry is one instance of this, built by `DatWidgetFactory.BuildText` for the DAT's Type-12 Editable element. |
| Talk-focus / dropdown menu | `src/AcDream.App/UI/UiMenu.cs` | Generic dropdown popup widget; the chat channel selector is one instance. |
| Chat-tab Settings surface | `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (+ `ChatOptionsDatDefaults.cs`) | Options panel → Chat tab: two opacity sliders + 5 per-window 13-row text-type filter blocks, all live-writing `ChatWindowState`/`RetailWindowOpacityController`. |
| Persistence | `src/AcDream.App/UI/RetailUiRuntime.cs` (`SaveChatWindowFilters`, `SaveChatOpacity`, load path ~`RetailUiRuntime.cs:1516-1518`) + generic `RetailWindowLayoutPersistence` | Filters (all 5 windows) + opacity persist to local `settings.json`; window geometry/open-state persist "for free" once registered under `WindowNames.Chat`/`ChatWindow1..4` (`src/AcDream.App/UI/WindowNames.cs:19-23`). |
| Dead/legacy surface | `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | An `IPanel` (ImGui-era D.2a stack) chat panel. **Never instantiated in `src/`** — see §6. |
**Not in this lane** (owned elsewhere per the task boundary): glyph tag
colouring/click dispatch, `UiText` rendering internals, SpewBox glyph
drawing. Where the input bar or router hands text to the SpewBox
(`ChatVM.ShowInterfaceText`, `ChatVM.cs:177-183`) that routing decision is
in-lane; the SpewBox's own rendering is not.
---
## 2. Window management
**Multiple windows.** Five windows total: 1 main (always open, `ChatWindowState.MainWindowId=0`, `ChatWindowState.cs:68,169-175`) + 4 floating (independently open/closed, `ChatWindowState.cs:69-70,178-206`). Confirmed working end-to-end: `ChatWindowController.SetIndicatorOpen`/`BindIndicatorClicks` (`ChatWindowController.cs:671-700`) mirror + drive floating-window visibility from the main window's 4 indicator LEDs, and `RetailUiRuntime.ToggleFloatingChatWindow` (`RetailUiRuntime.cs:1142-1143`) is the Alt+1..4 keybind's landing point (`KeyBindings.cs:218-221`).
**Filters.** Per-window 64-bit `LogTextType` bitmask filter (`ChatWindowState.GetFilter`/`SetFilter`/`ShouldDisplay`, `ChatWindowState.cs:145-167,230-235`), fully live: both controllers read it every transcript rebuild (`ChatWindowController.cs:742,776-777`; `FloatingChatWindowController.cs:261,278-279`), and it's user-editable through the Options→Chat tab (`ChatOptionsPageController.cs:181-193` five `FilterBlockSpec` rows, `:552-598` `BuildFilterBlock`). This is MORE complete than the chat digest implied — the digest's "Main's filter IS user-settable" note (digest line 118-119) is confirmed and the floaty filters are settable through the same UI, not just the main window.
**Move/resize.** The 8 authored resize grips (`ChatWindowController.cs:38-43` doc) and the drag/move title-strip import generically via `DatWidgetFactory`/`UiResizeGrip` — no per-controller binding code needed (confirmed by the class doc; no resize-specific code exists in either controller beyond `AttachWindow`/`ToggleMaximize`).
**Maximize/restore (main window only).** `ChatWindowController.ToggleMaximize` (`ChatWindowController.cs:537-580`) is a faithful port of `gmMainChatUI::HandleMaximizeButton @0x004CCE50` (save/restore Y+height, half-parent expansion, up/down growth choice, DAT-constraint clamping) plus `CaptureWindowState`/`RestoreWindowState` (`ChatWindowController.cs:702-715`) for session persistence. **Floating windows have no maximize** — matches retail (no max/min button authored on `0x2100005B`; `FloatingChatWindowController.cs` has no `MaxMinId`/`ToggleMaximize` equivalent, and this is correct, not a gap).
**Close (floating windows only).** `FloatingChatWindowController.Bind`'s Close-button wiring (`FloatingChatWindowController.cs:211-214`) calls `c.WindowHandle?.Hide()` — a straight port of `gmFloatyChatUI::ListenToElementMessage @0x004CE330`. **Main window has no close button** (matches retail; `ChatWindowState.SetOpen`/`Toggle` are explicit no-ops for window 0, `ChatWindowState.cs:177-188,195-206`).
**Opacity.** Two linked sliders (Default/Active), ported with retail's `DualHash` drag-the-other-value link (`ChatOptionsPageController.cs:344-372` doc, `:412-451`), scoped to exactly the 5 chat windows per issue #379 (see §5 — DONE). Batched persistence (`RetailUiRuntime.SaveChatOpacity`, `RetailUiRuntime.cs:1201-1210`) flushes once per discrete edit, not per drag tick.
**Persistence.** Filters for all 5 windows + both opacity values write to local `settings.json` on every live edit (`RetailUiRuntime.cs:1169-1210`) and reload at startup (`RetailUiRuntime.cs:1516-1518` for the main window's filter; the floaty load leg is the analogous call in `MountFloatingChatWindows`, cited by that same comment). Window geometry/open-state ride the generic `RetailWindowLayoutPersistence` path since all 5 windows are registered under distinct `WindowNames` entries (`WindowNames.cs:19-23`). **No gap found here** — window management persistence is comprehensive.
**Known, already-registered divergences (not new findings, listed for completeness):**
- AP-187/AP-189 (`docs/architecture/retail-divergence-register.md`): floaty filters are local-`settings.json`-only, no `0x1000008C` server-side wire sync between installs; and the chat log's shared `500`-entry ring buffer / `200`-entry display tail (`ChatLog.cs:21-22,447-448`; `InteractionRetainedUiComposition.cs:465`) gives every window a shallower **effective per-window** scrollback than retail's own **per-window** 10,000-line log — a low-traffic window's messages can be evicted from the shared tail by unrelated high-traffic windows' spam before that window's own filter ever sees them. Behaviorally the accumulate-while-closed and independent-per-window-scroll-position mechanics are correctly reproduced; only the depth differs.
- AP-188/#369 (OPEN): floating windows hardcode Send-channel to Say (`FloatingChatWindowController.cs:157`) because the floaty LayoutDesc authors no talk-focus menu; whether retail's floaties actually share the main window's last-picked channel is UNRESEARCHED (see §5).
- AP-190/#379 (#379 DONE, AP-190 partially retired): opacity scope-to-chat-only is fixed; the digest's "we snap, retail eases 5%-of-range per tick" easing-curve residual is unverified as still true today — UNKNOWN, needs re-check against `RetailWindowOpacityController` if picked up.
---
## 3. Input bar — exactly what `UiField` supports
Source: `src/AcDream.App/UI/UiField.cs`, wired per-window at `ChatWindowController.cs:329-372` (main) and `FloatingChatWindowController.cs:151-175` (floaty). Both controllers configure the SAME widget class with near-identical wiring (the floaty path lacks the talk-focus channel, per §2/#369).
**Supported:**
- **Typing / editing:** `InsertChar`, `Backspace`, `DeleteForward`, held-key auto-repeat for Backspace/Delete/Left/Right (`UiField.cs:159-189,683-700`, 0.40s delay / 25/s repeat).
- **Caret movement:** Left/Right (`MoveCaret`), Home/End (`MoveCaretTo`), all Shift-extendable when `Selectable` (`UiField.cs:791-811`). No Ctrl+Left/Right word-jump, no Ctrl+Backspace delete-word.
- **Selection:** mouse click+drag (`MouseDown`/`MouseMove`, `UiField.cs:749-763`), Shift+arrow, Ctrl+A select-all — **all three gated behind `Selectable`** (`UiField.cs:781,789`), which is DAT-authored property `0x27` on element `0x10000016`. Confirmed live-DAT-true for the chat input specifically: `ChatLayoutConformanceTests.ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace` (`tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs:220-228`) asserts `input.Selectable == true` after `ChatWindowController.Bind`, and neither controller sets it explicitly (`ChatWindowController.cs`/`FloatingChatWindowController.cs` grep clean for `Input.Selectable`) — so this is the real DAT default, not an accidental pass. **Not a gap.**
- **Clipboard:** Ctrl+C/Ctrl+X gated behind `Selectable` (same as above); Ctrl+V (paste) is **not** gated and always works when `Editable` (`UiField.cs:265-303,784`). Paste strips control characters and normalizes CR/LF for multi-line fields; the chat input is one-line so paste collapses to a single stripped line.
- **History:** 100-entry cap (`UiField.cs:326`, sentinel via `_historyIndex=-1`), Up/Down browse (`HistoryPrev`/`HistoryNext`, `UiField.cs:330-350,809-810`) — a faithful port per the class doc's citation of `ChatInterface::ProcessCommand @0x4f5100`.
- **Submit:** Enter/KeypadEnter (`UiField.cs:792-802`) calls `Submit()``OnSubmit` → clears (`ClearOnSubmit`, default true) → pushes history (`RecordHistory`, default true) → releases keyboard focus (`FindRoot()?.SetKeyboardFocus(null)`) — "exit write mode after sending," matching retail's read-mode/write-mode chat behavior.
- **Max length:** DAT-authored via property `0x1E``UiField.MaxCharacters` (`DatWidgetFactory.cs:788-789`); default `0xFFFF` if the DAT doesn't author one. Not hardcoded in the controller — correctly deferred to the imported layout.
- **Focus entry (keyboard):** `UiRoot.OnKeyDown` has a special case — when nothing is focused, Tab or Enter/KeypadEnter focuses `DefaultTextInput` (`UiRoot.cs:1059-1071`), which `RetailUiRuntime.cs:1587` sets to the bound chat `Input`. This is the actual, working mechanism for "press Enter/Tab to start typing" — it runs entirely inside `UiRoot`, independent of the `InputDispatcher`/`InputAction` system.
**Missing / gaps found:**
1. **Escape does nothing while the chat input is focused.** `UiField.OnEvent`'s `KeyDown` switch (`UiField.cs:790-811`) has no `Key.Escape` case, so it falls through to `return false;` (implicit end-of-block after the switch, `UiField.cs:812`). Because `KeyboardFocus.IsEditControl` is true for a focused `UiField` (`UiField.cs:149`), `UiRoot.OnKeyDown`'s modal/root fallback branch (`UiRoot.cs:1083-1089`) is skipped entirely, and the event falls to `WorldKeyFallThrough` (`UiRoot.cs:1091`) — **an event nobody subscribes to in production** (grep for `WorldKeyFallThrough +=` across `src/` finds only the class's own declaration and a `README.md` code sample, `src/AcDream.App/UI/UiHost.cs:19`, `src/AcDream.App/UI/UiRoot.cs:408`). Separately, `InputDispatcher`'s own action-routing is gated off entirely whenever a widget holds keyboard focus (`_mouse.WantCaptureKeyboard``SilkMouseSource.cs:193``Root.WantsKeyboard``KeyboardFocus is not null`, `UiRoot.cs:196`), so `GameplayInputCommandController.HandleEscape` (`GameplayInputCommandController.cs:238-248`, cancel target mode / exit fly mode / close window) never fires either. **Net effect: pressing Escape while the chat box has focus is a complete no-op in acdream today** — no clear, no defocus, no fallback to a game hotkey. This is not tracked in `docs/ISSUES.md` under any existing chat issue.
2. **No autocomplete / tab-completion** of player names, channel tags, or command verbs. Confirmed by exhaustive grep (`autocomplete|tabcomplete|namecomplet` across `src/`) — zero hits. `ChatCommandRouter.Submit` (`ChatCommandRouter.cs:30-179`) is pure parse-and-dispatch with no partial-match suggestion path. Whether retail AC's chat box ever had tab-completion is UNKNOWN — not established either way in this pass; flagging the absence, not asserting it's a regression.
3. **`@title` is a documented no-op** (see §4) — the floating window's title bar (`FloatingChatWindowController.cs:194-207`) is permanently `"Chat {windowId}"`, unaffected by the command that's supposed to set it.
4. **No Ctrl+Left/Right word-jump or Ctrl+Backspace delete-word** — minor editing convenience absent from `UiField`'s `KeyDown` switch entirely (not chat-specific, but the chat input is the surface a user would notice it on most).
---
## 4. Known no-ops and stubs (grepped, cited)
| Site | What's disabled |
|---|---|
| `src/AcDream.Runtime/Chat/ClientCommandId.cs:50-58` (`SetChatTitle`) + `RetailClientCommandCatalog.cs:258-269` (`SetTitle` definition) | `@title <text>` — retail sets the popup chat window's title bar (`ClientCommunicationSystem::DoTitle @0x0057A640`); acdream's binding "is a pure no-op (the value is neither stored nor consumed — no title-bar chrome exists to render it yet, AP-182)". Confirmed live: `FloatingChatWindowController.cs:204` hardcodes `$"Chat {windowId}"` with no seam for an external override. |
| `src/AcDream.App/Input/GameplayInputCommandController.cs:208-213` | `InputAction.ToggleChatEntry` (Tab, bound at `KeyBindings.cs:251`) — the switch case's own comment says "IDevToolsGameplayCommands.FocusChatInput() retired... Tab is still consumed here, matching the prior no-op's 'handled' contract." **Harmless**: `UiRoot.OnKeyDown` (§3) independently handles Tab-to-focus-chat before/alongside this path, so functionally nothing is lost — but the `InputAction`/keybind plumbing for it is dead weight that could mislead a future reader into thinking this is the live mechanism. |
| `src/AcDream.App/Input/GameplayInputCommandController.cs` (whole file) | `InputAction.EnterChatMode` (Enter, bound at `KeyBindings.cs:252`) has **no case at all** in `Handle`'s switch (`GameplayInputCommandController.cs:172-235`) — falls to `default: return false;`. Same "harmless because `UiRoot` does it independently" caveat as `ToggleChatEntry` above. |
| `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs:634-696` (`TryMatchAllegiance`) | 9 of 12 `@allegiance` subcommands (boot/ban/officer/title/motd/name/lock/house/chat/broadcast) unported — issue #360, still OPEN (see §5). |
| `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs:319-349` | `@day`/`@log`/`@render` recognized only by `/help <verb>`; execution falls through to server passthrough — issue #361, still OPEN (see §5). |
| `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | Entire `IPanel`-based class — see §6, dead code, not reachable from any production construction site. |
No other `deferred`/`TODO`/`stub`/`placeholder` hits inside `ChatWindowController.cs`, `FloatingChatWindowController.cs`, or the `Panels/Chat/` directory resolve to a genuine behavioral gap beyond what's listed above and in §5 — the remaining grep hits in those files are either doc-comment cross-references to *other* code's no-ops (e.g. `ChatWindowController.cs:230` explaining that the main filter is "not an inert no-op" — i.e. describing something that was FIXED) or historical narration.
---
## 5. Open issues — current code status
| Issue | One-line verdict | Evidence |
|---|---|---|
| **#358** Ctrl+M mute chord never fires | **STALE — DONE.** `KeyBindings.RetailDefaults()` now binds Ctrl+M (`KeyBindings.cs`, per the fix note); root cause (binding added to the dead `AcdreamCurrentDefaults()` table) is fixed. Live-client verification of the actual mute effect was still owed at closure time — UNKNOWN whether that connected check ever ran. |
| **#359** `0x019E` PlayerKilled prints to participants | **STILL OPEN.** `ChatLog.OnPlayerKilled` (`ChatLog.cs:188-206`) appends the death message unconditionally for every recipient — no `player_id == victim \|\| player_id == killer` guard exists anywhere in the method or its call site. |
| **#360** `@allegiance`/`@house` only port simple subcommands | **STILL OPEN.** `RetailClientCommandCatalog.TryMatchAllegiance` (`RetailClientCommandCatalog.cs:656-696`) only recognizes `hometown`/`ho` and `info`; every other subcommand falls to `AllegianceUnrecognizedSubcommand`'s refusal text (`:689-695`). House subcommands correctly passthrough to ACE per the same file's `TryMatchHouse` comments (`:643`), but neither dispatcher executes the ~22 unported subcommands locally. |
| **#361** `@day`/`@log`/`@render` recognized in help only | **STILL OPEN.** `RetailCommandHelpTable.cs:319-349` still carries the "NOT YET IMPLEMENTED in acdream" meta-tail for all three; `RetailClientCommandCatalog` has no `Day`/`Log`/`Render` client-command definitions with real handlers (only chat verbs actually wired execute; these three fall through to server passthrough, which is a silent no-op against ACE). |
| **#362** Four CH4 outbound requests had no inbound handler | **STALE — DONE**, closed 2026-08-09 (`ClientCommandResponses.cs` parses `ChannelIndex`/`ChannelList`/`AvailableHouses`/`AllegianceInfoResponse`). |
| **#363** Refusal sites typed `0x00` where retail types `0x1A` | **STALE — CLOSED 2026-08-10.** `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (`ChatVM.cs:159-183`) exists and is wired at `InteractionRetainedUiComposition.cs:472-473`; `ChatCommandRouter` routes every named site through it (confirmed at `ChatCommandRouter.cs:85-98,119-121,154,162`). |
| **#366** New-unseen-text indicator (`0x1000048C`) imports but unwired | **STILL OPEN** (narrowed 2026-08-16 — the build/import half is fixed, the behavior half — what triggers it, what a click does — remains un-researched). No controller code references `0x1000048C` in `ChatWindowController.cs`. |
| **#367** Local-presentation fallbacks land in chat scroll, not SpewBox | **STALE — CLOSED 2026-08-10**, closed as a side effect of #363 (same seam). |
| **#369** Unconfirmed whether floaty windows share the main window's talk-focus channel | **STILL OPEN, unresearched.** `FloatingChatWindowController.cs:157` hardcodes Say; whether that's retail-correct is not established either way — filed as a research task, not yet picked up. |
| **#372** Options panel Character/Chat/Config tabs render blank | **STALE — DONE** (blank-tabs half fixed at the `UiTemplateListBox` viewport-anchor level, not chat-specific; the tangential "13 Chat-tab filter labels resolve blank" sub-note was fixed by the `FilterStringTableId = 0x2300000D` correction visible at `ChatOptionsPageController.cs:96-105`). |
| **#379** Chat opacity applied to all windows, not just chat | **STALE — DONE**, `RetailWindowOpacityController` now scoped to the 5 chat windows only. |
| **#380** Chat tab opacity sliders missing row captions | **STALE — DONE**, `ChatOptionsPageController.cs:399-407,473-503` wires `SetOpacityCaption` from `ChatOptionsDatCaptions`. |
| **#382** Floating-window indicator buttons invisible until hovered | **STALE — DONE**, `UiButton.TrySetRetailState` fix (unrelated file, general `UiButton` bug that happened to be discovered via the chat indicators). |
**Net: of the 12 chat-tagged issues checked, 4 remain genuinely open in code (#359, #360, #361, #366) plus one unresearched design question (#369).** The rest closed since the digest's 2026-08-09/10 snapshot but the digest's own "Open" section (still listing #358/#359/#360/#361/#362/#363) is now stale for #358/#362/#363 — worth a digest refresh independent of this audit.
---
## 6. Test coverage
**Well covered** (direct, behavior-level tests exist):
- `ChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs` — bind success/failure, talk-focus specials (Squelch/Tell-to-selected), transcript parent/mode, transcript layout caching + out-of-range LogTextType fallback, input submit → `SendChatCmd`, channel-change updates submit channel, input-field resize/reflow (both with and without an imported `LayoutPolicy`), indicator open/closed/cross-window-isolation/out-of-range.
- `FloatingChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs` — bind success/failure/invalid-window-id, transcript parent, input parent (floaty row vs main bar), input always-Say, per-window filter subset + filter-change reflection + cache reuse.
- `ChatWindowState`: has its own filter/open/`ShouldDisplay` logic covered by construction (not independently verified in this pass, but the class is simple enough that the controller tests above exercise it transitively).
- `UiField`: `tests/AcDream.App.Tests/UI/UiFieldTests.cs` — insert/caret, backspace, submit/clear/history-push, empty-submit no-op, history up/down, history 100-cap, two multi-line-after-shrink regression tests (the 2026-07-29 crash class), character filter, select-all-on-focus, read-only field, multi-line Enter-inserts-newline.
- Command routing: `ChatCommandRouterTests.cs`, `ChatInputParserTests.cs`, `ChatInputParserAtPrefixTests.cs`, `RetailClientCommandCatalogTests.cs`, `RetailCommandHelpTableTests.cs`, `RetailCommandRegistryConformanceTests.cs` (bidirectional ownership-rule enforcement across the whole 152-verb registry).
- Colors: `RetailChatColorTableTests.cs`.
**Gaps found — user-visible behaviors with zero automated coverage:**
1. **`ChatWindowController.ToggleMaximize`** — no test anywhere calls it or exercises `CaptureWindowState`/`RestoreWindowState`. Grep for `ToggleMaximize`/`Maximiz` across `tests/` returns nothing. The growUp/clamp/DAT-constraint logic (`ChatWindowController.cs:537-580`, a direct port of `gmMainChatUI::HandleMaximizeButton`) is entirely unverified by automation — a regression here would only be caught by a human clicking the max/min button.
2. **Floating window's Close button** — no test exercises `FloatingChatWindowController.cs:211-214`'s `OnClick` wiring (`WindowHandle?.Hide()`). Grep for `CloseButton` in `FloatingChatWindowControllerTests.cs` returns nothing.
3. **`UiField` Escape handling** (or lack thereof — see §3 finding 1) — no test exists for Escape at all in `UiFieldTests.cs`; the absence of behavior is untested, meaning it could silently "start working" or silently regress further with no signal either way.
4. **`UiField` clipboard (Ctrl+C/X/V) and Shift-selection** — none of `UiFieldTests.cs`'s 13 tests exercise `CopySelection`/`CutSelection`/`Paste`/Shift+arrow extension. `Selectable`-gating (§3) is only indirectly confirmed via the DAT-fixture conformance test (`ChatLayoutConformanceTests.cs:220-228`), which checks the *property resolves true*, not that copy/cut/select-all *actually work* once it's true.
5. **`ChatPanel.cs` and its whole test suite are exercising dead code.** `ChatPanel` (`src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`) implements the old ImGui-era `IPanel` contract from the D.2a stack. `AcDream.UI.ImGui` no longer exists as a project (deleted at Campaign V slice V11 per `CLAUDE.md`), and a repo-wide grep for `new ChatPanel(` finds only the class's own constructor declaration — **no production code anywhere constructs a `ChatPanel`.** Its five test files (`ChatPanelFocusTests.cs`, `ChatPanelInputTests.cs`, `ChatPanelLayoutTests.cs`, plus the shared `ChatVMCombatTests.cs`/`ChatVMLastTellSenderTests.cs`/`ChatVMRetellAndProvidersTests.cs` that exercise `ChatVM` directly and remain legitimately live) still compile and pass, which gives a false impression of "chat input is covered" in a naive test-count read — the REAL live input surface is `UiField` + `ChatWindowController`, covered separately (and less deeply, per findings 1-4 above). This is worth flagging to whoever next touches chat tests: `ChatPanel.cs` and its three panel-specific test files are candidates for deletion (dead code, not a functioning fallback), not maintenance targets.
---
## 7. Prioritized gap list (most user-visible first)
This is the ordering to plan slices from — judgment calls, not a flat dump.
1. **Escape does nothing in the chat input (§3 finding 1).** Every retail player's muscle memory includes "Escape backs out of whatever I'm doing," and chat is the single most-used text-entry surface in the client. Right now it's a dead key while typing — worse than doing nothing wrong, because it silently swallows an action a user expects to work (defocus/clear), and if `WorldKeyFallThrough` were ever wired for something else, it would ALSO be swallowed by the exact-focus branch that already fails to handle it. This is a real, previously-untracked bug (no ISSUES.md entry), high frequency of exposure, small fix surface (add an Escape case to `UiField.OnEvent`'s `KeyDown` switch, decide clear-vs-defocus-vs-both against retail).
2. **#360`@allegiance`/`@house` management subcommands (22 of them unported).** Highest-traffic gap by command surface area; already tracked, already scoped ("largest single item; deserves its own slice" per the issue's own text), needs byte-level wire verification before implementation (target-name/guid resolution, confirmation dialogs, multi-field payloads) rather than guessing.
3. **#359 — PlayerKilled line double-prints for the victim/killer.** Small, well-scoped, single-method fix (`ChatLog.OnPlayerKilled` needs the local-player-guid participant check) with a clear retail citation already in the issue. Low effort, directly visible to anyone who dies or gets a kill in acdream.
4. **#369 — floaty-window channel-sharing research.** Currently a design assumption (Say-always) shipped without verification. Low implementation cost either way once researched, but the research itself (`gmCCommunicationSystem`'s floaty send path) hasn't started. Worth resolving before more chat work builds on the current assumption.
5. **`ToggleMaximize`/Close-button test coverage gap (§6.1/6.2).** Not a behavior bug — both features work per the code reading — but zero automated coverage on two interactive, DAT-geometry-dependent code paths (max/min clamping, close-then-reopen) is a latent regression risk given how much chat-adjacent layout churn this codebase has had (8+ chat-parity review rounds in the last two weeks alone).
6. **#366 — new-unseen-text indicator inert.** Cosmetic/discoverability only; retail's exact trigger condition is still unresearched, so this can't be fixed correctly without that research first, and its absence doesn't block any other chat behavior.
7. **#361`@day`/`@log`/`@render`.** Genuinely low-value: `@day` needs a renderer hook that doesn't exist yet (bigger than a chat fix), `@log` was deliberately deferred (file-handle lifecycle risk across reconnects), `@render` has no acdream render-option surface to bind to. Correctly the lowest priority of the open command-registry gaps.
8. **`@title` no-op + hardcoded floaty titles (§4).** Cosmetic, single command, no other feature depends on it. Fine to bundle with a future title-bar-chrome pass rather than a standalone fix.
9. **`ChatPanel.cs` dead-code cleanup (§6.5).** Not a behavior gap at all — it's hygiene. Flagging here rather than fixing inline per this audit's report-only scope; worth a small follow-up to delete the class and its now-misleading test files so future coverage audits don't need to re-discover this.
10. **Missing autocomplete / word-jump editing conveniences (§3 findings 2, 4).** Lowest priority: unconfirmed whether retail even had these, and even if it did, they're minor efficiency features, not correctness or discoverability gaps.
---
## Appendix: files read for this audit
- `src/AcDream.App/UI/Layout/ChatWindowController.cs`
- `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs`
- `src/AcDream.Core/Chat/ChatWindowState.cs`
- `src/AcDream.Core/Chat/ChatLog.cs`
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`
- `src/AcDream.Runtime/Chat/ChatCommandRouter.cs`
- `src/AcDream.Runtime/Chat/ClientCommandId.cs`
- `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs` (partial)
- `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs` (partial, grep-targeted)
- `src/AcDream.App/UI/UiField.cs`
- `src/AcDream.App/UI/UiMenu.cs` (partial)
- `src/AcDream.App/UI/UiRoot.cs` (partial — key dispatch + focus)
- `src/AcDream.UI.Abstractions/Input/InputDispatcher.cs` (partial)
- `src/AcDream.App/Input/GameplayInputCommandController.cs` (partial)
- `src/AcDream.App/Input/InputCaptureSources.cs` (partial)
- `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs`
- `src/AcDream.App/UI/RetailUiRuntime.cs` (partial — persistence + mount)
- `src/AcDream.App/UI/WindowNames.cs`
- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (partial)
- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (partial — Type-12 field build)
- `tests/AcDream.App.Tests/UI/UiFieldTests.cs`
- `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`
- `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs`
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` (partial)
- `docs/ISSUES.md` (targeted sections: #358-#382 chat-tagged range)
- `docs/architecture/retail-divergence-register.md` (targeted: AP-185 through AP-191)
- `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_chat_digest.md`

View file

@ -0,0 +1,376 @@
# acdream text-stack audit: seam for retail glyph-level tagged text
**Scope.** Research only — OUR codebase (`src/AcDream.App/UI/**`,
`src/AcDream.UI.Abstractions/Panels/Chat/**`, `src/AcDream.Core/Chat/**`).
Goal: determine what it takes to support retail's glyph-level tagged
text (a differently-colored, clickable name inside an otherwise
uniformly-colored chat line). No code changes made.
## 1. Current model — what is a rendered text line?
`UiText` (`src/AcDream.App/UI/UiText.cs`) is the one retained-UI text
widget (`RegisterElementClass(0xc)`, class doc at `UiText.cs:10-22`).
It has **two** display-line shapes, both single-color:
- **`Line`** — `UiText.cs:50`:
`public readonly record struct Line(string Text, Vector4 Color);`
One string, one `Vector4` color for the WHOLE string. This is what
`LinesProvider` (`UiText.cs:62`, `Func<IReadOnlyList<Line>>`) returns
and what the scrollable multi-line transcript path renders
(`DrawClippedText`, `UiText.cs:636-691`). Color is **per-line**, not
per-run: `lines[i].Color` (`UiText.cs:673`/`677`) is one value passed
whole to `ctx.DrawStringDatPass`/`ctx.DrawString`.
- **`TextRun`** — `UiText.cs:55`:
`public readonly record struct TextRun(string Text, Vector4 Color);`
Multiple colored fragments concatenated onto **one** authored line,
fed by `RunsProvider` (`UiText.cs:69`,
`Func<IReadOnlyList<TextRun>>?`) and drawn by `DrawSingleLineRuns`
(`UiText.cs:693-754`). This is real per-run coloring — each run gets
its own `ctx.DrawStringDatPass` call at its own pen X
(`UiText.cs:725-743`) — but it is **only reachable when
`OneLine == true`** (`UiText.cs:506-510`: `if (OneLine &&
RunsProvider is { } runsProvider)`), i.e. the static single-line
label path. The chat transcript is NOT `OneLine` (`ChatWindowController.cs:320`:
`c.Transcript.OneLine = false;`), so it can never reach
`DrawSingleLineRuns` — the scrollable multi-line path only ever
reads `LinesProvider`/`Line`.
**Answer to Q1:** color today is per-`Line` in the transcript
(scrollable, multi-line, bottom-pinned/word-wrapped) path, and
per-`TextRun` only in the unrelated static single-line label path
(currently used by exactly one controller — see §5/§6). Chat uses the
former exclusively.
## 2. Where the flattening happens (the key finding)
`ChatEntry` (`src/AcDream.Core/Chat/ChatLog.cs:499-536`) is a
structured record: `Sender` (string), `SenderGuid` (uint), `Text`,
`ChannelId`/`ChannelName`, `Kind`, `LogTextType`. The sender's identity
survives as a distinct field all the way through `ChatLog`.
It is destroyed in **two** steps inside `ChatVM`
(`src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`), and the second
step is the point of no return:
**Step A — string composition.** `ChatVM.FormatEntry`
(`ChatVM.cs:262-313`) string-interpolates `entry.Sender` directly into
the message prose, e.g. for `ChatKind.LocalSpeech`
(`ChatVM.cs:269-271`):
```
ChatKind.LocalSpeech => IsOwnSpeaker(entry.Sender)
? $"You say, \"{entry.Text}\""
: $"{entry.Sender} says, \"{entry.Text}\"",
```
After this call the sender name is prose inside one `string`; there is
no longer a machine-readable boundary marking where "Name" ends and
"says, ..." begins.
**Step B — metadata drop (the actual point of no return).**
`ChatVM.RecentLinesDetailed()` (`ChatVM.cs:345-371`) builds the
`FormattedLine` record (`ChatVM.cs:384-388`):
```
public readonly record struct FormattedLine(
string Text,
ChatKind Kind,
CombatLineKind? CombatKind,
uint LogTextType);
```
`FormattedLine` does **not** carry `Sender` or `SenderGuid` at all —
only the composed `Text`, `Kind`, `CombatKind`, and the retail color
key `LogTextType`. Every downstream consumer
(`ChatWindowController.GetTranscriptLines`, `ChatWindowController.cs:736-781`,
which calls `vm.RecentLinesDetailed()` at `ChatWindowController.cs:753`,
then `ChatTranscriptRenderer.BuildLines`,
`src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs:67-95`) only ever
sees the flat `Text` string plus one `LogTextType` per entry.
`ChatTranscriptRenderer.BuildLines` then assigns exactly **one**
`Vector4 currentColor` per entry (resolved once from `LogTextType` at
`ChatTranscriptRenderer.cs:89`) and stamps every word-wrapped fragment
of that entry with that single color (`ChatTranscriptRenderer.cs:90-93`):
```
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
currentColor = resolved;
foreach (string frag in WrapText(d.Text, maxW, measure))
result.Add(new UiText.Line(frag, currentColor));
```
**So: the sender's identity (name + guid) is available up through
`ChatLog`/`ChatEntry`, is baked into prose by `ChatVM.FormatEntry`, and
is then dropped entirely — not merely flattened, but discarded — by
`ChatVM.RecentLinesDetailed`'s `FormattedLine` shape.** By the time a
`UiText.Line` exists, there is no span boundary, no guid, and (because
word-wrap has already run) not even a guarantee that "the sender name"
is wholly contained within a single rendered `Line` if it happened to
sit at a wrap boundary. Any attempt to recover "where is the name"
downstream of this point would have to regex/string-match the composed
prose back apart — fragile (a message body containing the speaker's
own name, or a name that is a prefix of a common word, breaks it) and
still has no guid to attach for the click action.
## 3. Draw path
**One call per line/run, never per-glyph-color-batch beyond that.**
The scrollable path (`DrawClippedText`, `UiText.cs:636-691`) issues one
`ctx.DrawStringDatPass(datFont, text, x, y, color, isOutlinePass)`
(`UiText.cs:687-689`) per visible `Line` (whole wrapped fragment, one
color). The DAT-font backend (`UiRenderContext.DrawStringDatPass`,
`src/AcDream.App/UI/UiRenderContext.cs:320-367`) walks every glyph in
that ONE string with ONE `tint` (`UiRenderContext.cs:321`, `tint`
passed once, applied per-glyph at `UiRenderContext.cs:359-362`) — there
is no per-glyph or per-substring color inside a single
`DrawStringDatPass` call.
**Drawing N differently-colored runs on one visual line requires N
separate `DrawStringDatPass` calls, each starting at its own pen X** —
this is exactly the mechanism `DrawSingleLineRuns`
(`UiText.cs:693-754`) already uses: it measures each run's width
(`datFont.MeasureWidth(run.Text)`, `UiText.cs:709`/`736`), accumulates
a `penX` (`UiText.cs:731-737`), and issues one
`ctx.DrawStringDatPass(datFont, run.Text, run.X, y, run.Color,
isOutlinePass: false)` per run (`UiText.cs:742-743`). The outline pass
is batched block-wide first (all runs' outlines, then all runs' fills —
`UiText.cs:739-743`, same reasoning as the multi-line block-batching
documented at `UiRenderContext.cs:306-318`) so a per-run outline
doesn't notch an adjacent run's descender.
**Yes, the DAT font path supports starting a draw at an arbitrary X
offset and measuring a substring's width.** `UiDatFont.MeasureWidth(string
text)` (`src/AcDream.App/UI/UiDatFont.cs:160-172`) sums per-glyph
advances for any string/substring — already used for substring
measurement in the selection-highlight code
(`UiText.cs:656-657`/`661-662`, `datFont.MeasureWidth(text.Substring(0,
c0))`). `DrawStringDatPass`/`DrawStringDat` take an arbitrary `float x`
(`UiRenderContext.cs:277-278`, `320-321`) with no assumption it starts
at the element's left edge. The bitmap-font fallback (`BitmapFont.cs`,
`MeasureWidth` at `BitmapFont.cs:167`) and `UiRenderContext.DrawString`
(`UiRenderContext.cs:188-203`, also takes an arbitrary `float x`) mirror
the same capability. **Conclusion: the low-level draw primitives
already support everything a run-based transcript line needs — no
renderer/font work is required, only a widget-level model change to
call them N times instead of once.**
## 4. Hit-testing
**No sub-line hit-testing exists today; click/hover route to whole
elements, never to a text span.** `UiRoot.HitTestTopDown`
(`src/AcDream.App/UI/UiRoot.cs:1410-1430`) walks the retained tree via
`UiElement.HitTest` (`src/AcDream.App/UI/UiElement.cs:705-731`), which
recurses into children and, failing that, calls the virtual
`OnHitTest(localX, localY)` (`UiElement.cs:563-564`, default is a
rectangle containment check) — the granularity is always "some
`UiElement`", never "some substring of a `UiElement`'s text." A
resolved hit becomes a `Click` `UiEvent` at `UiRoot.OnMouseUp`
(around `UiRoot.cs:994-997`) and bubbles via
`UiRoot.BubbleEvent`/`UiElement.OnEvent` (`UiRoot.cs:1516-1525`,
`UiElement.cs:571`). `UiText.OnEvent`'s `Click` case
(`UiText.cs:809-813`) fires the single `OnClick` delegate for the
WHOLE element — there is no notion of "which run was clicked."
**The pieces needed already exist, just not wired to `Click`.**
`UiText.HitChar(float localX, float localY)` (`UiText.cs:1031-1050`)
already converts a local point into a `Pos(line, col)` caret position
using the cached draw geometry (`_lastLines`/`_lastBaseY`/
`_lastLineHeight`, `UiText.cs:268-273`) and a per-character advance
lookup (`UiText.cs:1042-1048`, works for both `UiDatFont` and
`BitmapFont`) — but it is currently invoked only from the
selection-drag path (`MouseDown`/`MouseMove` cases, `UiText.cs:826-847`),
gated behind `Selectable` (`UiText.cs:828`, `839`). The chat transcript
IS `Selectable = true` (`ChatWindowController.cs:321`), so `HitChar`
already runs on every mouse-down inside the transcript — it is simply
never asked "which run (if any) covers this `(line, col)`", because
`Line` carries no runs to check against.
**What mapping a click to a run would need:**
1. A per-line list of run boundaries (start col, end col, and a
payload — e.g. sender guid) reaching `UiText` alongside the text,
which does not exist today (`Line` has no such field, see §1/§2).
2. `HitChar`'s existing `(line, col)` result checked against that
list — this is a small, local addition to `UiText`, not a new
hit-test mechanism.
3. A dispatch from "run payload resolved" to an actual action (e.g.
pre-filling a `/tell <name>` in the chat input) — analogous to the
existing `OnClick` delegate, but keyed by run rather than by
element.
No retail-side click semantics (what a name-click does) were
researched here — that is a different agent's lane per the task brief.
## 5. Seam proposal
**Given Code Structure Rules (CLAUDE.md "Code Structure Rules" §1-3):**
- `AcDream.Core` must not depend on window/GL/backend projects (rule 2)
`ChatEntry`/`ChatLog` (Core) can carry the STRUCTURED data a tagged
run needs (sender name + guid + explicit text-span boundaries) but
must not know about `Vector4`/GL/rendering.
- UI panels target `AcDream.UI.Abstractions` only (rule 3) — the
composition of "structured entry -> ordered list of colored,
optionally-actionable spans" is exactly the kind of pure formatting
logic `ChatVM` already owns (`ChatVM.FormatEntry`/
`RecentLinesDetailed`, `ChatVM.cs:262-371`) and should keep owning;
it must not reach into `AcDream.App` (GL/rendering) types.
- The retained-widget rendering (`AcDream.App/UI/UiText.cs`) is where
GL-adjacent draw calls (`DrawStringDatPass`) and Silk.NET-adjacent
hit-testing (`HitChar`, `UiRoot`) live, and must stay there.
**Proposed layering (three seams, one per project boundary):**
1. **`AcDream.UI.Abstractions` (data shape)** — introduce a
run-carrying line shape parallel to (not replacing) `FormattedLine`.
Sketch: `FormattedLine` gains an optional ordered list of spans, or
a new `RichFormattedLine(IReadOnlyList<FormattedRun> Runs, ...)` is
added, where `FormattedRun` is something like
`(string Text, uint? ActorGuid, bool IsSpeakerName)` — deliberately
NOT carrying a color yet (`AcDream.UI.Abstractions` has no
`System.Numerics`/GL dependency requirement today, but keeping
color resolution in `AcDream.App` mirrors the existing
`RetailChatColorTable`/`ChatTranscriptRenderer` split, where
`ChatVM` supplies `LogTextType`/structured data and
`ChatTranscriptRenderer` in `AcDream.App` resolves it to `Vector4`).
`ChatVM.FormatEntry` (`ChatVM.cs:262-313`) would need a sibling that
returns spans instead of one interpolated string — e.g. split each
`case` into "prefix run" / "sender run" / "suffix run" instead of a
single `$"..."` — and `RecentLinesDetailed` would carry
`entry.Sender`/`entry.SenderGuid` through instead of discarding them
(the §2 fix).
2. **`AcDream.App/UI/Layout` (composition)** — `ChatTranscriptRenderer.BuildLines`
(`ChatTranscriptRenderer.cs:67-95`) is the existing per-controller-
shared seam that already resolves `LogTextType -> Vector4` and
word-wraps. It would gain a variant that word-wraps a RUN LIST
instead of a flat string per entry, producing a new "rendered line
with runs" shape (see below) instead of `UiText.Line`. Both
`ChatWindowController` (`ChatWindowController.cs:778`) and
`FloatingChatWindowController` (same shared function, per the class
doc at `ChatTranscriptRenderer.cs:9-15`) would switch to the new
builder — this is the one place both chat surfaces already share,
so it is the natural single point of change for chat specifically.
3. **`AcDream.App/UI/UiText.cs` (widget)** — this is where the actual
gap is. `Line` needs an additive `Runs` concept for the
MULTI-LINE (`OneLine == false`) path, not just the existing
`OneLine`+`TextRun` path (§1). Minimal shape: extend `Line` (or add
a parallel `RichLine`) to carry `IReadOnlyList<TextRun>` alongside
or instead of a flat `string Text` + single `Vector4 Color`; change
`DrawClippedText`'s multi-line loop (`UiText.cs:636-691`) to, for a
line with runs, do what `DrawSingleLineRuns` already does per-run
(walk runs, accumulate `penX`, call `DrawStringDatPass` per run,
batch all outlines-then-all-fills at the BLOCK level exactly as
`UiText.cs:681-690` already batches across LINES today — extending
that batching one level deeper, across runs within lines, is
mechanical). `HitChar` (`UiText.cs:1031-1050`) needs to additionally
resolve which run (if any) contains the hit `col`, and `OnEvent`'s
`Click` case (`UiText.cs:809-813`) needs a second dispatch path
(run-click, distinct from whole-element `OnClick`) that a controller
(e.g. `ChatWindowController`) can bind to "prefill a tell to this
guid," mirroring how `OnClick` is bound today.
**Existing abstraction that already almost does this:**
`TextRun`/`RunsProvider`/`DrawSingleLineRuns` (§1, `UiText.cs:55,69,693-754`)
is the closest precedent — it proves the draw-side mechanics (measure
run, accumulate pen, per-run `DrawStringDatPass`, block-batched
outline) already work and are exercised in production by
`CharacterStatController.BuildSelectedTitleRuns`
(`src/AcDream.App/UI/Layout/CharacterStatController.cs:1425-1454`,
wired at `CharacterStatController.cs:1680`) for a skill/attribute title
with a colored numeric delta suffix. It is currently scoped to
`OneLine` only and carries no click/actor payload — extending it to
the multi-line/wrapped path and adding a payload field is smaller than
building a new mechanism from scratch. `DatRichText`
(`src/AcDream.App/UI/Layout/DatRichText.cs`, `Segment(string? Text,
Vector4 Color)` at `DatRichText.cs:40`, `Compose` at
`DatRichText.cs:52-88`) is a second, partially-overlapping precedent:
it already composes multiple colored segments for a multi-line box,
but it word-wraps EACH segment independently and concatenates the
results as separate `Line`s (`DatRichText.cs:83-84`) — so two segments
that would visually share one wrapped row are NOT joined onto that row
today; it solves "multiple colors across a paragraph's several lines,"
not "multiple colors sharing one rendered row." A tagged-name-in-chat
feature needs the latter (the name and the rest of the sentence share
row 0 of a possibly-multi-row wrapped message), so neither existing
mechanism is a drop-in — both inform the shape of the fix.
## 6. Blast radius
`UiText.Line`/`LinesProvider` is used extremely broadly — 52 files
reference `LinesProvider = ` and 53 reference `UiText.Line(`/`new
UiText()` (full grep list retained below). **If the change is additive**
(new optional `Runs` field/type alongside the existing `Line`, default
behavior unchanged for every caller that keeps returning plain
`Line`s), the blast radius for BEHAVIOR is limited to whichever
controllers opt in (initially: chat only). The blast radius for
BUILD/COMPILE risk (anything that touches `UiText.cs`, `UiElement.cs`
recompiles the whole `AcDream.App` UI layer) and for REVIEW is the full
list below, grouped by category — every one of these should be
smoke-tested after a `UiText`-internal change even if it doesn't touch
their own code:
- **Chat (the actual feature target):**
`ChatWindowController.cs`, `FloatingChatWindowController.cs`,
`ChatTranscriptRenderer.cs`, `SpewBoxController.cs`
(`src/AcDream.App/UI/SpewBoxController.cs` — retail's other
colored-text-scroll surface, `RetailLogTextType.ClientLocal` per
`ChatVM.cs:159-183`; likely wants the SAME run model eventually since
it renders `LogTextType`-colored lines too).
- **Tooltips:** `RetailTooltipPresenter.cs` — world/UI hover tooltips;
currently plain `Line`s.
- **Appraisal / item & creature reports:** `AppraisalUiController.cs`,
`CreatureAppraisalRows.cs`, `ItemAppraisalReport.cs` — these already
render multi-colored informational text (spell names, damage types)
as SEPARATE `Line`s per colored fragment (one color per whole line,
not per run) — a run model could simplify these, or they could stay
as-is if row-granularity coloring already meets retail fidelity
there (not assessed here — out of this audit's scope).
- **Social panels:** `SocialSquelchPageController.cs`,
`SocialAllegiancePageController.cs`,
`SocialFellowshipPageController.cs`, `SocialFriendsPageController.cs`
— friends/allegiance/fellowship rows; per CLAUDE.md's Campaign FA
notes these already do per-state color swaps on `UiText`, a
different (not run-based) mechanism.
- **Vendor / trade:** `VendorUiController.cs`,
`SecureTradeUiController.cs`.
- **Combat / spellcasting:** `CombatUiController.cs`,
`SpellcastingUiController.cs`, `EffectsUiController.cs`.
- **Dialogs:** `RetailWaitDialogView.cs`, `RetailMessageDialogView.cs`,
`RetailConfirmationDialogView.cs`,
`RetailConfirmationTextInputDialogView.cs`.
- **Character sheet / creation:** `CharacterStatController.cs` (the
existing `TextRun` consumer, §5), `CharacterCreationSkillsPage.cs`,
`CharacterCreationSummaryPage.cs`, `CharacterCreationTownPage.cs`,
`CharacterCreationProfessionPage.cs`,
`CharacterCreationHeritagePage.cs`,
`CharacterManagementUiController.cs`.
- **Options / config:** `ConfigOptionsPageController.cs`,
`ChatOptionsPageController.cs`, `CharacterOptionsPageController.cs`,
`KeyboardConfigController.cs`.
- **Misc panels:** `InventoryController.cs`, `RadarController.cs`,
`MapPageController.cs`, `HousePageController.cs`,
`LinkStatusUiController.cs`, `VitaeUiController.cs`,
`VitalsController.cs`, `RetailFpsController.cs`,
`SelectedObjectController.cs`, `IndicatorDetailText.cs`,
`ComponentBookTemplateFactory.cs`, `EffectRowTemplateFactory.cs`,
`CharacterController.cs`, `DatWidgetFactory.cs` (the factory that
builds every `UiText` from LayoutDesc — touches all of the above by
construction).
- **Tests:** `UiTextTests.cs`,
`SocialFellowshipPageControllerTests.cs`,
`SocialPanelControllerTests.cs`, `RowTemplateResolverTests.cs`,
`DatWidgetFactoryTests.cs`, `CharacterStatControllerTests.cs`,
`VitalsBindingTests.cs`, `AppraisalUiControllerTests.cs` — any of
these that assert on `UiText.Line` shape/count would need review if
`Line`'s shape changes (not if a new type is added additively).
**Net:** an ADDITIVE seam (new run-carrying line type, existing `Line`
untouched) keeps the functional blast radius to chat (and optionally
SpewBox) while still requiring the whole `AcDream.App/UI` tree to
rebuild/retest since it all depends on `UiText.cs`/`UiElement.cs`. A
seam that changes `Line`'s existing shape would force a review pass
across every file in the list above.
## What this audit did NOT do
- Did not research retail's own tagged-glyph-run mechanism (separate
agent's lane per the task brief).
- Did not propose or write any code change — `Line`/`TextRun`/`Segment`
shapes above are illustrative sketches for sizing, not a spec.
- Did not assess whether `AppraisalUiController`/`CreatureAppraisalRows`'s
existing one-color-per-`Line` approach is already retail-faithful for
their own content (out of scope; flagged only as a blast-radius
member).

View file

@ -0,0 +1,543 @@
# Chat log click-to-tag dispatch (retail, Sept 2013 EoR build)
Research-only. Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`
(PDB-named pseudo-C) and `docs/research/named-retail/acclient.h` (verbatim
retail struct layouts). Every claim below is cited `symbol @ 0xADDRESS`.
Binary Ninja's rendering caveats (misleading compare idioms, ~33-char
truncated inline strings) are called out inline wherever they bit this
investigation.
## TL;DR
The suspected anchor `ChatInterface::SetReplyTextInChatBox @ 0x004F4760` is
**not** the click handler. It is a keyboard text-replacement macro
(`/t `, `/tell `, `reply ` + space → `@tell <LastTeller>,`) wired through
`ChatInterface::HandleTextReplacements @ 0x004F50D0`, itself fired from a
"character typed" UI broadcast, not a mouse event. It happens to share the
`"@tell %s, "` idea with the real click path but is a separate code path
with separate (looser) text.
The real click path is a generic, polymorphic **tag** system:
```
mouse button up over a UIElement_Text
→ UIElement_Text::MouseUp @ 0x004694F0
→ UIElement_Text::DeterminePositionFromXY @ 0x004688F0 (screen xy → glyph index)
→ GlyphList::InqGlyph @ 0x00473430 (glyph index → Glyph, incl. m_tag)
→ TextTag::HandleClick (virtual, vtable+0x14) (dispatch by tag TYPE)
TextTag_IIDString::HandleClick @ 0x00478840
→ ECM_UI::SendNotice_TextTag_IIDStringClick @ 0x006927C0
→ gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10
(gate: tag m_type == 0x10000001, chat entry not already focused)
→ ChatInterface::StartTell @ 0x004F41F0
(writes "@tell <Name>, " into the entry, focuses it)
```
Player/speaker names in chat are wrapped by the server/client text
formatters in a `<Tell:IIDString:<iid>:<name>>displayText<\Tell>` markup
span (note: closing marker is a **backslash**, `<\Tell>`, not a
forward-slash — confirmed from the raw literal at
`data_7d0bfc @ 0x007D0BFC` etc.). This markup is used for direct tells,
channel "says" lines, `[Fellowship]`, `[Co-Vassals]`, patron/vassal lines,
and (per the generic `[%ws] <Tell:IIDString:0:%ws>...` format at
`data_7e83e8 @ 0x007E83E8`) ordinary named-channel chat too — i.e. **every**
chat line that shows a speaker name embeds the same tag, not just tells.
Clicking any of them always opens a **tell**, regardless of which channel
the line came from.
---
## 1. Click → glyph → tag resolution
### 1a. Entry point: `UIElement_Text::MouseUp`
`UIElement_Text::MouseUp @ 0x004694F0` is registered directly in
`UIElement_Text`'s vtable slot for `MouseUp` (confirmed at the vtable dump,
e.g. `0079C1A4: MouseUp = UIElement_Text::MouseUp`). The relevant tail,
reached only when the mouse-up's button id was previously recorded as
mouse-down over this same element (`cond:0`, looked up in
`this->m_mouseDownTable` keyed by the button id `arg4`):
```c
// UIElement_Text::MouseUp @ 0x004694F0, tail (0x0046959C-0x004695DD)
if (eax_4 != 0) // cond:0 — this button's mouse-down WAS on this element
{
uint32_t eax_6 = UIElement_Text::DeterminePositionFromXY(this, ebp_2, edi_2);
Glyph var_24;
if (GlyphList::InqGlyph(&this->m_glyphList, eax_6, &var_24) != 0 && var_4 != 0)
*(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4); // var_4->HandleClick(arg4)
Glyph::~Glyph(&var_24);
}
```
`ebp_2`/`edi_2` are the mouse position converted to element-local,
margin-adjusted coordinates a few lines earlier in the same function:
```c
int32_t ebp_2 = ((arg2 - this->m_margL) - UIRegion::GetScreenX0(this));
int32_t edi_2 = ((arg3 - this->m_margU) - UIRegion::GetScreenY0(this));
```
`arg2`/`arg3` are the raw screen-space mouse coordinates passed down from
the UI event system; `m_margL`/`m_margU` are the element's left/top text
margins (`UIElement_Text` struct, `acclient.h:53412-53415`).
**BN caveat**: `var_4` (the pointer used for the `var_4 != 0` check and the
virtual call) is never shown being assigned in the decompiled output — it
is almost certainly `var_24.m_tag` after `GlyphList::InqGlyph` copies the
found `Glyph` into `var_24` via `Glyph::operator=`, but the copy-into-field
step is not visible in this rendering. This is exactly the kind of
"misleading compare idiom" the project's BN caveat warns about — flagged
rather than silently assumed. The surrounding evidence (struct layout,
vtable offset match below) makes this the only coherent reading, but it is
not a directly-visible assignment.
### 1b. Screen XY → glyph index: `UIElement_Text::DeterminePositionFromXY`
`UIElement_Text::DeterminePositionFromXY @ 0x004688F0`:
```c
int80_t UIElement_Text::DeterminePositionFromXY(this, arg2 /*local x*/, arg3 /*local y*/)
{
UIElement_Text::RecalculateGlyphList(this); // ensure wrapped-line layout is current
int32_t scrolledY = this->m_iScrollableY + arg3; // undo vertical scroll offset
uint32_t line = 0;
GlyphList::FindLineFromY(&this->m_glyphList, scrolledY, &line); // which wrapped line
uint32_t lineWidthPx = 0;
GlyphList::GetGlyphLineWidth(&this->m_glyphList, line, &lineWidthPx); // that line's pixel width
int32_t lineLocalX = (this->m_iScrollableX + arg2)
- UIElement_Text::CalcJustification(this, lineWidthPx, 1); // undo h-scroll + justification
uint32_t glyphIndex = 0;
GlyphList::FindPosFromLineAndPixels(&this->m_glyphList, line, lineLocalX, 1, &glyphIndex);
// clamp to end-of-text
return min(glyphIndex, this->m_glyphList.m_glyphList._num_elements);
}
```
Plain-language: convert the click's local (x, y) into a *scrolled* position
by adding back however far the text view has been scrolled; use the
scrolled Y to find which **wrapped display line** was clicked
(`GlyphList::FindLineFromY @ 0x00472770`); measure that line's pixel width
(`GlyphList::GetGlyphLineWidth @ 0x00472930`) so the justification offset
(left/center/right alignment, `UIElement_Text::CalcJustification @
0x00467260`) can be subtracted back out of the X; then walk that line's
glyph advances to find which **character index** the X pixel falls on
(`GlyphList::FindPosFromLineAndPixels @ 0x004732D0`). The result is a
single integer: "the click landed on/before character N of the full
(unwrapped) text buffer."
### 1c. Glyph index → Glyph (and its tag): `GlyphList::InqGlyph`
`GlyphList::InqGlyph @ 0x00473430`:
```c
uint8_t GlyphList::InqGlyph(this, arg2 /*index*/, arg3 /*out Glyph*/)
{
ListNode<Glyph>* node = this->m_glyphList._head;
if (node == 0 || arg2 >= this->m_glyphList._num_elements) return 0;
for (int i = 0; i != arg2; i++) { node = node->next; if (node == 0) return 0; }
Glyph::operator=(arg3, node); // copy the whole Glyph struct, incl. m_tag
return 1;
}
```
A straight O(n) linked-list walk (the glyph list is a `List<Glyph>`, not an
array) to the Nth glyph, then a struct copy. The `Glyph` layout
(`acclient.h:45330-45338`):
```c
struct __cppobj Glyph
{
unsigned __int16 m_data; // the character code
int m_width;
int m_height;
RGBAColor m_color; // per-glyph color, baked in at append time (see §4)
Font *m_font;
TextTag *m_tag; // non-null only for glyphs inside a <...> tag span
};
```
`m_tag` is set by `Glyph::SetTag @ 0x00474920` while the glyph list is
built from raw text (see §1d), and cleared in bulk by
`GlyphList::RemoveTextTag @ 0x00472BB0` (walks every glyph, clears any
whose `m_tag` matches the tag being removed — used when a tagged span is
truncated/deleted from the log).
### 1d. Building tags from markup: `TextTagFactory::MakeTag`
Text is appended to a `UIElement_Text` glyph list via
`UIElement_Text::InqGlyphs @ 0x00468EA0`. Its char-scan loop treats `<`
(0x3C) as the start of a tag span: it accumulates characters up to the
matching `>` (0x3E) into a string, then calls
`TextTagFactory::MakeTag @ 0x00478480` on that whole inner string (e.g.
`"Tell:IIDString:1234:PlayerName"`):
```c
TextTag* TextTagFactory::MakeTag(PStringBase<unsigned short> const* tagBody)
{
// tagBody looks like "TypeName:ShapeName:payload..."
if (!FindChar(tagBody, ':')) return 0;
typeNameStr = substring-before-first-colon;
if (EnumMapper::InqEnum(0x18 /*category*/, typeNameStr, &tagType) == 0) return 0; // e.g. "Tell" -> 0x10000001
if (!FindChar(rest, ':')) return 0;
shapeNameStr = substring-before-second-colon; // e.g. "IIDString"
if (EnumMapper::InqEnum(0x18, shapeNameStr, &shapeId) == 0) return 0; // 1..4
switch (shapeId) {
case 1: result = new TextTag_DID(); break;
case 2: result = new TextTag_IID(); break;
case 3: result = new TextTag_IIDEnum(); break;
case 4: result = new TextTag_IIDString(); break;
}
result->m_type = tagType; // from the FIRST lookup ("Tell" -> 0x10000001)
result->m_format = shapeId;
result->ParseStartTag(remaining payload); // shape-specific: fills TextTag_IIDString::m_IID/m_string, etc.
return result;
}
```
(Full disassembly at `docs/research/named-retail/acclient_2013_pseudo_c.txt:132871-133065`;
the two-colon split and the two `EnumMapper::InqEnum(..., 0x18, ...)` calls
are visible at `0x004784E6-0x00478545` and `0x00478577-0x004785DA`.) The
resulting `TextTag*` is stashed on every `Glyph` inside the span via
`Glyph::SetTag @ 0x00474920` as `InqGlyphs` walks the display characters
between the tag's `>` and its closing `<\...>`.
**UNKNOWN — needs a DAT/cdb dump**: `EnumMapper::InqEnum`'s category `0x18`
is a data-driven (DAT-resident, likely `client_portal.dat` StringTable/
EnumMapper resource) name↔id table — the code only proves the *mechanism*,
not the full roster of type-name strings it accepts. We confirmed "Tell"
(→ `m_type == 0x10000001`) and "IIDString" (→ shape id 4, inferred — see
§ "Other tag types" below) from literal format strings elsewhere in the
binary, but did not independently dump the table itself.
### 1e. Vtable-offset proof that `var_4` is the tag and `+0x14` is `HandleClick`
The `TextTag` vtable layout, read straight from the four subclass vtable
dumps (`docs/research/named-retail/acclient_2013_pseudo_c.txt:959321-959368`):
| offset | slot | `TextTag_DID` | `TextTag_IIDString` | `TextTag_IIDEnum` | `TextTag_IID` |
|---|---|---|---|---|---|
| 0x00 | `__vecDelDtor` | ✓ | ✓ | ✓ | ✓ |
| 0x04 | `ParseEndTag` | `TextTag::ParseEndTag` (shared) | shared | shared | shared |
| 0x08 | `ParseStartTag` | own | own | own | own |
| 0x0C | `BuildEndTag` | `TextTag::BuildEndTag` (shared) | shared | shared | shared |
| 0x10 | `BuildStartTag` | `TextTag::BuildStartTag` (shared) | shared | shared | shared |
| **0x14** | **`HandleClick`** | **own** | **own** | **own** | **own** |
| 0x18 | `BuildStartTagData` | own | own | own | own |
`+0x14` is exactly `HandleClick`, confirming `MouseUp`'s
`*(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4)` is `var_4->HandleClick(arg4)`
— a virtual call, i.e. this is dispatched **per concrete tag subclass**,
not a single hardcoded action.
---
## 2. What fires on click: generic dispatch, not hardcoded to tells
All four subclasses' `HandleClick` do the same shape of thing — forward to
a global "notice" (AC's internal pub/sub event system) carrying the tag's
type + payload, nothing else:
```c
// TextTag_DID::HandleClick @ 0x00478740
ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id); // @ 0x006926D0
// TextTag_IID::HandleClick @ 0x00478E80
ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID); // @ 0x00692720
// TextTag_IIDEnum::HandleClick @ 0x00478B40
ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum); // @ 0x00692770
// TextTag_IIDString::HandleClick @ 0x00478840
ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string); // @ 0x006927C0
```
So the dispatch **is** generic — any listener can register for any of the
four notices and react to any `m_type`. In this build, though, only ONE of
the four notices has an actual (non-stub) listener anywhere in the client:
- `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` — real,
wired to chat's click-to-tell (see §3).
- `NoticeHandler::RecvNotice_TextTag_IIDEnumClick @ 0x006A0240` is declared
`__pure` — a pure-virtual stub, no base behavior, and no override for it
was found anywhere in this pass.
- No function definition for a real `RecvNotice_TextTag_DIDClick` or
`RecvNotice_TextTag_IIDClick` override exists anywhere in the pseudo-C
file either — every other hit for those names is vtable-slot noise (the
decompiler filling unresolved thunk slots with neighboring symbol names;
cross-checked, none are real function bodies with those signatures).
**Conclusion**: the mechanism is generic (4 tag shapes × arbitrary
`m_type` values × arbitrary listeners), but in this Sept 2013 build only
the chat-log "clickable speaker name → start a tell" feature is actually
wired up end-to-end. `TextTag_DID`/`TextTag_IID`/`TextTag_IIDEnum` exist,
parse, and would dispatch correctly if clicked, but nothing in the client
reacts to their click notices — **UNKNOWN whether item links / URLs /
coordinates use these shapes in later builds or via server-composed text
we didn't grep for**; no evidence of them was found in this pass.
### Other tag types found (full roster)
| Class | Shape id (inferred) | Ctor | `HandleClick` | Real listener found? |
|---|---|---|---|---|
| `TextTag_DID` | 1 | `0x00478760` | `0x00478740` | No |
| `TextTag_IID` | 2 | `0x00478E60` | `0x00478E80` | No |
| `TextTag_IIDEnum` | 3 | `0x00478B20` | `0x00478B40` | No |
| `TextTag_IIDString` | 4 | `0x00478860` | `0x00478840` | **Yes**`gmMainChatUI` |
Shape-id-to-class mapping is inferred from the `switch(shapeId){case
1..4}` construction order in `MakeTag` (`0x00478632`/`0x004785E1`/
`0x004785FC`/`0x00478617`) plus the fact that the only shape name we can
directly read from format strings ("IIDString") is used everywhere the
"Tell" markup appears, which always constructs `TextTag_IIDString`. This
is strong circumstantial evidence, not a direct read of the DAT enum
table — flagged per the "no guessing" rule.
---
## 3. The prefill itself: `ChatInterface::StartTell`
`gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` is the
registered listener for `SendNotice_TextTag_IIDStringClick`:
```c
void gmMainChatUI::RecvNotice_TextTag_IIDStringClick(this, uint32_t type, uint32_t iid, PStringBase<unsigned short> const* name)
{
if (type == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0)
ChatInterface::StartTell(this, name);
}
```
Two gates: (a) the tag's semantic type must be `0x10000001` — i.e. only
"Tell"-markup spans do anything on click, other `m_type` values on an
`IIDString` tag (if any exist) are silently ignored here; (b) the chat
**entry box must not already have keyboard focus** — if the player is
mid-sentence typing something else, clicking a name in the log does
nothing (`ChatInterface::IsTextEntryFocused @ 0x004F30A0`, which checks
`UIElementManager`'s active/focused element against `this->m_chatEntry`).
Note the tag's own `iid` payload (`arg3`/`m_IID`) is read into the
parameter list but **never used** by this handler — only the embedded name
string matters.
`ChatInterface::StartTell @ 0x004F41F0`:
```c
void ChatInterface::StartTell(this, PStringBase<unsigned short> const* name)
{
PStringBase<unsigned short> text = Formatted(u"@tell %s, ", name); // note: trailing space after comma
this->m_chatEntry->vtable->Activate();
this->m_chatEntry->vtable->TakeFocus(); // <-- keyboard focus moves to the chat entry
CM_UI::SendNotice_ToggleChatEntry(1); // <-- ensures the chat entry bar is shown
UIElement_Text::SetText(this->m_chatEntry, &text);
UIElement_Text::MoveCursorToPosition(this->m_chatEntry, /* length of `text` */);
UIElement_Text::ClearSelection(this->m_chatEntry);
}
```
So, precisely:
- **Text placed**: `"@tell <PlayerName>, "` — literal `@tell`, a space,
the name, a comma, and a **trailing space** (ready to type the message
body immediately).
- **Focus**: YES, explicitly changed. `Activate()` + `TakeFocus()` move
keyboard focus into the chat entry field, and
`CM_UI::SendNotice_ToggleChatEntry(1) @ 0x0047A200` broadcasts a notice
whose real handler, `ClientUISystem::RecvNotice_ToggleChatEntry @
0x00564200`, is what shows/expands the chat entry bar if it was
currently hidden (confirmed as the one non-stub override among many
vtable-slot look-alikes for that notice name).
- **Cursor**: placed at the end of the inserted text (right after the
trailing space), any prior selection cleared.
- Whatever the player had already typed into the box (if it wasn't
focused — see the focus gate above) is **replaced outright**, not
merged or prepended to.
---
## 4. Hover behavior: color is static per-tag-type, not a hover effect
`UIElement_Text::MouseMove @ 0x004695F0` was checked directly — it does
**no** glyph/tag lookup at all. It only handles active text-selection
dragging (`this->m_bitField & 0x40`) or falls back to the base
`UIElement::MouseMove`. `UIElement_Text::GetShouldBeMouseVisible @
0x00467460` likewise only inspects `this->m_bitField & 5` (an
editable/selectable flag), not glyph tags. **No hover-triggered highlight,
brightening, or cursor-icon change tied to `Glyph::m_tag` was found
anywhere in this pass.**
What *is* real, and is presumably what reads as "the name is green," is a
**static per-glyph color chosen at glyph-construction time**, based on
whether the glyph belongs to an active `0x10000001`-typed tag span. Inside
`UIElement_Text::InqGlyphs @ 0x00468EA0`, right after a tag span is
opened/continues:
```c
// 0x00469084-0x0046908A, ebx_1 = the currently-active TextTag* for this glyph (0 if none)
if (ebx_1 == 0 || ebx_1->m_type != 0x10000001)
color = &this->m_curFontColor; // offset 0x6A4 on UIElement_Text
else
color = &this->m_curTagFontColor; // offset 0x6B8 on UIElement_Text
// ... color is then baked into the new Glyph's m_color field
```
`UIElement_Text`'s struct (`acclient.h:53392-53420`) confirms two distinct
color fields exist: `RGBAColor m_curFontColor;` and
`RGBAColor m_curTagFontColor;`, set via
`UIElement_Text::SetFontColorHelper(this, attrId, &field, colorIndex)`
with **different authored attribute ids**`0x1B` for `m_curFontColor`,
`0x1D` for `m_curTagFontColor` (seen consistently at
`UIElement_Text::AppendTextWithFont @ 0x00469D70` and
`AppendStringInfoWithFont @ 0x00469DE0`). `SetFontColorHelper @
0x00466AC0` treats its 4th argument as an **index into an authored color
array** (an `InqProperty`-backed attribute, not a raw RGBA value) — so
`FontColor` and `TagFontColor` are two independently-authored per-window
color tables (almost certainly LayoutDesc-driven, consistent with this
project's existing DAT-driven-UI findings), and can hold different colors
at the same index. That is the entire "green name" mechanism: **it's
baked into the glyph once, from data, when the text is appended — not
computed or changed on mouse hover.**
`m_curTagFontColor` defaults to `RGBAColor_White` (`0x00468641`) unless a
window's layout overrides attribute `0x1D` — chat windows presumably do.
**UNKNOWN — needs DAT/runtime inspection**: the actual authored RGBA
values (attribute `0x1D`'s color table) live in a LayoutDesc DAT resource,
not in code; not dumped in this pass. Cross-reference
`claude-memory/reference_retail_chat_colors.md` for retail chat-color
ground truth already captured via cdb.
---
## 5. Is `SetReplyTextInChatBox` the click handler? No — keyboard macro only
`ChatInterface::SetReplyTextInChatBox @ 0x004F4760` has exactly one
caller in the whole binary: `ChatInterface::HandleTextReplacements @
0x004F50D0`, which tries three "quick reply" expanders in order:
```c
void ChatInterface::HandleTextReplacements(this)
{
if (this->m_chatEntry == 0) return;
if (!ChatInterface::SetReplyTextInChatBox(this)) // @ 0x004F4760 — replies to LAST TELLER
if (!ChatInterface::SetMonarchReplyTextInChatBox(this)) // @ 0x004F4B70 — replies to monarch
ChatInterface::SetPatronReplyTextInChatBox(this); // @ 0x004F4EA0 — replies to patron
}
```
`HandleTextReplacements` is itself called from exactly one place:
`ChatInterface::ListenToElementMessage @ 0x004F51C0`, `case 0x11` (i.e.
`idMessage == 0x12`), gated on `arg2->dwParam1 == 0x20` (ASCII space) and
the message originating from the chat entry element:
```c
case 0x11: // idMessage == 0x12
if (arg2->pElement == this->m_chatEntry /* decompiled as m_fCurrentOpacity, mis-attributed field */
&& arg2->dwParam1 == 0x20)
ChatInterface::HandleTextReplacements(this);
break;
```
Message id `0x12` is confirmed elsewhere as the "character typed"
broadcast: `UIElement_Text::CharacterHandler @ 0x00469B90` ends its
non-control-character path with
`UIElement::BroadcastElementMessage(this, 0x12, typedChar, 0) @
0x00469CAF` — `dwParam1` carries the raw character code. So this whole
path only fires **while the player is typing into the chat entry box and
presses the SPACE bar**, and only after typing one of a small set of
recognized prefixes.
`SetReplyTextInChatBox` itself: reads the entry's current (trimmed) text,
checks whether it starts with `/` or `@`, and if so, matches the
first-word substring against known shortcut prefixes (`"t"`/`"te"`-style
12 char abbreviations at `data_7c4c70`/`data_7c4c68`, and the literal
`u"reply "` at `0x004F4A04`). If matched, it replaces that recognized
prefix (leaving anything typed after it in place) with:
```c
gmCCommunicationSystem::GetLastTellerName(...) @ 0x00589550 // whoever LAST sent YOU a tell
Formatted(u"@tell %hs,", lastTellerName) // note: NO trailing space, %hs = narrow string
UIElement_Text::SetText / MoveCursorToPosition / ClearSelection // @ 0x004F4ADC-0x004F4AFA
```
This is a **different string** from the click path's `"@tell %s, "` (no
trailing space here; `%hs` explicitly narrow-string-formats
`GetLastTellerName`'s `PStringBase<char>*` return, vs. the click path's
already-wide `PStringBase<unsigned short>` name) — a small but real
divergence between the two "start a tell" paths worth preserving if both
get ported. `SetMonarchReplyTextInChatBox @ 0x004F4B70` and
`SetPatronReplyTextInChatBox @ 0x004F4EA0` are structurally identical,
sourcing the name from `GetLastAtMonarchUserName`/`GetLastAtPatronUserName`
instead.
**Verdict**: `SetReplyTextInChatBox` is a **keyboard-shortcut/text-macro
handler only** — triggered by typing a recognized prefix then a space in
the chat entry. It shares the "write `@tell Name,` into the entry" idea
with the click path but is a wholly separate call chain, keyed off "last
person who told me something" global state
(`gmCCommunicationSystem::SetLastTeller @ 0x005891A0`,
`SetLastTellerName @ 0x00589500`) rather than the specific name embedded
in the clicked chat line's tag. It is **not** invoked by, and does not
invoke, any part of the click-to-tag chain in §1§3.
A consequence worth flagging for the port: because the click path reads
the name baked into that *specific* chat line's tag, clicking an **old**
"X tells you" line further up the scrollback still starts a tell to X,
even if X is no longer whoever last told you something — whereas the
`/t `+space keyboard shortcut always resolves to the single global
"last teller," which could be a different person by then.
---
## 6. Where the clickable markup comes from (bonus — answers "why does this reproduce on so many message types")
The `<Tell:IIDString:<iid>:<name>>displayText<\Tell>` span is built by
`sprintf`-style formatting at multiple sites, not just for direct tells.
Representative literals (some inline strings are BN-truncated at ~33
chars, marked `…`):
| Format string (verbatim where fully visible) | Address | Used for |
|---|---|---|
| `"<Tell:IIDString:%d:%s>%s<\Tell> tells you, \"%s\"\n"` | `data_7d0ec0 @ 0x007D0EC0` | direct tell received |
| `"<Tell:IIDString:%d:%s>%s<\Tell> says, \"%s\"\n"` | `data_7d0e60 @ 0x007D0E60` | local/say-range speech |
| `"[Fellowship] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0cdc @ 0x007D0CDC` | fellowship chat |
| `"[Co-Vassals] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0bfc @ 0x007D0BFC` | co-vassal chat |
| `"[Allegiance Broadcast] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0c30 @ 0x007D0C30` | allegiance broadcast |
| `"Your patron <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0d10 @ 0x007D0D10` | patron chat |
| `"Your vassal <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0d4c @ 0x007D0D4C` | vassal chat |
| `"Your follower <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0ca0 @ 0x007D0CA0` | follower chat |
| `"[%ws] <Tell:IIDString:0:%ws>%ws<\Tell> says, \"%ws\""` | `data_7e83e8 @ 0x007E83E8` | generic named channel say (channel name in `[%ws]`) |
Confirms the closing marker is a literal backslash `<\Tell>` in the raw
string data (not the HTML-style `</Tell>` one might assume), and that the
non-direct-tell variants hardcode the `iid` field to `0` (the click
handler ignores `iid` anyway, so this has no functional effect — but it
means the tag's `m_IID` is meaningless/decorative for anything except
direct tells). The gating logic for direct tells
(`docs/research/named-retail/acclient_2013_pseudo_c.txt:382537-382553`,
`gmCCommunicationSystem`-adjacent code around `0x00571880`) only emits the
tagged, clickable form when the sender's id falls in the player-character
GUID range (`0x50000001`-`0x6FFFFFFF`); tells attributed to ids outside
that range fall back to the plain, non-clickable
`"%s tells you, \"%s\"\n"` format (`0x00571880` false branch) — so
non-player "tells" (system/GM broadcast-as-tell, etc.) are never
clickable.
---
## Open gaps (explicitly not resolved here)
- **UNKNOWN**: the full roster of `EnumMapper` category `0x18` type-name
strings (only "Tell" and, by strong inference, "IIDString" are
confirmed). A DAT dump or a cdb breakpoint on `EnumMapper::GetString`
with category `0x18` would enumerate the rest and settle whether item
links/URLs/coordinates exist as other `m_type` values on the same
`IIDString` shape, or as `DID`/`IID`/`IIDEnum` shapes instead.
- **UNKNOWN**: authored RGBA values behind `m_curFontColor`/
`m_curTagFontColor` attribute ids `0x1B`/`0x1D` for the chat log window
specifically — lives in a LayoutDesc DAT resource, not code.
- **Confirmed absence, not a gap**: no hover-only visual/cursor change was
found for tagged glyphs in this build (`MouseMove`,
`GetShouldBeMouseVisible` both checked directly and neither look at
`Glyph::m_tag`).

View file

@ -0,0 +1,469 @@
# Retail chat: how a tagged, coloured player-name run gets composed
Research-only. No source files modified. All claims cite `symbol @ 0xADDRESS`
in `docs/research/named-retail/acclient_2013_pseudo_c.txt` (pseudo-C) or
`docs/research/named-retail/acclient.h` (verbatim retail struct defs) unless
otherwise noted. Addresses without an explicit file are in the pseudo-C dump.
## TL;DR — the mechanism in one paragraph
Retail does **not** use `StringInfo`'s two-colour-argument mechanism
(`RecvNotice_DisplayFinalStringInfo`'s `arg3`/`arg4`) to colour the player
name differently from the rest of the sentence. `StringInfo` is only a
**localization/variable-substitution template** (string-table id + named
variables, or a literal override) — it has no colour or tag fields at all.
Instead, the sender's name is delivered as a **literal inline markup tag**,
`<Tell:IIDString:<GUID>:<Name>>Name<\Tell>`, baked directly into the plain
wide-char sentence *before* it is ever handed to the UI. When that sentence
is appended to the chat log, `UIElement_Text`'s glyph-list builder
(`UIElement_Text::InqGlyphs @ 0x00468ea0`) recognizes the `<...>` markup,
asks `TextTagFactory::MakeTag @ 0x00478480` to parse it into a
`TextTag_IIDString` object (GUID + name), and colours **every individual
`Glyph`** inside the tagged span from `m_curTagFontColor` (font-color
property `0x1D`) instead of the line's `m_curFontColor` (property `0x1B`) —
but **only if the tag's type is the "Tell" enum value `0x10000001`**. Each
`Glyph` also carries a `TextTag*` pointer, which is what makes the run
clickable and lets a click resolve back to the right player.
## 1. Where the sender name becomes a tagged run
### 1a. The literal tag text is baked in at message-composition time
Two adjacent handlers on `ClientCommunicationSystem` build the chat line
for incoming speech, and both embed the markup directly via `sprintf`,
*before* any StringInfo/UI code runs:
- **Local/overheard speech**`ClientCommunicationSystem::Handle_Communication__HearSpeech @ 0x005712a0`:
```
005714f5 if ((arg4 < 0x50000001 || arg4 > 0x6fffffff))
005714f5 PStringBase<char>::sprintf(&s_NullBuffer_2, "%s says, \"%s\"\n"); // no tag
005714f5 else
00571511 PStringBase<char>::sprintf(&s_NullBuffer_2, "<Tell:IIDString:%d:%s>%s<\Tell> says, \"%s\"\n");
```
(full literal recovered from the constant pool: `data_7d0e60 @ 0x007d0e60`
= `"<Tell:IIDString:%d:%s>%s<\\Tell> says, \"%s\"\n"`, since Binary
Ninja's inline preview truncates at ~33 chars).
- **Direct tell**`ClientCommunicationSystem::Handle_Communication__HearDirectSpeech @ 0x005715a0`, same shape:
```
00571880 if ((arg4 < 0x50000001 || arg4 > 0x6fffffff))
00571880 PStringBase<char>::sprintf(&arg5, "%s tells you, \"%s\"\n"); // no tag
00571880 else
0057189c PStringBase<char>::sprintf(&arg5, "<Tell:IIDString:%d:%s>%s<\Tell> tells you, \"%s\"\n");
```
(full literal: `data_7d0ec0 @ 0x007d0ec0` =
`"<Tell:IIDString:%d:%s>%s<\\Tell> tells you, \"%s\"\n"`).
`%d` = `arg4`, the speaker's actual object GUID from the wire message.
`%s` (first) = the speaker's display name (repeated once inside the tag
payload, once again as the visible glyph text after the `>`).
**The `0x50000001..0x6FFFFFFF` GUID-range gate is load-bearing**: only
senders whose object id falls in that range get the clickable/coloured
treatment at all. This is AC1's dynamic-object id range (players and other
non-static weenies); ids outside it (system/NPC broadcast cases handled
elsewhere) fall through to the plain, untagged `"%s says/tells...` format
with no markup and no special colour.
Group/channel broadcasts go through a **separate** builder,
`ChatRoomTracker::GetChatFormat @ 0x005cd7c0` (called from
`gmCCommunicationSystem::uiChatInterfaceProvider::OnSendToRoom @ 0x0058a590`,
the TurbineChat room-message handler), which always uses `IID:0` in the tag
(no real object id is available/needed there) and prepends the channel
name, e.g. for General chat:
```
005cd93a ebx = 0x1b; // LogTextType = General
005cd93f var_1c_14 = &ChannelSystem::General_GlobalChannelName;
005cd85c sprintf(&s_NullBuffer_2, "[%ws] <Tell:IIDString:0:%ws>%ws<\\Tell> says, \"%ws\"");
```
(full literal: `data_7e83e8 @ 0x007e83e8` =
`"[%ws] <Tell:IIDString:0:%ws>%ws<\\Tell> says, \"%ws\""`). The function
returns a `ChatDisplayInfo{ m_ltt (LogTextType), m_display (the whole
sprintf'd string), m_doDisplayText }` and the caller passes `m_display` and
`m_ltt` straight into `ClientSystem::AddTextToScroll`. Similar hard-coded
`IID:0` tag formats exist for Fellowship broadcast (`"[Fellowship] <Tell:IIDString:0:%s>%s<\\Tell> says, \""`,
`data_7d0cdc @ 0x007d0cdc`), Co-Vassals (`data_7d0bfc @ 0x007d0bfc`),
Allegiance Broadcast (`data_7d0c30 @ 0x007d0c30`), patron/vassal/follower
tells (`data_7d0d10`, `data_7d0d4c`, `data_7d0ca0`).
**BN-truncation note**: every one of the `sprintf(..., "<Tell:IIDString:...")`
call sites above showed only a `…`-truncated ~33-char preview in the raw
decompiler output. The full strings quoted here were recovered from the
named constant-pool entries (`data_7d0bfc`, `data_7d0c30`, `data_7d0c88`,
`data_7d0ca0`, `data_7d0cdc`, `data_7d0d10`, `data_7d0d4c`, `data_7d0e60`,
`data_7d0ec0`, `data_7e83e8`), which the pseudo-C dump prints verbatim
elsewhere in the file as global `char const data_XXXXXXXX[N] = "...", 0`
declarations — not guessed.
### 1b. The plain sprintf'd string reaches the chat window unmodified
`ClientSystem::AddTextToScroll @ 0x00487fc0` (char* overload) widens to
UTF-16 and tail-calls the wide overload `@ 0x00563c50`, which wraps the
**already-composed** text (markup and all) in a **literal-override**
`StringInfo` and forwards it to the notification bus:
```
00563ee9 StringInfo::SetLiteralValue(&var_890, &m_charbuffer_3 /* full "<Tell:...>...\"" text */, 1);
00563f05 StringInfo::SetLiteralValue(&var_920, &s_NullBuffer_4 /* timestamp string, or empty */, 1);
00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3 /* LogTextType/colour index */, &var_890, &var_920, arg5 /* window id */);
```
This confirms `StringInfo` here is used purely as a **transport wrapper**
around an already-fully-formed literal string — `StringInfo::SetLiteralValue`
sets `m_Override = 1` (literal) so `StringInfo::GetString` later just
returns the wrapped text verbatim; no template/variable substitution
happens for chat lines built this way. (See §4 for why `StringInfo` cannot
itself be the tag carrier.)
`ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640` (the
override registered on `ChatInterface`) receives this notice:
```
004f46dc if (StringInfo::IsValid(arg4, 1) != 0)
004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4, 0, 0xc); // arg4 = timestamp StringInfo, colour index 0x0C
004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3, 0, arg2); // arg3 = the message StringInfo, colour index arg2 (LogTextType)
```
`arg4` (the *second* StringInfo, appended first, at the fixed colour index
`0x0C`) is the **timestamp prefix** (`"HH:MM:SS "`, built a few lines
earlier in `AddTextToScroll` from `PlayerModule::DisplayTimeStamps` +
`wcsftime`), not a channel-name prefix. `arg3` (colour index `arg2` = the
caller-supplied LogTextType) is the **entire rest of the line**, channel
prefix and all — see §3 for why that resolves the "0x0C is grey" question.
### 1c. `AppendStringInfoWithFont` resolves the literal text and hands it to the glyph parser
`UIElement_Text::AppendStringInfoWithFont @ 0x00469de0`:
```
00469df4 UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3);
00469e09 UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4); // line colour, keyed by LogTextType index arg4
00469e1a UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4); // tag colour, same index
00469e2a eax_1 = StringInfo::GetString(arg2, &arg3, 0); // resolves literal-override text verbatim
00469e44 UIElement_Text::AddText_Internal(this, m_charbuffer, 3);
```
`m_curFontColor` and `m_curTagFontColor` are named fields on
`UIElement_Text` (`docs/research/named-retail/acclient.h:53408,53410`):
```
struct __cppobj __declspec(align(8)) UIElement_Text : UIElement_Scrollable, CInputHandler
{
...
RGBAColor m_curFontColor;
Font *m_curFontObj;
RGBAColor m_curTagFontColor;
...
};
```
So **before any markup parsing happens**, the widget primes two colours
for the whole append call — the base line colour and a *separate* tag
colour — both looked up via the exact same LogTextType-indexed mechanism
(`UIElement_Text::SetFontColorHelper @ 0x00466ac0`, which does
`InqProperty(propId) → indexed-array element at [arg4]`).
### 1d. The glyph-list builder recognizes `<...>` and creates the `TextTag`
`UIElement_Text::InqGlyphs @ 0x00468ea0` (the routine `AddText_Internal`
uses to turn the resolved wide string into `Glyph` records) scans
char-by-char; on finding `<` it captures through the matching `>` and
calls the tag factory:
```
00469021 int32_t eax_16 = TextTagFactory::MakeTag(); // parses the whole "<Tell:IIDString:GUID:Name>" span
00469084 if (ebx_1 == 0 || tag->m_type != 0x10000001)
00469084 edx_15 = <UIElement_Text + 0x6a4>; // this->m_curFontColor (RGBAColor field order matches struct above)
00469084 else
0046908a edx_15 = <UIElement_Text + 0x6b8>; // this->m_curTagFontColor
004690c5 Glyph::Glyph(&esp_1[9]); // constructs the glyph with the chosen colour + tag pointer
```
(Note: this function is heavily register/stack-mangled in Binary Ninja's
output — the raw operand forms above are paraphrased from the literal
`esp`/`ecx` chains in the dump, not verbatim BN text, because the BN
pseudo-C here reads as raw stack-slot arithmetic rather than named field
accesses. The two struct-offset destinations (`+0x6a4`, `+0x6b8`) are
`0x14` bytes apart, matching an `RGBAColor` (16 bytes) + `Font*` (4 bytes)
gap between `m_curFontColor` and `m_curTagFontColor` in the struct dump
above — consistent with, but not a byte-for-byte confirmed alias of, those
two named fields.)
`Glyph` itself carries **per-character** colour and tag
(`docs/research/named-retail/acclient.h:45330`):
```
struct __cppobj Glyph
{
unsigned __int16 m_data; // character code
int m_width;
int m_height;
RGBAColor m_color; // per-glyph colour — this is what makes the name a different colour from the rest of the line
Font *m_font;
TextTag *m_tag; // non-null only for glyphs inside a <Tag:...>...<\Tag> span — this is what makes it clickable
};
```
**This is the answer to "who creates the tagged run": `TextTagFactory::MakeTag @ 0x00478480`**,
called from `UIElement_Text::InqGlyphs @ 0x00468ea0` while it walks the
resolved plain-text string looking for `<...>` markers. It is a **markup
parser operating on plain text**, not a StringInfo/variable mechanism.
`TextTagFactory::MakeTag @ 0x00478480` itself:
1. Confirms the captured span starts with `<` and ends with `>`.
2. Splits on the first `:` — the text before it (e.g. `"Tell"`) is looked
up via `EnumMapper::InqEnum(name, 0x18, &m_type)` (a DAT-driven
string→enum table, category `0x18`) to get the numeric tag **type**
(`this->m_type`, e.g. `0x10000001` for `"Tell"`).
3. Splits again on the next `:` — the text between them (e.g.
`"IIDString"`) is looked up the same way to get a small **class**
selector (`var_18`, 14), which a `switch` uses to instantiate the
right `TextTag` subclass:
- `1``TextTag_DID`
- `2``TextTag_IID`
- `3``TextTag_IIDEnum`
- `4``TextTag_IIDString` (jump table `jump_table_478728 @ 0x00478728`, case `4 @ 0x00478617`)
4. Calls the new tag's virtual `ParseStartTag` on the remaining payload
text (everything after the second `:`, i.e. `"<GUID>:<Name>"`).
## 2. Tag payload — what a click needs to address the right player
`struct TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase<unsigned short> m_string; }`
(`docs/research/named-retail/acclient.h:53960`), with the base class
`struct TextTag : ReferenceCountTemplate<1048576,0> { unsigned int m_type; unsigned int m_format; }`
(`docs/research/named-retail/acclient.h:45358`).
`TextTag_IIDString::ParseStartTag @ 0x00478910` fills it in:
```
00478946 if (PStringBaseIter_Common<unsigned short>::FindChar(&iter, ':', 0) != 0) // find the FIRST ':' in "GUID:Name"
00478a02 if (PSUtils::is_uint32(leftPart) != 0) // left of ':' must parse as a uint32
004788ad this->m_IID = PStringBase<unsigned short>::to_uint32(&leftPart); // -> numeric object GUID
00478ae2 PStringBase<unsigned short>::operator=(&this->m_string, &rightPart); // -> display name text
```
So the payload is **both** the numeric object id **and** the display name
string, not just one or the other. `TextTag_IIDString::BuildStartTagData @ 0x004788e0`
is the inverse (serializes back to `"0x%08X:%ls"`), confirming the same
two-field shape round-trips.
**Click resolution** (`TextTag_IIDString::HandleClick @ 0x00478840`):
```
0047884c ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string);
```
which is picked up by `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004cce10`:
```
004cce1b if (arg2 == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0) // arg2 = tag->m_type ("Tell")
004cce2d ChatInterface::StartTell(this, arg4); // arg4 = tag->m_string — starts a /tell using the NAME, not the GUID
```
So in the one client-side consumer we traced, the actual action
(`StartTell`) only uses the **name string**, even though the tag also
carries the numeric GUID. The GUID is transmitted through the notice
(`arg3`) but this handler doesn't consume it — it may be used by other,
untraced `RecvNotice_TextTag_IIDStringClick` overrides (several other UI
classes register the same override — see the vtable-slot list in the
pseudo-C dump around `0x0079e580` onward — only `gmMainChatUI`'s and the
base `NoticeHandler::RecvNotice_TextTag_IIDEnumClick` fallback were read in
this pass). **UNKNOWN — needs a scan of every other `RecvNotice_TextTag_IIDStringClick`
override** if a consumer that actually resolves by GUID matters for
acdream's design (e.g. distinguishing two players who changed names, or a
"select in world" action).
## 3. The `[General]` channel prefix — colour, and resolving the 0x0C puzzle
**Resolved: the apparent "0x0C is grey" contradiction was a mis-identification
on my part before tracing the code, not a real contradiction.** Index `0x0C`
is not the channel-prefix colour — it's hard-coded in
`ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640` (§1b) as the
colour for the **timestamp** StringInfo (`arg4`), which is entirely
separate from the channel-prefixed message text (`arg3`). Confirmed against
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` (see below): index
`0x0C`'s colour **is** `colorGrey` — exactly matching the existing project
note. It's grey because it's the timestamp, not because it's "[General]".
`"[General]"` and the rest of the line (`says, "..."`, including the
embedded name tag's line-colour-before-override) share **one** LogTextType
value for the whole assembled string — set in
`ChatRoomTracker::GetChatFormat @ 0x005cd7c0` (§1a):
```
005cd93a ebx = 0x1b; // General
005cd949 ebx = 0x1c; // Trade
005cd95c ebx = 0x1d; // LFG
005cd96d ebx = 0x1e; // Roleplay
005cd97e ebx = 0x12; // Olthoi
005cd9b8 ebx = 0x20; // Society (all variants)
```
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` builds **one**
LogTextType-indexed colour array (`BaseProperty` at property id `0x1B`,
34 entries, indices `1..0x22`) applied to `this->m_chatLog`. It defaults
every index to `colorGreen @ 0x81c578` and then overrides ~27 of them.
Reading the override sequence address-by-address against the named
`RGBAColor` globals in the constant pool (`0x81c4a8`..`0x81c598`, each
printed with a name, e.g. `class RGBAColor colorGrey = { r=0.824 g=0.824
b=0.784 a=1 }`) gives this full index→colour table:
| LogTextType index | Colour name | RGBA |
|---|---|---|
| 2 | colorWhite | (1, 1, 1, 1) |
| 0x0C | colorGrey | (0.824, 0.824, 0.784, 1) — **timestamp**, not General |
| 3, 0xA, 0x13, 0x1F | colorYellow | (1, 1, 0.247, 1) |
| 4, 0xB | colorTan | (0.824, 0.824, 0.392, 1) |
| 5 | colorBrightPurple | (1, 0.498, 1, 1) |
| 6, 0xF, 0x15 | colorDarkRed | (1, 0.247, 0.247, 1) |
| 7, 0x11 | colorLightBlue | (0.247, 0.749, 1, 1) |
| 8, 9 | colorPink | (1, 0.588, 0.588, 1) |
| 0xD | colorCyan | (0.247, 0.863, 0.863, 1) |
| **0xE, 0x1B (General), 0x1C (Trade), 0x1D (LFG), 0x1E (Roleplay), 0x20 (Society)** | **colorBlueGrey** | **(0.706, 0.863, 0.941, 1)** |
| 0x16 | colorLightRed | (0.96, 0.459, 0.447, 1) |
| 0x12 (Olthoi) , 0x21 | colorOrange | (0.933, 0.573, 0.118, 1) |
| 0x1A | colorBrightRed | (1, 0, 0, 1) |
| everything else (1, 0x10, 0x14, 0x17, 0x18, 0x19, 0x22) | colorGreen (default, unoverridden) | (0.5, 1, 0.498, 1) |
So **General/Trade/LFG/Roleplay/Society chat all render in the same pale
blue-grey** (`colorBlueGrey`) as their base line colour — `"[General]"`
and `says, "..."` are the same colour. This cross-checks cleanly against
the project's existing `claude-memory/reference_retail_chat_colors.md`
(same named constants, same addresses, independently dumped live via cdb
on 2026-06-16): its `colorWhite`→LocalSpeech, `colorBrightPurple`(index
5)→Tell, `colorLightRed`(index 0x16)→Combat, and `colorGrey`(index
0x0C)→"Emote/SoulEmote/fallback" mappings all match the indices found here
exactly. That memory doc's "Channel"→`colorLightBlue` guess (its own text
flags this mapping as an *unverified* nearest-match, "the rare kinds map
to the nearest named color... wasn't traced") is superseded by the exact
trace above: the built-in text channels are `colorBlueGrey`, not
`colorLightBlue` (`colorLightBlue` is indices 7 and 0x11, whose LogTextType
names weren't identified in this pass — **UNKNOWN**, would need the
DAT-driven `LogTextTypeEnumMapper` string table to name every index; see §5).
**Net effect for the screenshot in the prompt**: `"[General] <name> says, ..."` is
**two** colours, not three — the whole line (brackets, "says,", the
quoted message) in `colorBlueGrey`, and the name span in whatever
`m_curTagFontColor` resolves to (see §5) wherever the tag's type is
`"Tell"` (`0x10000001`). If the user's read genuinely showed three
distinguishable hues, the third one is not explained by anything traced in
this pass — flag as **UNKNOWN, possibly a rendering/outline-colour effect
(`m_curOutlineColor`, also a field on `UIElement_Text`, untraced here) or
a visual misread of anti-aliasing against the grey timestamp prefix.**
## 4. Is `StringInfo` the tag carrier?
**No.** Traced its full field set from the constructor/accessor bodies
(`StringInfo::StringInfo @ 0x0042da60`, `::Reset @ 0x0042daf0`,
`::IsValid @ 0x0042cbe0`, `::AddVariable_Int/UInt/Float/String/StringInfo
@ 0x0042dde0-0x0042e7d0`): `m_Override` (0=table-driven / 1=literal /
2=?), `m_stringID`, `m_tableID`, `m_strToken`, `m_LiteralValue`,
`m_strEnglish`, `m_strComment`, and `m_variables` (an
`IntrusiveHashTable<name, StringInfoData*>` for named-variable
substitution into a localized template). None of these are colour, tag,
or link fields — `StringInfo` is purely a **localization envelope**
(string-table id + substitution variables, or a raw literal override via
`SetLiteralValue`). Tagging is applied **after** the envelope is unwrapped:
`StringInfo::GetString` (called inside `AppendStringInfoWithFont @
0x00469de0`, §1c) resolves the final plain wide string, and *that* plain
string is what `UIElement_Text::AddText_Internal`/`InqGlyphs` scans for
`<...>` markup. So the two systems are cleanly separated: StringInfo
answers "what text, and in what language", the glyph-list builder answers
"does any of this text contain clickable/differently-coloured spans".
## 5. Which colour does the tagged name actually use?
**Structurally proven, exact value UNKNOWN.** §1c/1d prove the mechanism:
`UIElement_Text::SetFontColorHelper(this, 0x1D, &m_curTagFontColor, arg4) @
0x00469e1a` looks up property `0x1D` through the *identical*
indexed-array-by-LogTextType path as property `0x1B` (line colour) — see
`UIElement_Text::SetFontColorHelper @ 0x00466ac0`, which does
`InqProperty(propId) → array bounds check (arg4 < count) indexed element
copy`. And `UIElement_Text::InqGlyphs @ 0x00468ea0` proves the *use*: a
glyph gets `m_curTagFontColor` instead of `m_curFontColor` specifically
when its enclosing tag's `m_type == 0x10000001` (the "Tell" tag-name enum
value, resolved via the DAT-driven `EnumMapper` category `0x18` — see §1d
step 2). Tags of any *other* type (e.g. `IIDEnum`-based links used
elsewhere in the client) are still clickable (non-null `Glyph.m_tag`) but
render in the ordinary line colour — the green/special-colour behaviour is
specific to Tell-type name links, not "any markup tag."
What I could **not** find: a second `BuildXxxColorLookupTable`-style
function that populates property `0x1D`'s array the way
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` populates `0x1B`
(that function only ever calls `SetPropertyName(&var_18, 0x1b)` once, and
the whole function body — read start to end — only ever writes to that one
array before the final `this->m_chatLog->vtable->SetProperty(&var_18)`).
Two explanations are consistent with what's traced and neither is
confirmed:
- Property `0x1D` is authored directly on the chat-log `UIElement_Text`
widget via its `LayoutDesc` (a per-widget default, not something
`ChatInterface` code builds at runtime) — plausible since
`SetFontColorHelper`'s `InqProperty` call would find *any* property the
widget inherits, not just ones `BuildChatColorLookupTable` wrote.
- A second, unlocated runtime builder populates it elsewhere.
**UNKNOWN — needs either**: (a) a `LayoutDesc`/DAT dump of the chat-log
window's property `0x1D` (or its default RGBAColor), or (b) a live cdb
breakpoint on `UIElement_Text::SetFontColorHelper` with `arg2==0x1D` while
a real Tell-tagged line renders, reading `this->m_curTagFontColor` after
the call returns (same toolchain as `claude-memory/reference_retail_chat_colors.md`'s
`x acclient!color*` / `dd` recipe). The user's screenshot reads it as
green, and AC's clickable-name convention is widely remembered as green,
but that is **not** something this pass proved from decomp — flagging it
as inferred-from-screenshot/prior-knowledge, not decomp-verified.
## Open items / follow-ups
- §2: only one `RecvNotice_TextTag_IIDStringClick` override
(`gmMainChatUI`) was traced for click behaviour; others exist (vtable
slots reference `NoticeHandler::RecvNotice_TextTag_IIDEnumClick` and
`UIElement::MouseHover` as generic fallbacks — worth a second pass if
acdream needs to replicate hover/tooltip behaviour, not just click).
- §3: LogTextType names for indices 7, 0x11 (colorLightBlue), 0xE
(shares colorBlueGrey with the built-in channels), and the seven
unoverridden default-green indices (1, 0x10, 0x14, 0x17, 0x18, 0x19,
0x22) are unidentified — the DAT-driven `LogTextTypeEnumMapper` string
table (`struct __cppobj LogTextTypeEnumMapper`, `acclient.h:57333`)
would name them; not pulled in this pass.
- §5: the exact `m_curTagFontColor` RGBA value is unproven from static
decomp alone — see the two follow-up options listed there.
- The `m_format` field on `TextTag` (set from the tag's second colon-split
segment, e.g. `"IIDString"` → the class-selector `1..4`) was read as a
class-shape selector, consistent with the `TextTagFactory::MakeTag`
switch, but its retail name/purpose beyond "which TextTag subclass" was
not otherwise probed.
## RESOLVED: the tag colour is authored, and it is green
The research pass above could only prove the *mechanism* for the tag colour
(property `0x1D`, applied per-glyph when a tag is open and its `m_type` is
`0x10000001`), not its value — `ChatInterface::BuildChatColorLookupTable
@0x004F31C0` builds only the ordinary `0x1B` array, so it correctly flagged the
RGBA as UNKNOWN rather than assuming the green seen in a screenshot.
It is authored in the LayoutDesc, and it measures out of the installed DATs as:
chat window 0x2100006F, transcript element 0x10000011
P0x1B (line colour) [0x00] R=204 G=204 B=204 A=255
P0x1D (tag colour) [0x00] R= 0 G=178 B= 0 A=255 <- the green
Reproduce with:
dotnet run --project tools/LayoutDump -c Release -- 0x2100006F --colors
Two things worth carrying into the port:
- **The tag colour is per-ELEMENT, not per-LogTextType.** `0x1B` here is a
one-entry array too, so on this element the ordinary colour comes from the
runtime-built chat table while the tag colour comes from the authored
property. A port that files "tag green" into the LogTextType colour table
would be putting it in the wrong place.
- The same `0x1D` green appears on more than one element in this layout, so it
is not unique to the transcript.
`tools/LayoutDump --colors` was added for this measurement and prints the
`0x1B`/`0x1D` arrays of every element in a layout.

View file

@ -0,0 +1,849 @@
# Retail chat TextTag / glyph-tag model — data model, lifetime, colour rule
Research-only. No source was modified for this document. All addresses are
from the Sept 2013 EoR build (`refs/acclient.pdb` / `acclient.exe` v11.4186,
CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`), as decompiled in
`docs/research/named-retail/acclient_2013_pseudo_c.txt` (pseudo-C) and
`docs/research/named-retail/acclient.h` (verbatim retail struct headers).
Every claim below cites `symbol @ 0xADDRESS`; anything not directly
observed in the decompile is marked **UNKNOWN**.
This document explains the mechanism behind the retail behaviour: a
speaker's name inside a chat line renders in a different colour and is
clickable (click → prefill a tell to that person). The mechanism is a
**glyph-level tag model**, not per-line colouring. `UIElement_Text`
(the class backing chat log / most retail text widgets) keeps a list of
`Glyph` structs, one per character, and each `Glyph` optionally points at
a shared, reference-counted `TextTag` object. A **contiguous run of
glyphs sharing the same `TextTag*` pointer** is what gets the special
colour and the click behaviour — there is no separate "run" or "span"
object; identity is pointer equality on `Glyph::m_tag`, discovered by
linear walk every time it matters.
---
## 1. The `TextTag` type family
### 1.1 Struct layout
```
acclient.h:45358
struct __cppobj TextTag : ReferenceCountTemplate<1048576,0>
{
unsigned int m_type;
unsigned int m_format;
};
```
`ReferenceCountTemplate<1048576,0>` (`acclient.h:7974`) is:
```
struct __cppobj ReferenceCountTemplate<1048576,0>
{
ReferenceCountTemplate<1048576,0>Vtbl *vfptr; // +0x0
unsigned int m_cRef; // +0x4
};
```
So on a live `TextTag`, `vfptr` is at `+0x0`, `m_cRef` at `+0x4`,
`m_type` at `+0x8`, `m_format` at `+0xc`. Those exact offsets are used
directly by the pseudo-C at several sites cited below (e.g.
`*(uint32_t*)((char*)result + 8) = var_14` for `m_type`,
`*(uint32_t*)((char*)ebx_1 + 8) != 0x10000001` for a runtime `m_type`
comparison), which cross-checks the struct layout against the header.
`TextTagType` (the type of `m_type`/`m_format`) is only ever typedef'd:
```
acclient.h:62585
typedef unsigned int TextTagType;
```
No named enum for it survived in the PDB (`grep`'d `TextTagType|TAG_TYPE|
eTextTag` across `acclient.h` returns only that one typedef line). See
§7 for what this means for the `0x10000001` sentinel.
### 1.2 Four concrete subclasses — what identifies/distinguishes a tag
```
acclient.h:53947 struct __cppobj TextTag_IID : TextTag { unsigned int m_IID; };
acclient.h:53953 struct __cppobj TextTag_IIDEnum : TextTag { unsigned int m_IID; unsigned int m_enum; };
acclient.h:53960 struct __cppobj TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase<unsigned short> m_string; };
acclient.h:53967 struct __cppobj TextTag_DID : TextTag { IDClass<_tagDataID,32,0> m_DID; };
```
So a `TextTag` is a small, polymorphic, ref-counted "click payload"
object. Its identity as far as glyphs/runs are concerned is just its
**pointer value** (see §2). Its semantic identity — what clicking it
actually means — is carried by the concrete subclass's extra field(s):
- `TextTag_DID` — wraps a `DataID` (a DAT-file object reference).
- `TextTag_IID` — wraps an `IID` (an in-world Instance ID, i.e. a live
object/creature/player's server-assigned id).
- `TextTag_IIDEnum` — an `IID` plus an `enum` (a secondary
small-integer qualifier).
- `TextTag_IIDString` — an `IID` plus a `PStringBase<unsigned short>`
(a wide string) — **this is the shape used for a clickable player
name**: `m_IID` is the speaker's object id, `m_string` most plausibly
carries their display name for building the tell command (see §6).
Each subclass overrides a fixed 7-slot vtable (`__vecDelDtor`,
`ParseEndTag`, `ParseStartTag`, `BuildEndTag`, `BuildStartTag`,
`HandleClick`, `BuildStartTagData` — confirmed layout dumped verbatim at
`acclient_2013_pseudo_c.txt:959321-959375`, e.g. `TextTag_DID::`vftable''
@ `0x0079e09c`).
**Decompiler artifact to flag**: in the vtable dump, `TextTag_IID`'s
`ParseStartTag`/`BuildStartTagData` slots point at
`TextTag_DID::ParseStartTag` / `TextTag_DID::BuildStartTagData`
(`acclient_2013_pseudo_c.txt:959363,959367`), not at distinct
`TextTag_IID::*` functions. This is almost certainly MSVC identical-code
folding (COMDAT folding) — `TextTag_IID`'s parse/build logic for a bare
32-bit `m_IID` is byte-identical to `TextTag_DID`'s for a bare 32-bit
`m_DID.id`, so the linker merged them and the PDB can only attribute the
merged function to one of the two symbols. Treat this as "same code,
shared by both classes," not "IID delegates to DID."
### 1.3 Factory / parsing — `TextTagFactory::MakeTag`
```
acclient_2013_pseudo_c.txt:132871
00478480 class TextTag* TextTagFactory::MakeTag(class PStringBase<unsigned short> const* arg1)
```
Given the text between `<` and `>` (the delimiters are stripped by the
caller — see §3), `MakeTag`:
1. Finds the first `:` in the substring (`FindChar(':')`,
`acclient_2013_pseudo_c.txt:132904`). If none is found, parsing fails
and `MakeTag` returns `0` (`return 0;` @ `0x478700`,
`acclient_2013_pseudo_c.txt:133064`). **This is the mechanism an "end
tag" uses to close a run — see §3.2.**
2. Resolves the substring before the first `:` to an enum value via
`EnumMapper::InqEnum` (`acclient_2013_pseudo_c.txt:132921`).
3. Finds the *second* `:` and resolves that substring to a second enum
value, also via `EnumMapper::InqEnum`
(`acclient_2013_pseudo_c.txt:132954`), then `switch`es on it
(`acclient_2013_pseudo_c.txt:132955`) to allocate one of the four
concrete subclasses:
```
acclient_2013_pseudo_c.txt:132955-133051 (paraphrased switch table)
case 1: result = TextTag_DID::TextTag_DID(...)
case 2: result = TextTag_IID::TextTag_IID(...)
case 3: result = TextTag_IIDEnum::TextTag_IIDEnum(...)
case 4: result = TextTag_IIDString::TextTag_IIDString(...)
```
4. Stores the two resolved enum values into the new object:
```
acclient_2013_pseudo_c.txt:132981-132982
*(int32_t*)((char*)result + 8) = var_14; // m_type = first EnumMapper::InqEnum result
*(int32_t*)((char*)result + 0xc) = var_18; // m_format = second EnumMapper::InqEnum result (== the switch discriminant, 1-4)
```
5. Delegates the remainder of the string (after the second `:`) to the
new object's own `ParseStartTag` virtual (via the vtable, at
`acclient_2013_pseudo_c.txt:133008`,
`*(int32_t*)((char*)vtable + 8)(__return)` — vtable slot `+0x8` =
`ParseStartTag` per the layout in §1.1) to consume the
type-specific payload (the `IID`/`DID`/`enum`/`string` fields).
If that fails, the freshly-allocated tag is released
(`ReferenceCountTemplate<1048576,0>::Release(result)` @
`acclient_2013_pseudo_c.txt:133016`) and no tag is produced for this
span.
So the overall wire format `MakeTag` parses is
**`TYPE_NAME:FORMAT_NAME:PAYLOAD`**, where `TYPE_NAME` selects `m_type`
(a semantic category — see §7) and `FORMAT_NAME` selects which concrete
subclass parses `PAYLOAD` (`m_format` doubles as "which of the four
built-in payload shapes this is").
Round-trip confirmation comes from `TextTag::BuildStartTag` (the
inverse operation, used when a tagged region is serialized back to
text — see §2.3):
```
acclient_2013_pseudo_c.txt:133619-133669 (TextTag::BuildStartTag @ 0x478fe0)
eax_1 = EnumMapper::InqString(0x18, this->m_type, &...); // name for m_type
eax_6 = EnumMapper::InqString(0x18, this->m_format, &...); // name for m_format
this->vtable->BuildStartTagData(&...); // subclass-specific payload text
PStringBase<unsigned short>::sprintf(arg2, u"<%ls:%ls%:%ls>");
```
(`u"<%ls:%ls%:%ls>"` — the stray `%` immediately after the second `%ls`
is very likely a Binary Ninja string-literal rendering artifact, not a
real extra `%` in the format string; the surrounding logic only ever
supplies three substitutions. **Flagged as uncertain** — resolving it
precisely would need a raw byte dump of the `.rdata` string at its
address rather than BN's decompiled string preview.)
`EnumMapper::InqString`/`InqEnum` both route through a *table id*
argument of `0x18` (`acclient_2013_pseudo_c.txt:133627,133636,133725,
133364`, and `EnumMapper::InqEnum`'s call site in `MakeTag` at
`acclient_2013_pseudo_c.txt:132918-132921`). This table id is what
selects which named-enum table (`m_type`'s table vs. individual
subclass fields' tables) to search — see §7 for why we can't yet name
what string maps to `m_type == 0x10000001`.
---
## 2. Attachment model — per-glyph, not per-run, not per-line
### 2.1 `Glyph` struct
```
acclient.h:45330
struct __cppobj Glyph
{
unsigned __int16 m_data; // the character
int m_width;
int m_height;
RGBAColor m_color; // acclient.h:8100 — 4 floats (r,g,b,a), resolved per-glyph at append time
Font *m_font;
TextTag *m_tag; // nullable, shared, ref-counted
};
```
`GlyphList` (`acclient.h:45305`) is a doubly-linked `List<Glyph>` plus a
cached `SmartArray<GlyphLine,1>` (line-break layout cache) and some
bookkeeping (`m_nMaxCharacters`, `m_nFirstInvalidPosition`, etc.).
`UIElement_Text` (`acclient.h:53392`) owns exactly one `GlyphList
m_glyphList` (the live/editable text) and a second `GlyphList
m_glTruncate` (used by the truncation machinery — not investigated
further here).
**There is no `TextTag*` field, run/span object, or index range
anywhere on `GlyphList`, `GlyphLine`, or `UIElement_Text`.** The *only*
place a tag pointer lives is `Glyph::m_tag`, one per character. A
"tagged run" is purely an emergent property: a maximal sequence of
adjacent glyph list nodes whose `m_tag` fields are pointer-equal.
### 2.2 How code discovers a run boundary
Every place in the decompile that needs to know "does this edit split a
tagged run" or "did the tag change here" does the same thing: walk
adjacent glyphs and compare `data.m_tag` by pointer. Three load-bearing
examples:
**On insert**, if the two glyphs immediately either side of the
insertion point shared a tag, the whole tag is stripped (see §3.1 for
why it's the *whole* tag, not just the boundary):
```
acclient_2013_pseudo_c.txt:127079-127090 (GlyphList::Insert @ 0x472e70)
class ListNode<Glyph>* prev = _current->prev;
if (prev != 0)
{
class TextTag* m_tag = prev->data.m_tag;
if (m_tag == _current->data.m_tag)
GlyphList::RemoveTextTag(this, m_tag);
}
```
**On delete**, the boundary glyph of the doomed range is checked the
same way:
```
acclient_2013_pseudo_c.txt:127294-127302 (inside GlyphList::Delete @ 0x4730a0)
class TextTag* m_tag = edi->data.m_tag;
if (m_tag != 0)
{
for (class ListNode<Glyph>* i = this_1->m_glyphList._head; i != 0; i = i->next)
{
if (i->data.m_tag == m_tag)
Glyph::SetTag(i, nullptr);
}
}
```
**On bulk append** (`GlyphList::AddText`), the glyph immediately before
the insertion point is compared to the first glyph being spliced in:
```
acclient_2013_pseudo_c.txt:127354-127362 (inside GlyphList::AddText @ 0x473190)
int32_t ebx = *(int32_t*)((char*)prev + 0x20); // prev->data.m_tag (offset +0x20 into Glyph)
if ((ebx == var_8->data.m_tag && ebx != 0))
{
for (class ListNode<Glyph>* i = this_2->m_glyphList._head; i != 0; i = i->next)
if (i->data.m_tag == ebx)
Glyph::SetTag(i, nullptr);
}
```
**On text serialization** (`GlyphList::InqText`, used to reconstruct
markup text — e.g. what `ChatInterface::TruncateChatLog` reads before
truncating, see §3.3), the same pointer-compare drives when to close
the previous tag's markup and open the new one:
```
acclient_2013_pseudo_c.txt:127671-127700 (inside GlyphList::InqText @ 0x473560)
class TextTag* m_tag = _head->data.m_tag;
if ((eax_4 != 0 && m_tag != m_tag_1)) // tag changed since previous glyph
{
if (m_tag_1 != 0)
m_tag_1->vtable->BuildEndTag(&arg5); // close previous run's markup
if (m_tag != 0)
m_tag->vtable->BuildStartTag(&arg5); // open new run's markup
}
...
m_tag_1 = m_tag; // carried into next iteration
```
So: **a contiguous run is identified only by walking neighbours and
comparing `Glyph::m_tag` pointers; there is no cached run table.**
Every operation that could break a run's contiguity re-derives the
answer by walking.
### 2.3 How a tag attaches during append — `UIElement_Text::InqGlyphs`
```
acclient_2013_pseudo_c.txt:115983
00468ea0 uint8_t __stdcall UIElement_Text::InqGlyphs(class UIElement_Text* this @ ecx, class PStringBase<unsigned short> const* arg2, class SmartArray<Glyph,1>* arg3)
```
This converts a raw wide string (which may contain embedded
`<TYPE:FORMAT:DATA>` markup) into a flat array of `Glyph`s, one
character at a time. It is called from `UIElement_Text::AddText_Internal`
(`acclient_2013_pseudo_c.txt:116791`), which is itself the single choke
point every text-append path funnels through (`AppendText`,
`AppendStringInfo`, `AppendStringInfoWithFont`, `CharacterHandler`, the
paste handler, etc. — confirmed by grepping every
`AddText_Internal(` call site, `acclient_2013_pseudo_c.txt:116886,116916,
116994,117008,117026,117043,117106`).
The character loop keeps one local, `ebx_1` (`class TextTag*`), which is
the **currently-open tag while walking characters** — this is NOT a
field on `UIElement_Text`; it's a local in this one function's loop:
- On seeing `<` (`0x3c`), it scans to the matching `>` (`0x3e`),
extracts the substring, and calls
`TextTagFactory::MakeTag(...)` (`acclient_2013_pseudo_c.txt:116117`).
The `<...>` delimiter text itself is **not emitted as glyphs** — the
character cursor (`edi_1`) is advanced past the closing `>` before
glyph emission resumes (`acclient_2013_pseudo_c.txt:116124-116131`).
The result — a new tag pointer, or `0` if `MakeTag` failed to parse —
replaces `ebx_1` for subsequent characters.
- For every ordinary character, the glyph's colour is chosen from `ebx_1`
(see §5 for the exact rule) and a `Glyph` is constructed carrying
`ebx_1` as its `m_tag` (§4 covers the ref-count mechanics of that
construction).
**Because `ebx_1` is never explicitly reset to `0` on a "closing
bracket," the only way a tag run ends is for a later `<...>` span to
fail to parse into a valid tag** (no `:` found → `MakeTag` returns `0`,
§1.3 step 1). That is exactly what `TextTag::BuildEndTag` emits:
```
acclient_2013_pseudo_c.txt:133714-133748 (TextTag::BuildEndTag @ 0x479190)
if (this->m_type != 0)
{
EnumMapper::InqString(0x18, this->m_type, &var_4); // just the type's name, no ':'
PStringBase<unsigned short>::sprintf(arg2, u"<\%ls>");
return 1;
}
return 0;
```
i.e. the end-tag markup is a bracketed **type name with no colon**
(`u"<\%ls>"` — the `\` immediately before `%` is almost certainly a
Binary Ninja rendering artifact for a literal `/`, i.e. the real string
is most plausibly `"</%ls>"`; **flagged as uncertain**, same caveat as
§1.3 — BN's string preview/escaping for embedded control characters is
not reliably faithful and this should be confirmed with a raw
`.rdata` byte dump before being relied on verbatim). Since that
substring has no `:`, `TextTagFactory::MakeTag`'s `FindChar(':')` check
fails and it returns `0``ebx_1` becomes `0` → every glyph after that
point is untagged, until the next successfully-parsed `<TYPE:FORMAT:
DATA>` start tag. **Start/end tags are symmetric in markup shape but
asymmetric in mechanism**: a start tag is a successful `MakeTag` parse;
an end tag is nothing more than *any* bracketed text that fails to
parse as one.
---
## 3. Lifetime
### 3.1 Creation, retention, destruction — reference counting
`TextTag` inherits `ReferenceCountTemplate<1048576,0>` (`m_cRef` at
`+0x4`, `vfptr` at `+0x0`). `TextTagFactory::MakeTag` hands back an
object with `m_cRef == 1` (set in the base ctor,
`acclient_2013_pseudo_c.txt:133587-133594`,
`TextTag::TextTag @ 0x478f80`: `this->m_cRef = 1;`). From there,
ownership is **fully distributed across every `Glyph` that points at
it** — there is no separate owning list or registry.
**Adopting a tag reference increments the count.** The parameterized
`Glyph` constructor used by `InqGlyphs` when building a new glyph does
this explicitly:
```
acclient_2013_pseudo_c.txt:129088-129106 (Glyph::Glyph(this, char, color*, font*, tag*) @ 0x474a90)
*(uint32_t*)((char*)this_1 + 0x20) = arg5; // this->m_tag = tag
Glyph::SetFont(this_1, arg4);
int32_t eax_5 = *(uint32_t*)((char*)this_1 + 0x20);
if (eax_5 != 0)
InterlockedIncrement((eax_5 + 4)); // tag->m_cRef++
```
The copy assignment operator (used whenever a glyph is copied — e.g.
splicing the freshly-built `SmartArray<Glyph,1>` from `InqGlyphs` into
the live `List<Glyph>`, or `List<Glyph>::flush`'s per-node teardown)
does the matching release-then-acquire:
```
acclient_2013_pseudo_c.txt:128949-128969 (Glyph::operator= @ 0x474870)
class TextTag* m_tag_1 = this->m_tag;
if (m_tag_1 != 0)
{
if (InterlockedDecrement(&m_tag_1->m_cRef) == 0 && m_tag_1 != 0)
m_tag_1->vtable->__vecDelDtor(1); // release old tag, free at zero
this->m_tag = nullptr;
}
class Font* m_font = arg2->m_font;
this->m_font = m_font;
this->m_tag = arg2->m_tag;
if (this->m_tag != 0)
InterlockedIncrement(&this->m_tag->m_cRef); // acquire new tag
```
**Releasing decrements the count and self-deletes at zero, via the
destructor**:
```
acclient_2013_pseudo_c.txt:128905-128925 (Glyph::~Glyph @ 0x474820)
class TextTag* m_tag = this->m_tag;
if (m_tag != 0)
{
if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0)
m_tag->vtable->__vecDelDtor(1);
this->m_tag = nullptr;
}
```
**`Glyph::SetTag` is the exception to note carefully** — it releases the
*old* tag (decrement, free at zero) but does **not** increment the
refcount of the incoming tag:
```
acclient_2013_pseudo_c.txt:128977-128993 (Glyph::SetTag @ 0x474920)
void Glyph::SetTag(class Glyph* this, class TextTag* arg2)
{
class TextTag* m_tag = this->m_tag;
if (m_tag == 0)
{
this->m_tag = arg2;
return;
}
if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0)
m_tag->vtable->__vecDelDtor(1);
this->m_tag = nullptr;
this->m_tag = arg2;
}
```
Every call site of `Glyph::SetTag` actually observed in this decompile
passes `nullptr` for `arg2` (§2.2's three excerpts, plus the identical
pattern at `acclient_2013_pseudo_c.txt:126830`,
`GlyphList::RemoveTextTag`). In practice `SetTag` is only ever used as
"sever this glyph's reference to whatever tag it has" — a porting
engineer must not assume the general two-argument form is
refcount-safe for a non-null argument; if a call site with a non-null
tag is ever found, it must AddRef beforehand, mirroring the
constructor/`operator=` pattern above.
### 3.2 Whole-tag invalidation on any edit that could split a run
This is the single most important porting gotcha in this document.
None of the three "does this edit touch a tag boundary" checks in §2.2
try to *split* a run in two. All of them, on detecting that an edit
would break contiguity, call `Glyph::SetTag(i, nullptr)` on **every
glyph in the entire `GlyphList` that shares that tag pointer** — not
just the glyphs adjacent to the edit. See `GlyphList::RemoveTextTag`:
```
acclient_2013_pseudo_c.txt:126822-126833
void __thiscall GlyphList::RemoveTextTag(class GlyphList* this, class TextTag* arg2)
{
if (arg2 != 0)
{
for (class ListNode<Glyph>* i = this->m_glyphList._head; i != 0; i = i->next)
{
if (i->data.m_tag == arg2)
Glyph::SetTag(i, nullptr);
}
}
}
```
`GlyphList::Insert`'s boundary check (§2.2) calls exactly this function
when it detects a would-be-split. `GlyphList::Delete` and
`GlyphList::AddText` inline the identical "walk the whole list, clear
every glyph sharing this tag" loop rather than calling
`RemoveTextTag` directly, but the effect is the same. **The retail
behaviour is: any edit that would leave a discontiguous run under one
tag pointer instead destroys the tag for the ENTIRE list, not just the
disturbed portion.** A tagged player name that gets partially edited or
partially deleted loses its colour/clickability everywhere it appears
in that `GlyphList`, not just at the edit site.
### 3.3 Scroll-off / truncation — `ChatInterface::TruncateChatLog`
```
acclient_2013_pseudo_c.txt:247098
004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2)
```
This reads the chat log's current text length (via
`UIElement_Text::GetText`, which is backed by `GlyphList::InqText`, §2.2)
and, if it exceeds the cap (`arg2`), calls:
```
acclient_2013_pseudo_c.txt:247148,247178
UIElement_Text::BeheadText(this->m_chatLog, N, 1);
```
`BeheadText` is a thin wrapper:
```
acclient_2013_pseudo_c.txt:116727-116731 (UIElement_Text::BeheadText @ 0x469970)
void __thiscall UIElement_Text::BeheadText(class UIElement_Text* this, uint32_t arg2, uint8_t arg3)
{
UIElement_Text::DeleteSection(this, 0, arg2, arg3);
}
```
which in turn calls `GlyphList::Delete` (`acclient_2013_pseudo_c.txt:
116680`, inside `UIElement_Text::DeleteSection @ 0x469800`) — **the
exact same generic deletion path used for any other text edit** (typed
backspace, cut, selection delete). There is no special-cased "truncate
the chat log" tag handling. Consequences, following directly from §3.1
and §3.2:
- A `TextTag` whose glyphs are entirely scrolled off is destroyed the
ordinary way: `GlyphList::Delete` walks the doomed range, finds the
shared tag, `Glyph::SetTag(..., nullptr)`s every glyph that shares it
(§3.2), decrementing to zero and freeing it.
- A `TextTag` whose glyphs are only **partially** scrolled off (the
truncation boundary falls inside a tagged name) has the tag stripped
from **all** its glyphs, including the ones that remain visible — per
§3.2's whole-list behaviour. The surviving remnant of a truncated
tagged name renders and behaves as plain untagged text. This is a
concrete, verified retail behaviour, not a hypothesis — it falls
directly out of `GlyphList::Delete`'s implementation, which
`BeheadText`/`TruncateChatLog` invoke with no special-casing.
---
## 4. Colour rule — property `0x1b` (font colour) vs. `0x1d` (tag font colour)
### 4.1 `UIElement_Text`'s "current" state fields
```
acclient.h:53392-53420 (struct UIElement_Text, relevant fields with confirmed field order)
RGBAColor m_curFontColor; // used for the property "0x1b" value
Font *m_curFontObj; // used for the property "0x1a" value
RGBAColor m_curTagFontColor; // used for the property "0x1d" value
unsigned int m_curOutlineColor;
```
The field ORDER in the header (`m_curFontColor`, `m_curFontObj`,
`m_curTagFontColor` back to back) matches the raw offset arithmetic seen
at the glyph-construction site in `InqGlyphs`
(`acclient_2013_pseudo_c.txt:116153-116162`, this-relative offsets
`0x6a4` for `m_curFontColor` and `0x6b8` for `m_curTagFontColor`, a
`0x14`-byte gap = 16 bytes of `RGBAColor` + 4 bytes of the `Font*`
pointer in between) — this cross-check confirms the struct layout
against the pseudo-C's raw pointer math.
### 4.2 Setting them per append — `UIElement_Text::AppendStringInfoWithFont`
```
acclient_2013_pseudo_c.txt:117031-117048
00469de0 void __thiscall UIElement_Text::AppendStringInfoWithFont(class UIElement_Text* this, class StringInfo const* arg2, int32_t arg3, int32_t arg4)
{
UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3);
UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4);
UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4);
...
UIElement_Text::AddText_Internal(this, m_charbuffer, 3);
...
}
```
Both colour properties are refreshed from the **same caller-supplied
index**, `arg4`, immediately before the string is appended
(`AddText_Internal``InqGlyphs`, §2.3). The same three-call pattern
(`0x1a`/`0x1b`/`0x1d`, same index argument) recurs at every other append
entry point that carries a colour index:
`UIElement_Text::AppendText`/`AppendStringInfo`'s shared helper at
`acclient_2013_pseudo_c.txt:115213-115222`, the string-download
completion handler at `acclient_2013_pseudo_c.txt:117098-117104` (index
taken from the queued download's own stored index,
`ebx_2[0x25]`/`ebx_2[0x26]`), and the reset-to-index-0 call at
`acclient_2013_pseudo_c.txt:117546-117548`.
### 4.3 What `SetFontColorHelper` actually does — an indexed property array
```
acclient_2013_pseudo_c.txt:113699 (UIElement_Text::SetFontColorHelper @ 0x466ac0)
void __thiscall UIElement_Text::SetFontColorHelper(class UIElement_Text* this, uint32_t arg2 /*propId*/, class RGBAColor* arg3 /*out*/, uint32_t arg4 /*index*/)
```
Simplified control flow (BN's exact vtable-offset dispatch on `0xf0`,
`0xf4`, `0x98` is not named by the PDB — see caveat below):
1. `this->vtable->InqProperty(propId, &var_10)` — looks up the
UIElement's own authored property (`0x1b` or `0x1d`) via its normal
`UIElement` property mechanism, i.e. this is a
**LayoutDesc/DAT-authored per-element property**, not global engine
state.
2. If found, the returned property object is treated as an *indexed
collection*: a virtual call through vtable offset `+0xf0`
(`acclient_2013_pseudo_c.txt:113743`) that plausibly returns the
collection's element count into `arg2`; a bounds check
`if (arg4 < arg2)`; then a virtual call through `+0xf4`
(`acclient_2013_pseudo_c.txt:113758`) that plausibly fetches the
sub-property at index `arg4`; then a virtual call through `+0x98`
(`acclient_2013_pseudo_c.txt:113761`) that plausibly extracts an
`RGBAColor` from that sub-property into the caller's `arg3` output.
3. If any step fails (property absent, index out of range), `arg3` (the
caller's `m_curFontColor`/`m_curTagFontColor`) is left untouched —
i.e. it retains whatever colour it already held from a previous
append.
**Caveat**: BN does not resolve names for the `+0xf0`/`+0xf4`/`+0x98`
virtual calls (they're dispatched through a generic `BaseProperty`-family
vtable, and the pseudo-C prints them as raw `(*(uint32_t*)(vtable +
offset))(...)` calls). The functional interpretation above
("count / get-at-index / get-color") is inferred from the argument
shapes and control flow (an `arg4 < count` bounds check immediately
followed by an index-parameterized fetch), not from a symbol. Treat the
step-by-step mechanics as **probable, not certain** — the porting-load-
bearing fact that IS certain is the *outcome*: property `0x1b` and
property `0x1d` are each an array of colours on the `UIElement_Text`,
indexed by the same `arg4` the caller supplies, and `SetFontColorHelper`
resolves one colour from each array into `m_curFontColor` /
`m_curTagFontColor` respectively before the string is walked into
glyphs. This lines up with `claude-memory/project_chat_digest.md`'s
`LogTextType colors` note — `arg4` is almost certainly the `LogTextType`
of the message being appended (a per-message-category colour index),
and property `0x1d` is a **parallel, per-category array of "tag"
colours** — i.e. retail authors one link/tag colour per chat category,
not one global link colour.
### 4.4 Which colour a glyph actually gets — the `m_type == 0x10000001` gate
Back in `UIElement_Text::InqGlyphs`, per character, the code picks
between the two "current" colours based on whether a tag is open **and**
that tag's `m_type` equals a specific sentinel:
```
acclient_2013_pseudo_c.txt:116150-116162
void* edx_15;
void* esi_6;
if ((ebx_1 == 0 || *(uint32_t*)((char*)ebx_1 + 8) != 0x10000001))
{
esi_6 = esp_1[6]; // this (UIElement_Text*)
edx_15 = ((char*)esi_6 + 0x6a4); // &this->m_curFontColor
}
else
{
esi_6 = esp_1[6];
edx_15 = ((char*)esi_6 + 0x6b8); // &this->m_curTagFontColor
}
```
(`ebx_1 + 8` is `TextTag::m_type`, per the struct layout in §1.1.)
`edx_15` is then passed straight into the parameterized `Glyph`
constructor as the colour source (`acclient_2013_pseudo_c.txt:116173-
116180`). So, precisely:
- No open tag (`ebx_1 == 0`) → glyph gets `m_curFontColor` (property
`0x1b`'s indexed value).
- Open tag, but its `m_type != 0x10000001` → **still**
`m_curFontColor`. Not every tag type gets the special colour.
- Open tag with `m_type == 0x10000001` → glyph gets `m_curTagFontColor`
(property `0x1d`'s indexed value).
**This is the exact rule the task asked for**: property `0x1b` is the
default/base colour used for all untagged text and for any tag whose
type isn't the specially-recognized one; property `0x1d` is used only
for glyphs inside a tag of that one recognized type, and both are
selected from the same caller-supplied colour-category index. See §7
for what is and is not known about what `0x10000001` names.
### 4.5 "Currently open tag" state
There is **no persistent "currently open tag" field on `UIElement_Text`**
`m_curFontColor`/`m_curFontObj`/`m_curTagFontColor` are the *colour
palette currently in effect for this append call* (refreshed once per
`AppendStringInfoWithFont`/`AppendText` call from the indexed DAT
properties), not per-tag state. The actual "is a tag open right now,
and which one" state during the character walk is the **local variable
`ebx_1` inside `UIElement_Text::InqGlyphs`'s loop** (§2.3) — it does not
outlive one call to `InqGlyphs`/`AddText_Internal`. Each append call
starts fresh with no tag open, and the markup embedded in that call's
own string is what opens/closes tags within it.
---
## 5. Click dispatch
Each concrete subclass's `HandleClick` (vtable slot `+0x14`, per §1.2)
forwards to a `ECM_UI::SendNotice_TextTag_*Click` free function, which —
per the `NoticeHandler` vtable declared in `acclient.h:30237-30240` — is
a broadcast notice any registered `NoticeHandler` (e.g. the chat/social
UI) can receive via a matching `RecvNotice_TextTag_*Click` virtual:
```
acclient_2013_pseudo_c.txt:133078-133085 (TextTag_DID::HandleClick @ 0x478740)
ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id);
acclient_2013_pseudo_c.txt:133521-133528 (TextTag_IID::HandleClick @ 0x478e80)
ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID);
acclient_2013_pseudo_c.txt:133328-133335 (TextTag_IIDEnum::HandleClick @ 0x478b40)
ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum);
acclient_2013_pseudo_c.txt:133150-133157 (TextTag_IIDString::HandleClick @ 0x478840)
ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string);
```
matching the `NoticeHandler` vtable slots:
```
acclient.h:30237-30240
void (__thiscall *RecvNotice_TextTag_DIDClick)(NoticeHandler *this, unsigned int, IDClass<_tagDataID,32,0>);
void (__thiscall *RecvNotice_TextTag_IIDClick)(NoticeHandler *this, unsigned int, unsigned int);
void (__thiscall *RecvNotice_TextTag_IIDEnumClick)(NoticeHandler *this, unsigned int, unsigned int, unsigned int);
void (__thiscall *RecvNotice_TextTag_IIDStringClick)(NoticeHandler *this, unsigned int, unsigned int, PStringBase<unsigned short> *);
```
This strongly supports the observed behaviour ("clicking a speaker's
name in chat prefills a tell to them"): a chat name is plausibly tagged
`TextTag_IIDString`, carrying the speaker's `IID` (their in-world
object id — the correct addressee for a `/t` tell) and `m_string`
(plausibly the speaker's display name — needed because the retail tell
command syntax is name-based, not id-based). Clicking dispatches
`ECM_UI::SendNotice_TextTag_IIDStringClick(type, IID, &name)`, and
whichever `NoticeHandler` owns the chat input box (this document did
not trace that far — **UNKNOWN, needs a search of `RecvNotice_TextTag_
IIDStringClick` overrides across the UI classes to find which panel
consumes it and confirm it prefills `/t "name" `**) reacts by loading
that into the input field.
**Not traced from HandleClick backward to the mouse-hit-test that finds
"which glyph, hence which tag, is under the cursor."** No literal
`->vtable->HandleClick(...)` call site was found via text grep (it's an
indirect vtable call, invisible to a literal-string search); locating
the exact hit-test function that resolves a click's screen position to
a glyph index and reads that glyph's `m_tag` was out of scope for this
pass. **UNKNOWN — needs a targeted search for the `UIElement_Text`
mouse-down handler** (candidates: something built on
`GlyphList::FindPosFromLineAndPixels` @
`acclient_2013_pseudo_c.txt:127424`, which is already known to resolve
screen pixels to a glyph index and is a very likely component of that
path, but the actual click-to-`HandleClick` wiring was not confirmed).
---
## 6. Summary — the model to port
1. **Data model**: `TextTag` is a small polymorphic ref-counted object
(`m_type`, `m_format`, plus subclass payload — `IID`/`DID`/`enum`/
`string` in various combinations). `Glyph` carries an OPTIONAL
`TextTag*`. `GlyphList` is a flat list of `Glyph`; nothing above the
glyph level stores tag/run information.
2. **Attachment**: identity of a "run" is pointer equality on
consecutive glyphs' `m_tag`. No cached run table exists; every
consumer (insert-boundary check, delete-boundary check, append-
boundary check, text-serialization) re-derives it by walking
neighbours.
3. **Markup**: `<TYPE:FORMAT:DATA>` opens a tag (parsed by
`TextTagFactory::MakeTag`, dispatching on the `FORMAT` value to one
of 4 concrete classes); any bracketed text that fails to parse (in
particular the literal `<TYPE>`-shaped close marker emitted by
`TextTag::BuildEndTag`) closes the currently-open tag. The delimiter
text itself is never rendered as glyphs.
4. **Lifetime**: pure intrusive refcounting. Adopting a tag reference
(construction, copy-assignment) increments; releasing (destruction,
explicit clear, reassignment) decrements and self-deletes at zero.
**Any edit that would split a tagged run instead strips the tag from
every glyph in the WHOLE `GlyphList` that shares it** — there is no
run-splitting. Chat-log truncation (`TruncateChatLog`
`BeheadText``DeleteSection``GlyphList::Delete`) is not
special-cased; it goes through this exact same path, so a tagged
name straddling the truncation boundary loses its tag entirely, even
on the surviving portion.
5. **Colour**: two parallel, per-`UIElement_Text`, DAT-authored,
index-selected colour arrays — property `0x1b` (base/default) and
property `0x1d` (tag colour) — refreshed from the same caller
colour-category index at the top of every append call. A glyph gets
the `0x1d` colour only if a tag is open AND that tag's `m_type`
equals the sentinel `0x10000001`; otherwise it gets the `0x1b`
colour regardless of whether some *other* kind of tag is open.
6. **Click**: each concrete `TextTag` subclass's `HandleClick`
broadcasts a `NoticeHandler`-family notice
(`ECM_UI::SendNotice_TextTag_*Click`) carrying its payload; some
listener elsewhere (not traced in this pass) reacts to populate chat
input, open a character sheet, etc., depending on subclass/`m_type`.
---
## 7. Open questions / explicitly unresolved
- **What symbolic name does `TextTag::m_type == 0x10000001` correspond
to?** `TextTagType` has no recovered named enum (`acclient.h:62585` is
a bare typedef). The literal `0x10000001` recurs pervasively
elsewhere in the pseudo-C for apparently unrelated purposes (dialog
IDs, `StringInfo::SetStringIDandTableEnum` table-enum arguments,
keymap IDs — see the broad grep hits at
`acclient_2013_pseudo_c.txt:2334,135182,149121,150950,154810,...`),
which suggests it's a low, sequential "category 1" id reused across
several small per-subsystem enums rather than one global constant
with a single meaning — i.e. seeing the same literal elsewhere is
**not** evidence about what it means for `TextTag`. Both
`EnumMapper::InqEnum`/`InqString` route through a table id of `0x18`
(§1.3), which is very likely a DAT-resident enum/string table (the
lookup falls through `MasterDBMap::DivineType`-style DBObj resolution
seen in `EnumMapper::GetEnumByDID`,
`acclient_2013_pseudo_c.txt:29890-29937`, for other DID categories),
meaning the actual keyword strings (e.g. whatever text maps to
`m_type == 1`) live in a DAT string/enum table, not as a compiled
string literal — grepping for literal tag keywords like `"IID"`,
`"DID"` in the pseudo-C found nothing. **Needs**: pulling DAT category
`0x18`'s EnumMapper table contents (likely in
`client_local_English.dat` or `client_portal.dat`) to find the actual
keyword-to-`m_type` mapping, the same way
`claude-memory/project_settings_options_digest.md`'s `GetNameFromKey`
work pulled DAT tables `0x2300000A`/`0x2300000B`/`0x23000007`.
- **Exact wording of the two `sprintf` format strings** at
`acclient_2013_pseudo_c.txt:133651` (`u"<%ls:%ls%:%ls>"`) and
`acclient_2013_pseudo_c.txt:133728` (`u"<\%ls>"`). Binary Ninja's
string-literal rendering is known to mis-escape embedded
slashes/percents in this codebase; both are flagged inline in §1.3/§2.3
as probable artifacts (a stray `%` in the first, a `\` that's likely a
literal `/` in the second). **Needs**: a raw byte dump of the two
`.rdata` string constants at their addresses (not BN's decompiled
preview) to confirm exact bytes before porting the exact markup
syntax.
- **The exact semantics of `SetFontColorHelper`'s three virtual calls**
(vtable offsets `+0xf0`, `+0xf4`, `+0x98`, §4.3) are inferred from
control flow, not named by the PDB. The functional summary (indexed
colour array) is believed correct, but the precise interface
(`BaseProperty`'s exact virtual table) was not independently
confirmed against `acclient.h`'s `BaseProperty`-family struct
definitions in this pass.
- **The mouse-hit-test → `HandleClick` wiring** (§5) — which function
resolves a click position to a glyph, reads its `m_tag`, and invokes
`HandleClick` through the vtable — was not located in this pass (no
literal-text call site exists to grep for an indirect vtable call).
- **Which `NoticeHandler` override actually consumes
`RecvNotice_TextTag_IIDStringClick` and prefills the chat input** —
not traced. This is the last link needed to fully confirm "clicking a
chat name populates a `/t` tell," though the `IID` + name payload
shape on `TextTag_IIDString` makes it the overwhelmingly likely
candidate tag type for that UI behaviour.

View file

@ -0,0 +1,650 @@
# Retail chat WINDOW shell — window model, filters, scrollback, chrome
**Date:** 2026-08-21
**Status:** RESEARCH ONLY. No source files touched.
**Scope:** retail's chat window SHELL and DISPLAY behavior — window
management, filtering, scrollback, chrome/interaction, multi-window,
line-composition structure, and other user-visible window mechanics.
**Explicitly out of scope** (covered by sibling research this session):
glyph text-tag coloring, clickable/colored names, tag click dispatch, and
acdream's own current UI code. This document does not re-derive anything
already answered there.
**Primary sources**
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build, Binary Ninja pseudo-C, PDB-named)
- `docs/research/named-retail/acclient.h` (verbatim retail struct/enum defs)
- `docs/research/named-retail/symbols.json`
**Notes read first so this extends rather than repeats:**
- `docs/research/2026-08-09-chat-retail-window-shell.md` (CH6 shell research
— window lifecycle/identity, LayoutDesc geometry, resize model, opacity,
persistence, multi-window/floaty mechanics). **This document is the
authority for §1 window-lifecycle mechanics, §4 chrome/resize/opacity, and
§5 tabs/multi-window — I only summarize its findings below with pointers,
and add what it doesn't cover:** scrollback/truncation, the exact
window-ID routing predicate as a single decompiled function, structural
line-composition order, and the unseen-text/auto-scroll interaction.
- `docs/research/2026-08-09-chat-retail-color-table.md` §4 (filter storage,
`m_llTextTypeFilter`, `PostInit` seeded defaults) — I summarize and do not
re-derive; I use its findings to cross-check the routing function decoded
fresh below.
- `docs/plans/2026-08-09-chat-parity-campaign.md` — Campaign CH plan/ledger.
**Binary-Ninja caveats (apply throughout, per
`claude-memory/feedback_bn_decomp_field_names.md`):** BN's struct-field
attribution in `ChatInterface::PostInit`/`gmMainChatUI::PostInit` is shifted
by one slot relative to the true member order — the window-shell doc already
documented this for the main window's border elements. I hit the same
artifact in `ChatInterface::PostInit`'s `GetChildRecursive` binding sequence
(§1) and resolve it the same way: against the verbatim struct order in
`acclient.h:54898-54912`, which is authoritative and does not shift.
---
## 1. Window model
### 1.1 How many windows, and how they're identified
Confirmed against `acclient.h:54898-54912` (verbatim `ChatInterface`
struct):
```cpp
/* 6041 */
struct __cppobj ChatInterface : gmNoticeHandler, UIElement_Field
{
unsigned int m_eWindowID;
float m_fDefaultOpacity;
float m_fActiveOpacity;
float m_fCurrentOpacity;
UIElement_Text *m_chatEntry;
UIElement_Text *m_chatLog;
UIElement *m_chatNewNonVisibleTextIndicator;
unsigned __int64 m_llTextTypeFilter;
UIElement_Text *m_pChatTargetButtonText;
PStringBaseArray<unsigned short> m_InputHistory;
unsigned int m_LastInputHistoryPos;
ClientCommunicationSystem *m_pCCS;
};
```
Per the window-shell doc §1.2/§4.1 (not re-derived here): **five** live
chat windows exist — the main window (`m_eWindowID == 8`) and four floating
windows (`m_eWindowID == 2..5`). `m_eWindowID == 0` is the **UNAUTHORED
constructor default** (`ChatInterface::ChatInterface @0x004F4550` sets
`this->m_eWindowID = 0;` before `PostInit` reads the real value off the
LayoutDesc attribute `0x1000007E`). All five windows are authored,
always-resident children of the gameplay-UI root — there is no
runtime-allocated window registry (window-shell doc §1.1).
The SpewBox (`gmSpewBoxUI`) is a **separate, unrelated class** — not a
`ChatInterface` subclass, not part of this window-id space (per
`claude-memory/project_chat_digest.md`).
### 1.2 The wire-to-window routing predicate — one function, load-bearing
`ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640` is the single
function every displayed chat line passes through. Its **head** (the routing
decision, before any text is appended) is:
```
004f4640 void __thiscall ChatInterface::RecvNotice_DisplayFinalStringInfo(
class ChatInterface* this, uint32_t arg2 /*type*/,
class StringInfo const* arg3 /*body*/,
class StringInfo const* arg4 /*prefix*/, uint32_t arg5 /*windowId*/)
004f4640 {
004f4640 uint32_t eax_7 = arg5;
004f4652 if (eax_7 == this->m_eWindowID)
004f4652 {
004f467c label_4f467c:
… (appends — see §3/§6) …
004f4652 }
004f4652 else if ((eax_7 == 0 && ChatInterface::TypeIsActive(this, arg2) != 0))
004f4666 goto label_4f467c;
004f4640 }
```
**The predicate is exactly: `windowId == m_eWindowID` OR (`windowId == 0`
AND `TypeIsActive(type)`).** This is an ADDRESS-vs-BROADCAST model, not a
"windows subscribe to a channel" model:
- A line sent with a **specific windowId** (matching an already-open target
window, e.g. a command whose output is explicitly directed at the window
that issued it — `m_idCurrentCommandSource` per the color-table doc §4)
is shown **only** in that one window, unconditionally — the destination
window's own filter is never consulted for an address-targeted line.
- A line sent with **windowId == 0** ("broadcast") is shown in **every**
window whose own `TypeIsActive(type)` (i.e. its 64-bit
`m_llTextTypeFilter`, decoded in the color-table doc §4) says yes. This is
how the same "Sio says, ..." line can land in the main window and in a
floating window simultaneously if both have `Speech` enabled.
- **Window id 0 is therefore never itself a *window* — it is the broadcast
sentinel value on the wire/call parameter, exactly as the window-shell
doc's goal-window addendum states.** No live `ChatInterface` instance ever
keeps `m_eWindowID == 0` after `PostInit` runs.
`ChatInterface::TypeIsActive @0x004F2F10` (cited, not re-derived, per the
color-table doc §4) is `(1ULL << type) & m_llTextTypeFilter`.
---
## 2. Filters
**Fully decoded already in `2026-08-09-chat-retail-color-table.md` §4 — not
re-derived here.** Summary for completeness of this document's structure:
- Storage: 64-bit `ChatInterface::m_llTextTypeFilter` (`acclient.h:54907`),
read from `PlayerModule::InqChatWindowOption(windowId, 0x1000007F, …)`
(`ChatInterface::UpdateFromPlayerModule @0x004F3920`) and live-updated via
`RecvNotice_GameplayOptionChanged @0x004F30E0`.
- Test: `ChatInterface::TypeIsActive @0x004F2F10` — `(1ULL << type) &
m_llTextTypeFilter`, used both for the broadcast-routing predicate (§1.2)
and, per the color-table doc, nowhere else.
- **`PostInit`'s per-window seeded default** switches on `m_oldState`
(`ChatInterface::PostInit @0x004F3DD0`, `0x004f3df9`): 1 and 8 (the main
window) get `0xFBFFFFFF` low-dword (everything except client-local `0x1A`);
2 (floaty 1) gets Speech/Tell/Speech_Direct_Send/Emote; 3 (floaty 2) gets
Social/Social_Send/Allegiance; 4 (floaty 3) gets Fellowship; 5 (floaty 4)
gets the four Turbine rooms General/Trade/LFG/Roleplay. Every default's
HIGH dword is 0 — Society (`0x20`) and the reserved `0x21` slot start
disabled in **every** window and must be opted into by the user.
- User edit path: `gmChatOptionsUI::InitOptions @0x0049FC60` /
`AddCheckboxBitfield64Option @0x0049EDA0` build one checkbox-grid `SetUserData`
block per window id (main = id 8, with its own dedicated Society checkbox
child at `0x0049FEFB`).
- Squelching is a **separate axis** from filtering:
`LogTextTypeEnumMapper::IsLegalChannel @0x006AFF40` whitelists a 14-value
subset of `LogTextType` as squelchable at all; it has no interaction with
`m_llTextTypeFilter`.
Nothing new to add here beyond what the color-table doc already covers —
the routing predicate decoded fresh in §1.2 above is a second, independent
confirmation of the same "`windowId==0` → filter-gated broadcast" model that
doc's §4 described from `RecvNotice_DisplayFinalStringInfo`'s citation
alone; this document supplies the full decompiled function body.
---
## 3. Scrollback
### 3.1 The cap, the trigger, and the trim target
Still inside `RecvNotice_DisplayFinalStringInfo @0x004F4640`, immediately
after the body append (full excerpt with the append order in §6):
```
004f4701 int32_t m_chatLog_1 = this->m_chatLog;
004f4711 if (*(uint32_t*)(m_chatLog_1 + 0x61c) > 0x2710)
004f4711 {
004f4713 int32_t var_14_4 = 0x1d4c;
004f471a m_chatLog_1 = ChatInterface::TruncateChatLog(this, m_chatLog_1);
004f4711 }
```
`0x2710` = **10,000**, `0x1d4c` = **7,500**. The field read at transcript
offset `+0x61C` tracks the transcript's total **character** count (not a
line count) — retail's scrollback limit is a character budget, not a
fixed number of retained lines. **Trigger: transcript exceeds 10,000
characters. Target: trim back down toward ~7,500.** This runs on every
appended line once the log is over budget — it is not a periodic/timed
sweep, it is inline in the same call that just displayed the line.
### 3.2 The truncation rule — trims at a newline boundary, not mid-line
`ChatInterface::TruncateChatLog @0x004F4290` (arg2 = target length, 7500 at
the only call site found):
```
004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2)
004f4290 {
text = GetText(m_chatLog); // live PStringBase
currentLen = text.length; // *(len_ptr - 4)
004f42c2 if (currentLen <= arg2)
return; // under budget — no-op
004f42c2 else
{
004f42c8 excess = currentLen - arg2; // chars over target
… PStringBaseIter_Common<unsigned short>::FindChar(iter, "\n", 1) …
// search FORWARD from the excess offset for the next '\n'
004f4350 if (found && (excess - foundPos) < (currentLen / 10))
004f4360 BeheadText(m_chatLog, foundPos + 1, 1); // cut at that newline
else {
… FindChar(iter, "\n", 0) … // search again, other direction arg
004f43f0 if (found2 && (foundPos2 - excess) < (currentLen / 10))
goto (the same BeheadText-at-newline path)
004f4403 else
BeheadText(m_chatLog, excess, 1); // fallback: cut at the raw excess offset
}
004f4290 }
```
Reading this at the BN pseudo-C level is genuinely uncertain past the
overall shape — **flagging per the assignment's constraint rather than
guessing**: the `0xCCCCCCCD` multiply + `HIGHD(...) >> 3` pair is the
standard MSVC constant-division-by-10 idiom (`length / 10`), and the two
`FindChar` calls with a `PStringBase(&data_79c288)` needle (confirmed below,
§3.3, to be a single `\n` character) plus `UIElement_Text::BeheadText` are
unambiguous. **UNKNOWN — needs a live cdb capture with real transcript
content to nail down exactly:** whether the two `FindChar` calls search in
opposite directions from the excess offset (my reading above) or whether
one is a fallback re-search after the first's 10%-tolerance check fails for
a different reason; the two `arg3` values passed to `FindChar` (`1` then
`0`) are almost certainly a direction or "case-sensitive/whole-word" flag,
but the pseudo-C never names the parameter. **What is certain and
sufficient to port:** truncation removes text from the FRONT of the
transcript (`BeheadText`), it PREFERS a boundary within the char that
begins the next `\n`-terminated line rather than a raw char-offset cut
(there's a ~10%-of-current-length tolerance band around the target for
preferring the newline-aligned cut), and it falls back to an exact
char-offset behead only if no acceptable newline is found nearby.
### 3.3 The separator character — confirms `\n`, not `\r\n`
```
0079c280 data_79c280: 0d 00 0a 00 00 00 00 00 // L"\r\n" — used elsewhere, NOT here
0079c288 data_79c288: 0a 00 00 00 00 00 00 00 // L"\n" — the separator + the TruncateChatLog needle
```
`data_79c288` is passed both as the inter-line separator string appended in
`RecvNotice_DisplayFinalStringInfo` (§6) and as the `FindChar` needle in
`TruncateChatLog` above — confirming truncation genuinely searches for line
breaks, i.e. it is line-boundary-aware even though the budget itself is
counted in characters.
### 3.4 Auto-scroll / "stick to bottom" — `IsAtVerticalEnd` + `ScrollToPosition`
`UIElement_Text::IsAtVerticalEnd @0x00469350`:
```
00469350 uint8_t __fastcall UIElement_Text::IsAtVerticalEnd(class UIElement_Text* this)
00469350 {
lineCount = this->m_glyphList.m_glyphList._num_elements;
00469359 if (lineCount == 0)
return 1; // empty log counts as "at end"
00469360 lastLineIndex = lineCount - 1;
00469369 return UIElement_Text::IsPositionInView(this, &lastLineIndex);
00469350 }
```
**This is not a scroll-offset comparison — it is "is the last line
currently visible inside the viewport right now."** `IsPositionInView` is
the same hit-test the widget uses for click-to-position, applied to the
transcript's own final line.
`RecvNotice_DisplayFinalStringInfo` captures this **before** appending the
new line, then decides what to do with it **after** appending and
truncating:
```
004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // BEFORE the new line lands
… append prefix, append body, truncate if over budget (§6, §3.1) …
004f4723 if (ebx != 0)
004f4723 {
004f4732 UIElement_Text::ScrollToPosition(m_chatLog, currentLineCount); // re-stick to the new bottom
004f4739 return;
004f4723 }
004f4723 else
004f473c this->m_chatNewNonVisibleTextIndicator->vtable->SetState(1); // flag "unseen text" instead
```
**So: if the user was already looking at the bottom of the log, retail
scrolls the new line into view (sticky-bottom). If the user had scrolled up
into history, retail does NOT move their scroll position at all — it
instead lights the "new unseen text" indicator.** There is no separate
manual "scroll lock" toggle; this automatic per-line check IS retail's
scroll-lock mechanism. `m_chatNewNonVisibleTextIndicator` is a real
`UIElement*` field (`acclient.h:54906`), bound in `PostInit` from element
id `0x1000048C` — the 16×16 button the window-shell doc's layout dump
already placed at (21,62) in the main window and (5,169) in the floaties,
labeled there "new-unseen-text indicator (Button)" from the authored rect
alone; this document supplies the code that drives it.
### 3.5 Clearing the unseen-text flag
`ChatInterface::ListenToElementMessage @0x004F51C0`, click-message case,
`idElement == 0x1000048c`:
```
004f51f1 if (idElement == 0x1000048c)
004f51f1 {
if (m_chatEntry_or_chatLog != 0) // see field-shift caveat below
004f5208 UIElement_Text::ScrollToPosition(transcript, transcript->lineCount);
004f520d indicator->vtable->SetState(0xd);
}
```
**Field-attribution caveat:** this function's local variable is BN-named
`m_chatEntry` at the point it calls `ScrollToPosition`, but the object it
scrolls is described by `_num_elements` of its own `m_glyphList` — the
transcript's own line count, not the chat-entry input field's. Combined
with the ctor/struct order (§1.1) and the same-class shift already
documented in the window-shell doc for `PostInit`, the operation this
really performs is: **clicking the unseen-text indicator scrolls the
transcript to its own bottom and resets the indicator's own visual state**
(`SetState(0xd)`, a different state than the "flagged" `SetState(1)` set
when new text arrives while scrolled up) — i.e. clicking it is the user's
manual "catch up" action, and it un-flags itself. **Not independently
re-verified via cdb; treat the exact numeric visual STATE values (1 vs
0xd) as confirmed, but the specific field bound to "which object gets
scrolled to bottom" as inferred from the semantics of `IsAtVerticalEnd`
elsewhere, not a literal read of this function's own variable names.**
---
## 4. Window chrome & interaction
**Fully covered by the window-shell doc §1 and §2§4 — not re-derived
here.** Summary pointers:
- **Move/resize:** eight authored `UIElement_Resizebar` (type 9) grips per
window with per-grip bool properties `0x2A`/`0x2B`/`0x2C`/`0x2D`
(bottom/left/right/top); the main window's plain top edge strip is a
`UIElement_Dragbar` (type 2, move-only) rather than a ninth resize grip —
window-shell doc §2.1/§2.3, `UIElement_Resizebar::StartMouseResizing
@0x0046B7E0`.
- **Docking/anchoring:** none found — windows are free-floating, clamped
to stay on-screen only at restore time (`gmFloatyMainChatUI::MoveTo
@0x004D2D10:004d2d53-004d2dbb`).
- **Opacity:** two GLOBAL floats (`Option_DefaultOpacity_Property
0x10000080` unfocused, `Option_ActiveOpacity_Property 0x10000081`
focused), applied to the WHOLE composited window surface including text
via one `SetOpacity` call — window-shell doc §3, `ChatInterface::SetOpacity
@0x004F3120`. Per-class constructed starting values differ (main window
1.0/1.0 always-opaque, floaties 0.5/1.0) until a saved option overrides
them. Retail eases toward the target at 5%-of-delta per tick
(`ChatInterface::ListenToGlobalMessage @0x004F3840`); acdream currently
snaps (AP-190, window-shell doc §3.1).
- **Show/hide:** authored elements toggled via `SetVisible`, driven by
either a keybind (`Alt+1..4` for the floaties) or a generic
registered-action click dispatch — window-shell doc §1.3/§1.4.
- **Persistence:** two independent paths — the per-window `GameplayOptions`
blob (position/size/visible/title, gated on `m_eWindowID != 0`, i.e. the
main window's geometry is NEVER saved this way) and a separate local
screen-layout text file that IS the only path persisting the main
window's geometry — window-shell doc §4.
**One piece of chrome not covered by the window-shell doc — the talk-focus
menu (main window only):**
`gmMainChatUI::InitTalkFocusMenu @0x004CDC50` builds a dropdown menu (from
the button/group pair at elements `0x10000014`/`0x10000015`, window-shell
doc §2.1) with a squelch-toggle entry plus 13 target items, each carrying
an `Enum` attribute `0x1000000B` set to a distinct small integer (`1`
through `0xD`) that records which "talk focus" (broadcast target category)
that menu row represents:
```
004cdcd3 this->m_pSquelchToggleButton = UIElement_Menu::AddTextItem(eax_1, &var_90);
… (13x) …
004cdcfb UIElement::SetAttribute_Enum(eax_3, 0x1000000b, 5);
004cdd07 SmartArray<UIElement_Text *,1>::push_back(&this->m_aTalkFocusButtons, &var_94);
```
`gmMainChatUI::EnableSelection @0x004CE0A0` toggles individual rows'
enabled/greyed state (`SetState(0xd)` when Olthoi-locked); a companion
`RecvNotice_SelectionChanged @0x004CE050` re-syncs the menu's currently
highlighted target whenever the player's WORLD selection changes (via
`ACCWeenieObject::selectedID` and `PublicWeenieDesc::IsTalkable`) — this is
a **world-object selection** feed (F1-click on an NPC), not a transcript
text-tag click, and is out of this document's lane beyond noting that the
main window's talk-focus button exists and is driven from it. Only the
main window has this menu; floaty windows (window-shell doc §2.2) have
neither a talk-focus menu nor a max/min button, only a title bar and close
button.
---
## 5. Tabs / multiple windows
**Fully covered by the window-shell doc §1.1§1.4, §2, §4.1 — not
re-derived here.** Summary:
- There is no "tab" widget. The five windows (§1.1) are five separate,
independently positioned/sized/opaque floating panels, not tabs of one
container.
- **Creation:** none — all five exist from gameplay-UI construction; users
cannot create additional windows. **Naming:** each floaty window has an
editable title (`gmFloatyChatUI::SetWindowTitle @0x004CEAA0`, persisted
option `0x1000008D`) but the SET of windows is fixed at five; there is no
"new chat tab" affordance analogous to modern MMO UIs. **Closing:**
floaty windows close via their own title-bar close button
(`gmFloatyChatUI::ListenToElementMessage @0x004CE330`, element
`0x1000052A`) or the `Alt+N` toggle; the main window cannot be closed at
all (no close button is authored on it — window-shell doc §2.1's element
table has none). **Switching:** there is no focus-cycling shortcut found;
each window is an independent, simultaneously-visible panel, and
"switching" only means moving keyboard focus into a different window's
entry field by clicking it (which is what drives the opacity fade,
§4/window-shell doc §3).
- **Per-window state:** `m_eWindowID`, `m_llTextTypeFilter` (§2),
`DefaultOpacity`/`ActiveOpacity` (global, not per-window — window-shell
doc §3 correction), position/size/visible/title (§4), and the transcript
itself (`m_chatLog`, independently truncated per §3 — each window keeps
its own scrollback, so a floaty showing only Tells has its own 10k/7.5k
character budget separate from the main window's).
- The main window's four indicator buttons (`0x10000522`-`0x10000525`)
mirror the four floaties' visibility as one-directional state indicators,
not a tab strip — window-shell doc §1.4.
---
## 6. Timestamps, prefixes, and line composition order
### 6.1 The two-part composition model — confirmed structurally
`ClientSystem::AddTextToScroll @0x00563C50` is where a body string
(`arg2`), a `LogTextType` (`arg3`), a plugin-hook flag (`arg4`) and a
windowId (`arg5`) become the two `StringInfo` arguments
`RecvNotice_DisplayFinalStringInfo` receives. Its structurally relevant
branch (client-local `0x1A` short-circuits both the timestamp AND the local
log file):
```
00563de6 if (arg3 == 0x1a)
00563de6 {
// build body-only StringInfo, EMPTY prefix StringInfo
00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3, &bodyOnly, &emptyPrefix, windowId);
00563de6 }
00563de6 else
00563de6 {
00563dfb if (PlayerModule::DisplayTimeStamps(&playerModule) != 0)
00563dfb {
00563e24 wcsftime(&buf, 0x400, u"%#H:%M:%S ", localtime(&now)); // "H:MM:SS " — no date, trailing space
00563e39 PStringBase<unsigned short>::set(&prefixBuffer, &buf);
00563dfb }
… if (s_pLogFile) fprintf(s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer); // §7.4
}
```
**There is exactly ONE structural prefix element: the timestamp, and it is
entirely optional (gated on `PlayerModule::DisplayTimeStamps()`, a
character option toggle backed by `PlayerModule::options2_` bit 6 —
`PlayerModule::DisplayTimeStamps @0x005D39B0`: `return (options2_ >> 6) &
1`).** There is **no separate structural "channel name" prefix element**
(`[Fellowship]`, `[<name>]`, etc.) anywhere in this function or in
`RecvNotice_DisplayFinalStringInfo`. Channel-name brackets that DO appear
in retail's transcript (documented already, by content not structure, in
the color-table doc §3.3's channel-bit table) are baked directly into the
`arg2` body string by the SENDING handler (e.g.
`Handle_Communication__ChannelBroadcast`) before it ever reaches
`AddTextToScroll` — from this function's point of view there are only ever
two composed parts: prefix (timestamp-or-empty) and body.
### 6.2 The append order — separator, then prefix, then body
Back in `RecvNotice_DisplayFinalStringInfo @0x004F4640` (full body, per the
excerpts in §1.2/§3.1/§3.4 stitched together in call order):
```
004f467c if (this->m_chatLog->m_glyphList.m_glyphList._num_elements > 0)
004f4687 {
004f469a UIElement_Text::AppendTextWithFont(this->m_chatLog, L"\n", 0, arg2 /*type*/);
004f467c } // 1. separator (skipped on the very first line)
004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // captured BEFORE any of the below
004f46dc if (StringInfo::IsValid(arg4, 1) != 0)
004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4 /*prefix*/, 0, 0xc);
// 2. timestamp prefix — ALWAYS color idx 0x0C (grey), only if valid/non-empty
004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3 /*body*/, 0, arg2 /*type*/);
// 3. body — colored by the wire LogTextType
```
**Fixed structural order: `[\n if not first line] → [timestamp, if
enabled] → [body]`.** The leading separator is a property of the LOG
(inserted once per new entry, before the entry, so the transcript never
starts with a blank line), not a property of the entry itself — a port that
appends `body + "\n"` per-line instead of `"\n" + body` will still LOOK
identical on screen but will behave differently under `TruncateChatLog`'s
newline-boundary search (§3.2) and under `IsAtVerticalEnd` line-counting
(§3.4) if the two approaches disagree at the very first/last line. The
color assignment itself is the color-table doc's territory (not re-derived
here) — the load-bearing NEW fact this document adds is the *order* and
that the timestamp is unconditionally color index `0x0C` regardless of the
body's own type, which the color-table doc §3.2 already states from the
same address; this document supplies the surrounding append sequence and
confirms the timestamp's StringInfo is `arg4`, always appended strictly
BEFORE the body `arg3`, never interleaved or after.
### 6.3 Timestamp format, verbatim
`u"%#H:%M:%S "` fed to `wcsftime` — hour without a leading zero, minute,
second, **no date**, one trailing space baked into the format string
(explaining why no separate space-insertion code exists between prefix and
body — the prefix string itself carries its own trailing separator).
---
## 7. Other user-visible window behaviors
### 7.1 Local session log file — a port would miss this
`ClientSystem::s_pLogFile` — a plain-text file retail writes chat lines to
during the session, independent of the on-screen transcript's 10k/7.5k
character budget (§3.1) or any window's filter (§2). Written from the same
`AddTextToScroll` branch that builds the on-screen timestamp (§6.1):
```
00563e5b fprintf(ClientSystem::s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer);
```
Client-local type `0x1A` text (§6.1's short-circuit branch) explicitly
bypasses this — client-local errors/refusals never reach the log file,
only the on-screen transcript. **UNKNOWN — needs further grep:** the log
file's path/naming convention and whether it rotates per-session or
per-character; not chased further as it's a filesystem-artifact question
more than a window-UI one, but flagged because "retail also writes a
plain-text chat log to disk" is exactly the kind of behavior a UI-only port
would miss entirely.
### 7.2 Unread/unseen marker — confirmed, see §3.4/§3.5
The `0x1000048C` "new unseen text" indicator button IS retail's unread
marker. It is per-window (each `ChatInterface` owns its own
`m_chatNewNonVisibleTextIndicator`), lights when a broadcast/addressed line
arrives while the user has scrolled away from the bottom, and clears when
the user clicks it (which also snaps the transcript back to its bottom).
There is no separate "flash the window" or "flash the taskbar/app icon" —
`FlashWindow`/`FlashWindowEx` do not appear anywhere in the pseudo-C dump
(checked via a whole-file grep; zero hits).
### 7.3 Sound cues on incoming chat — UNKNOWN, likely none dedicated
A targeted grep for `PlaySound`/`SoundManager::Play*` near the
`Handle_Communication__HearDirectSpeech @0x005715A0` (incoming tell) handler
body found no sound-manager call inside it, and no `Sound_*`-named constant
resembling "tell received" or "chat" turned up in the identifiers swept.
The one chat-adjacent audio-related symbol found is a **global** preference
`Sound_PlaySoundOnlyWhenActive` / `ID_Sound_NoFocusNoSound`
(`UIPreferences::AttachPreference @0x004037E4`,
`SoundManager::PlaySoundInternal @0x0054FEC0` checks
`SoundManager::s_bPlaySoundOnlyWhenActive` against `Device::m_bIsActiveApp`)
— which mutes ALL UI sounds (not specifically chat) when the game window
isn't the active app. **UNKNOWN — needs a deeper sweep or a live cdb
capture on an incoming tell**: this document did not find a chat-specific
sound cue, but a negative grep result over a 66 MB pseudo-C dump is weak
evidence of absence given how many code paths route through indirect
vtable calls the text search can't follow. Flagging rather than asserting
"retail has no tell sound."
### 7.4 Copy/paste and text selection — a base `UIElement_Text` capability
`UIElement_Text::GetSelection @0x00466F20` and `UIElement_Text::SelectAll
@0x004678D0` exist as capabilities of the general text-widget class that
BOTH the chat entry field (`m_chatEntry`) and the read-only transcript
(`m_chatLog`) are instances of (`acclient.h:54904-54905`, both typed
`UIElement_Text*`). `SelectAll`'s call sites found are mostly OTHER
text-entry fields (a character-name box, a stack-size entry box) triggered
by a "select-all-on-first-click" attribute (`UIElement::GetAttribute_Bool(this,
0xd1, ...)` inside `UIElement_Text::MouseDown @0x00469370`), not anything
chat-specific. **UNKNOWN — not independently confirmed for the read-only
transcript specifically:** whether the transcript panel exposes the SAME
click-drag-select-then-copy affordance as the entry field, or whether it is
flagged read-only in a way that suppresses selection; the class-level
capability clearly exists on the type, but no chat-transcript-specific
selection code path was located distinct from the generic `UIElement_Text`
mouse-down handler already cited. Worth a live-client check (select text in
the retail transcript, see if a selection highlight appears) rather than
further static digging.
### 7.5 What's genuinely absent
- No `FlashWindow` anywhere in the binary (§7.2).
- No docking/snapping between chat windows or to screen edges — the
window-shell doc's resize/move research found only free-floating
clamped-on-restore positioning (§4).
- No tab strip / tabbed-window container (§5) — five independent panels,
not a tab model.
- No manual "scroll lock" toggle — the auto-scroll behavior in §3.4 IS the
scroll-lock mechanism, driven automatically by `IsAtVerticalEnd`, with no
user-facing on/off switch found.
---
## Behaviours acdream is most likely missing
Ordered by how load-bearing each gap looks against `RuntimeCommunicationState`
(`docs/research/2026-07-26-slice-j4-1-communication-state.md`) and
`ChatWindowController` as of this session:
1. **Scrollback truncation entirely.** No 10,000-char trigger / ~7,500-char
target / newline-boundary-preferring trim (§3.1§3.3) appears to exist in
acdream today — grep `TruncateChatLog`-equivalent behavior in
`ChatLog`/`ChatWindowController` before assuming an unbounded transcript
is fine; it will diverge from retail under long play sessions (memory
growth) and, more subtly, under the exact wrap point if a port ever needs
pixel/line parity with a retail screenshot at high message volume.
2. **The auto-scroll / stick-to-bottom vs. flag-unseen-instead split
(§3.4§3.5).** This is a genuine behavioral fork, not a cosmetic one: a
naive port that ALWAYS scrolls to bottom on new text will yank the user's
scroll position out from under them mid-read whenever a broadcast line
arrives — exactly the annoyance retail's `IsAtVerticalEnd` check exists to
prevent. Confirm `ChatWindowController` checks "was I at the bottom
before this line landed" before auto-scrolling, and confirm the
`0x1000048C` unseen-indicator element (window-shell doc's layout dump
already has its rect for both window layouts) is wired to light up +
clear via click exactly as §3.4/§3.5 describe.
3. **The window-ID routing predicate as ONE explicit rule (§1.2).** The
color-table doc already flags the routing behavior; this document adds
the exact decompiled shape. Verify `RuntimeCommunicationState`'s chat
windows model (per the CH6c plan in the window-shell doc §6.1) implements
precisely `windowId == m_eWindowID || (windowId == 0 && TypeIsActive)`
not, e.g., "every window with the type enabled shows every line
regardless of address," which would make addressed command-output lines
leak into windows they were never meant for.
4. **Structural composition order (§6.2)** — separator-before-entry (not
after), timestamp-before-body, timestamp always present-or-absent as a
single unit gated on one option bit. A port that concatenates
`timestamp + " " + body` as one string loses retail's separately-colored,
separately-truncatable prefix run and the option-driven all-or-nothing
presence.
5. **The local session chat-log file (§7.1).** Small, but "retail writes a
plain-text transcript to disk every session" is the kind of feature users
notice is missing only when they go looking for it after the fact.
6. **Per-window independent scrollback.** Once §1 is implemented, confirm
each of the five windows truncates its OWN transcript independently
(§5) rather than sharing one global buffer — a floaty window filtered
down to just Tells should never truncate early just because the main
window's transcript is huge.
7. **Sound cues and transcript text-selection are open questions, not
confirmed gaps** (§7.3, §7.4) — do not build negative-result "retail has
none of this" code around them; re-check live if/when they become
relevant.

View file

@ -682,6 +682,18 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
lock (d.DatLock)
chargenSkillTable = d.Dats.Get<SkillTable>(0x0E000004u);
var chargenSkillScoreResolver = new ChargenSkillScoreResolver(chargenSkillTable);
// Campaign QT slice QT5: lazily loaded on first open (the panel
// is hidden at mount), then held for the session.
AcDream.Core.Quests.ContractCatalog? contractCatalog = null;
AcDream.Core.Quests.ContractCatalog questCatalog()
{
if (contractCatalog is not null)
return contractCatalog;
lock (d.DatLock)
contractCatalog = AcDream.Content.ContractTableReader.Load(d.Dats);
return contractCatalog;
}
var bindings = new RetailUiRuntimeBindings(
Host: host,
Assets: assets,
@ -1007,6 +1019,23 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
CurrentCalendar: d.CurrentCalendar,
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
HouseLines: () => d.Runtime.HouseOwner.Lines),
// Campaign QT slice QT5. The catalog is read from the dats
// ONCE and cached: it is immutable installed content, and the
// panel would otherwise re-read a 322-entry table on every
// refresh under the shared DatLock.
Quests: new QuestRuntimeBindings(
Contracts: d.Runtime.ContractsOwner.View,
Catalog: questCatalog,
Journal: d.Runtime.JournalOwner.View,
JournalCommands: d.Runtime.JournalOwner,
PlayerCell: () => d.PlayerController.Controller?.CellId ?? 0u,
AbandonContract: contractId =>
late.Session.CurrentSession?.SendAbandonContract(contractId),
JournalDirectory: System.IO.Path.Combine(
AcDream.Platform.ApplicationPathSet.Resolve().DataDirectory,
"journal"),
Report: message =>
d.Communication.Chat.OnSystemMessage(message, 0x0Fu)),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -278,6 +278,30 @@ internal sealed class SessionPlayerCompositionPhase
d.DatLock,
world.TerrainBuild.HeightTable,
d.Options.DumpSceneryZ);
// Campaign QT slice QT6: the plugin-facing contract view. A
// pull-through source rather than a mirror, so a plugin always reads
// the canonical tracker instead of a copy that could fall behind it.
//
// The catalog is loaded lazily and independently of the Journal
// panel's own. Two reads of a 322-row immutable table across a whole
// session is not worth threading a shared instance through three
// composition records for; correctness is identical either way.
AcDream.Core.Quests.ContractCatalog? pluginContractCatalog = null;
d.WorldGameState.ContractsSource = () =>
{
if (pluginContractCatalog is null)
{
lock (d.DatLock)
pluginContractCatalog =
AcDream.Content.ContractTableReader.Load(content.Dats);
}
return AcDream.Runtime.Gameplay.ContractPluginProjection.Project(
d.Runtime.ContractsOwner.View,
pluginContractCatalog,
DateTime.UtcNow);
};
var streamerLease = scope.Acquire(
"landblock streamer",
() => LandblockStreamer.CreateForRequests(

View file

@ -239,8 +239,6 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
{
if (_targetMode.IsAnyTargetModeActive)
_targetMode.CancelTargetMode();
else if (_camera.IsFlyMode)
_camera.ExitFlyMode();
else if (_playerMode.IsPlayerMode)
_playerMode.ExitPlayerMode();
else

View file

@ -382,6 +382,9 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
InvokeClient(b => b.ShowConfirmation(text, callback)),
Suicide: () => InvokeClient(static b => b.Suicide()),
ClearChat: all => InvokeClient(b => b.ClearChat(all)),
SetChatLogFile: name => ReadClient(
b => b.SetChatLogFile(name),
default(AcDream.Core.Chat.ChatLogResult)),
SaveUi: name => InvokeClient(b => b.SaveUi(name)),
LoadUi: name => InvokeClient(b => b.LoadUi(name)),
SaveAutoUi: () => InvokeClient(static b => b.SaveAutoUi()),

View file

@ -115,6 +115,16 @@ internal sealed class LiveSessionRuntimeFactory
private readonly TimeSpan _loginCommandDelay;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Where a bare <c>@log</c> filename lands. See <see cref="ChatSessionLog"/>
/// for why this is not the install directory retail names.
/// </summary>
private readonly string _chatLogDirectory;
private ChatSessionLog? _chatSessionLog;
private ChatTranscriptLogWriter? _chatLogWriter;
public LiveSessionRuntimeFactory(
LiveSessionPlayerRuntime player,
LiveSessionDomainRuntime domain,
@ -127,7 +137,8 @@ internal sealed class LiveSessionRuntimeFactory
string sessionId = "app",
IReadOnlyList<string>? loginCommands = null,
int loginCommandDelayMs = 500,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
string? chatLogDirectory = null)
{
_player = player ?? throw new ArgumentNullException(nameof(player));
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
@ -146,6 +157,8 @@ internal sealed class LiveSessionRuntimeFactory
throw new ArgumentOutOfRangeException(
nameof(loginCommandDelayMs));
}
_chatLogDirectory = chatLogDirectory
?? AcDream.Platform.ApplicationPathSet.Resolve().LogsDirectory;
_loginCommands = loginCommands is null ? [] : [.. loginCommands];
_loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs);
_timeProvider = timeProvider ?? TimeProvider.System;
@ -213,7 +226,15 @@ internal sealed class LiveSessionRuntimeFactory
_ui.RetailUi?.RedeclareSocialPanelAfterWorldEntry();
},
SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(),
LoadCharacterSettings: _interaction.Settings.LoadCharacterContext,
LoadCharacterSettings: name =>
{
_interaction.Settings.LoadCharacterContext(name);
// Campaign QJ slice QJ5: retail loads the journal on
// entering the world, from the same per-character moment
// the settings context uses. RetailUiRuntime owns the file
// because the panel that writes it does.
_ui.RetailUi?.LoadJournal(name);
},
ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm,
// Logout-audio round (2026-08-17): reopen the world-audio
// pool the session reset closed (see the reset manifest's
@ -252,6 +273,43 @@ internal sealed class LiveSessionRuntimeFactory
connectOptions);
}
/// <summary>
/// Retail's <c>@log</c> file lifecycle
/// (<c>ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0</c> /
/// <c>CloseLogFile @0x0057ACC0</c>). An empty name closes.
/// </summary>
/// <remarks>
/// The writer attaches to the transcript on OPEN rather than at startup,
/// which is what retail's own help promises: "All the information that
/// appears in your chat window AFTER you type this command will be copied".
/// It detaches on close, so a closed log costs nothing per line.
/// <para>
/// The line written is the composed display line, because that is what
/// retail logs — <c>fprintf @0x00563E5B</c> sits inside
/// <c>AddTextToScroll</c>, downstream of composition and upstream of glyph
/// layout. <see cref="ChatLog.Append"/> is acdream's equivalent single
/// fan-in, and it already owns the timestamp decision the log shares.
/// </para>
/// </remarks>
private ChatLogResult SetChatLogFile(string name)
{
ChatSessionLog log = _chatSessionLog ??= new ChatSessionLog(_chatLogDirectory);
ChatTranscriptLogWriter writer = _chatLogWriter ??= new ChatTranscriptLogWriter(log);
string? closedName = log.CurrentName;
writer.Detach();
bool closed = log.Close();
if (string.IsNullOrWhiteSpace(name))
return new ChatLogResult(Opened: false, closed, string.Empty, closedName);
bool opened = log.Open(name, out string resolved);
if (opened)
writer.Attach(_domain.Communication.Chat);
return new ChatLogResult(opened, closed, resolved, closedName);
}
private LiveSessionResetBindings CreateResetBindings(
IRuntimeGenerationResetHost resetHost) => new()
{
@ -343,7 +401,8 @@ internal sealed class LiveSessionRuntimeFactory
Fellowship: _domain.Runtime.FellowshipOwner,
Allegiance: _domain.Runtime.AllegianceOwner,
Trade: _domain.Runtime.TradeOwner,
House: _domain.Runtime.HouseOwner));
House: _domain.Runtime.HouseOwner,
Contracts: _domain.Runtime.ContractsOwner));
return new GraphicalSessionEventRoute(
route,
_domain.Runtime,
@ -582,6 +641,7 @@ internal sealed class LiveSessionRuntimeFactory
_ui.RetailUi?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide,
ClearChat: _ => _domain.Communication.Chat.Clear(),
SetChatLogFile: SetChatLogFile,
SaveUi: name => _ui.RetailUi?.SaveNamedLayout(name),
LoadUi: name => _ui.RetailUi?.RestoreNamedLayout(name),
SaveAutoUi: () => _ui.RetailUi?.SaveLayout(),

View file

@ -32,6 +32,14 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
public required IReadOnlyDictionary<uint, uint>?[] SurfaceOverrides;
public required bool[] PartAvailable;
public PhysicsBody? Body;
/// <summary>
/// Retail <c>CPhysicsObj::m_omegaVector</c>. It lives on the owner
/// rather than on <see cref="Body"/> because DAT scenery — which is
/// most of this workset, and every ambient flyer in it — never gets a
/// PhysicsBody at all: <c>BindLiveOwner</c> refuses a zero ServerGuid.
/// </summary>
public Vector3 Omega;
public AnimationSequencer? PendingProcessHooks;
public ulong PendingResidencyVersion;
public readonly List<PartTransform> PreparedLivePartFrames = new();
@ -454,6 +462,19 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
}
}
else if (owner.Omega != Vector3.Zero)
{
// Same grotate, on the only root these objects have. A DAT
// static has no CPhysicsObj of its own, so the WorldEntity IS
// the frame retail would be rotating — and the render
// projection re-reads entity.Rotation every frame, so the
// parts composed below orbit it.
owner.RootFrameScratch.Origin = owner.Entity.Position;
owner.RootFrameScratch.Orientation = owner.Entity.Rotation;
FrameOps.GRotate(owner.RootFrameScratch, owner.Omega);
owner.Entity.Rotation = owner.RootFrameScratch.Orientation;
}
if (!IsResidentAtVersion(owner, residencyVersion))
{
InvalidatePending(owner);
@ -516,6 +537,16 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
continue;
}
// SetOmegaHook::Execute 0x00526F30 -> CPhysicsObj::set_omega
// 0x0050F6D0 writes m_omegaVector on the owning physics object,
// which animate_static_object then feeds to Frame::grotate every
// frame. Apply it BEFORE the presentation sink drains the queue.
//
// Retail runs process_hooks AFTER the grotate in the same pass, so
// a newly-set omega first takes effect on the following frame; our
// Tick/ProcessHooks split preserves that ordering.
ApplyOmegaHooks(owner, sequencer.PendingHooks);
// Clear before the callback: hook delivery may unregister or
// replace the owner, and a nested caller must not replay this tail.
owner.PendingProcessHooks = null;
@ -523,6 +554,47 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
}
}
private static void ApplyOmegaHooks(Owner owner, IReadOnlyList<AnimationHook> hooks)
{
if (!TryResolveOmega(hooks, out Vector3 omega))
return;
owner.Omega = omega;
if (owner.Body is { } body)
body.Omega = omega;
}
/// <summary>
/// The omega a hook batch leaves on the physics object, if any.
/// </summary>
/// <remarks>
/// <para>
/// This is the mechanism behind AC's circling birds and flitting
/// butterflies, and it is not a translation: every authored omega in the
/// dat is pure yaw, and the setup's parts sit well off the origin (5.6 m to
/// 36.8 m across the eight setups that use it). Spinning a frame whose mesh
/// hangs metres off the axis carries that mesh around a circle of the same
/// radius, which is what reads as flight.
/// </para>
/// <para>
/// Last hook wins: retail executes a batch in order and every
/// <c>set_omega</c> overwrites the vector outright rather than accumulating.
/// </para>
/// </remarks>
internal static bool TryResolveOmega(
IReadOnlyList<AnimationHook> hooks, out Vector3 omega)
{
omega = default;
bool found = false;
for (int i = 0; i < hooks.Count; i++)
{
if (hooks[i] is not SetOmegaHook set)
continue;
omega = new Vector3(set.Axis.X, set.Axis.Y, set.Axis.Z);
found = true;
}
return found;
}
private bool IsResidentAtVersion(Owner owner, ulong version) =>
_isResident(owner.Entity)
&& _residencyVersion(owner.Entity) == version;

View file

@ -0,0 +1,73 @@
using System;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI;
/// <summary>
/// Copies the chat transcript into retail's <c>@log</c> file.
/// </summary>
/// <remarks>
/// <para>
/// Retail's log write sits INSIDE <c>ClientSystem::AddTextToScroll</c>
/// (<c>fprintf(s_pLogFile, "%ls%ls\n", …) @0x00563E5B</c>) — downstream of
/// composition and upstream of glyph layout. So the log records the finished
/// display line, timestamp included, and does not re-derive one.
/// <see cref="ChatLog.Append"/> is acdream's equivalent single fan-in.
/// </para>
/// <para>
/// Attaching happens on OPEN rather than at startup, which is what retail's
/// own help promises: "All the information that appears in your chat window
/// AFTER you type this command will be copied into a text file."
/// </para>
/// </remarks>
public sealed class ChatTranscriptLogWriter
{
private readonly ChatSessionLog _log;
private ChatLog? _source;
public ChatTranscriptLogWriter(ChatSessionLog log)
=> _log = log ?? throw new ArgumentNullException(nameof(log));
/// <summary>
/// Starts copying <paramref name="source"/>, detaching from whatever was
/// attached before. Re-attaching to the same transcript does not double
/// up.
/// </summary>
public void Attach(ChatLog source)
{
ArgumentNullException.ThrowIfNull(source);
Detach();
_source = source;
source.EntryAppended += Write;
}
/// <summary>
/// Stops copying. Detaches from the instance actually attached to, not
/// from whatever is current — a transcript replaced mid-log must not leave
/// a handler behind on the old one.
/// </summary>
public void Detach()
{
if (_source is null)
return;
_source.EntryAppended -= Write;
_source = null;
}
private void Write(ChatEntry entry)
{
ChatLog? source = _source;
if (source is null)
return;
// The SAME gate the window uses, so a log never disagrees with the
// transcript it is a copy of.
bool stamped = source.DisplayTimestampsSource?.Invoke() == true;
_log.Write(
stamped ? ChatLog.FormatTimestampPrefix(entry.Received) : null,
ChatVM.FormatEntry(entry));
}
}

View file

@ -1,4 +1,5 @@
using System.Globalization;
using AcDream.Core.Chat;
using AcDream.Core.Physics;
using AcDream.Core.Ui;
using AcDream.Core.Social;
@ -34,6 +35,7 @@ public sealed class ClientCommandController
Action<string, Action<bool>> ShowConfirmation,
Action Suicide,
Action<bool> ClearChat,
Func<string, ChatLogResult> SetChatLogFile,
Action<string> SaveUi,
Action<string> LoadUi,
Action SaveAutoUi,
@ -189,6 +191,9 @@ public sealed class ClientCommandController
_bindings.ClearChat(FirstArgument(command.Arguments)
.Equals("all", StringComparison.OrdinalIgnoreCase));
break;
case ClientCommandId.ChatLogFile:
ExecuteChatLogFile(command.Arguments);
break;
case ClientCommandId.SaveUi:
ExecuteUiProfile(command.Arguments, save: true);
break;
@ -407,6 +412,38 @@ public sealed class ClientCommandController
return false;
}
/// <summary>
/// Retail's <c>@log</c> (<c>ClientCommunicationSystem::DoSetOutput
/// @0x0057E4F0</c>). One verb does both jobs: a filename opens a log, no
/// argument closes the open one. All four replies are retail's own
/// strings, byte-decoded from the paired binary because Binary Ninja
/// truncates its previews at ~33 characters.
/// </summary>
private void ExecuteChatLogFile(string arguments)
{
// Retail JoinArgs the remainder, so the name may contain spaces.
string name = arguments.Trim();
ChatLogResult result = _bindings.SetChatLogFile(name);
// CloseLogFile announces itself wherever it is called from, which
// includes the open path — starting a second log tells you the first
// one ended.
if (result.Closed)
_bindings.ShowSystemMessage($"Chat log {result.ClosedName} closed.");
if (name.Length == 0)
{
_bindings.ShowSystemMessage(result.Closed
? "Chat output now directed only to the screen."
: "Please specify a file to append chat messages to.");
return;
}
_bindings.ShowSystemMessage(result.Opened
? $"Copying chat to {result.Name}. Run command again with no arguments to turn off logging."
: $"Failed to redirect to file {result.Name}!");
}
private void ExecuteAway(string arguments)
{
string first = FirstArgument(arguments);

View file

@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.IO;
using AcDream.Core.Journal;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI;
/// <summary>
/// Reads and writes the per-character journal file.
/// </summary>
/// <remarks>
/// <para>
/// Retail loads on entering the world and saves whenever the notes page is
/// hidden (<c>gmJournalUI::OnVisibilityChanged @0x004978F0</c>) rather than
/// only at exit, so a crash costs at most the page in front of you. Both
/// moments call through here.
/// </para>
/// <para>
/// The file lives in the client's own data directory rather than beside the
/// executable, for the same reason the chat log does: the launcher replaces
/// the install atomically on update, and a file written there is wiped by the
/// next update. Register row QJ-1.
/// </para>
/// </remarks>
public sealed class JournalPersistence(
RuntimeJournalState journal,
string directory,
Action<string>? report = null)
{
private readonly RuntimeJournalState _journal =
journal ?? throw new ArgumentNullException(nameof(journal));
private readonly string _directory = string.IsNullOrWhiteSpace(directory)
? throw new ArgumentException("A journal directory is required.", nameof(directory))
: directory;
private string? _characterName;
/// <summary>The file the loaded character's journal lives in, or null.</summary>
public string? CurrentPath { get; private set; }
/// <summary>
/// Loads a character's journal, replacing whatever was in memory.
/// </summary>
/// <remarks>
/// A character with no file is the ordinary first-time case and loads as an
/// empty journal. A file that fails retail's own validity check reports the
/// message and leaves the journal EMPTY rather than partially populated —
/// a half-read notebook silently loses pages, and the next save would then
/// write the loss back over the original.
/// </remarks>
public void Load(string characterName, string serverName = "acdream")
{
if (string.IsNullOrWhiteSpace(characterName))
return;
_characterName = characterName;
CurrentPath = Path.Combine(
_directory, JournalFile.FileNameFor(serverName, characterName));
string text;
try
{
text = File.Exists(CurrentPath) ? File.ReadAllText(CurrentPath) : string.Empty;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
report?.Invoke($"Problem loading journal: {e.Message}");
_journal.Load([]);
return;
}
JournalReadResult result = JournalFile.Read(text);
if (result.Error is not null)
{
report?.Invoke(result.Error);
_journal.Load([]);
return;
}
_journal.Load(result.Pages);
}
/// <summary>
/// Writes the journal if anything has changed since the last write.
/// </summary>
/// <returns>Whether a file was written.</returns>
public bool Save(DateTime now)
{
if (CurrentPath is null || _characterName is null)
return false;
if (!_journal.IsDirty)
return false;
IReadOnlyList<JournalPage> pages = _journal.CaptureForSave(now);
try
{
Directory.CreateDirectory(_directory);
File.WriteAllText(CurrentPath, JournalFile.Write(pages));
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// Reported, not thrown: losing a note is bad, taking the client
// down while the player is writing one is worse.
report?.Invoke($"Problem saving journal: {e.Message}");
return false;
}
_journal.MarkSaved();
return true;
}
/// <summary>Saves and forgets the character — session teardown.</summary>
public void Close(DateTime now)
{
Save(now);
_characterName = null;
CurrentPath = null;
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
@ -64,14 +65,188 @@ internal static class ChatTranscriptRenderer
/// unchanged" rule — see <see cref="RetailChatColorTable"/>'s own doc).
/// Callers pass their transcript's <see cref="UiText.DefaultColor"/>.
/// </param>
/// <summary>
/// The colour retail gives the timestamp prefix — chat colour index
/// <c>0x0C</c>, which
/// <c>ChatInterface::BuildChatColorLookupTable @0x004F31C0</c> fills with
/// <c>colorGrey</c>.
/// </summary>
/// <remarks>
/// Read from the same table every message colour comes from rather than
/// hard-coded, so it cannot drift from the rest of the palette. This one
/// IS a table index, unlike the tagged-name colour, which is authored per
/// element (property <c>0x1D</c>) and deliberately lives elsewhere.
/// </remarks>
private static Vector4 TimestampColor =>
RetailChatColorTable.TryGetColor(0x0Cu, out Vector4 grey)
? grey
: new Vector4(0.5f, 0.5f, 0.5f, 1f);
/// <summary>
/// Retail's transcript character budget:
/// <c>ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640</c>
/// truncates once the chat log passes <c>0x2710</c> characters.
/// </summary>
/// <remarks>
/// <para>
/// Retail keeps ONE accumulating glyph buffer per window and beheads it
/// back toward <c>0x1D4C</c> (~7,500) when it passes this, preferring to
/// cut at a newline (<c>ChatInterface::TruncateChatLog @0x004F4290</c>).
/// Its transcript therefore oscillates between roughly 7,500 and 10,000
/// characters.
/// </para>
/// <para>
/// We rebuild the visible list from the log each time instead of
/// accumulating, so the two-threshold hysteresis has nothing to damp — it
/// exists to stop retail trimming on every single append. A single cap
/// gives a STABLE window here; oscillating one would make the oldest
/// visible line jump around as messages arrive. Cutting at whole lines is
/// automatic for the same reason: our unit already is the line, which is
/// what retail's newline preference is trying to achieve.
/// </para>
/// </remarks>
public const int MaxTranscriptCharacters = 0x2710;
/// <summary>
/// The first index of <paramref name="detailed"/> that fits in retail's
/// character budget, counting back from the newest line.
/// </summary>
/// <remarks>
/// Only ACCEPTED lines consume budget — a line this window filters out is
/// not in its buffer at all, so it cannot push older lines off the top.
/// </remarks>
internal static int FirstLineWithinBudget(
IReadOnlyList<FormattedLine> detailed,
Func<uint, bool>? accept,
int budget = MaxTranscriptCharacters)
{
long used = 0;
for (int i = detailed.Count - 1; i >= 0; i--)
{
if (accept is not null && !accept(detailed[i].LogTextType))
continue;
// +1 for the newline retail stores between lines.
used += detailed[i].Text.Length + 1;
if (used > budget)
return i + 1;
}
return 0;
}
/// <summary>
/// The runs covering one wrapped fragment, or <see langword="null"/> when
/// the fragment is a single colour.
/// </summary>
/// <remarks>
/// <para>
/// Campaign CT slice A4. Wrapping splits a line into fragments, and a tag
/// can straddle a break, so a fragment may hold part of a tagged run, all
/// of it, or none.
/// </para>
/// <para>
/// Returns null unless a tag actually falls inside the window — a
/// single-colour fragment must take the ordinary flat draw path rather
/// than a one-run list that means the same thing.
/// </para>
/// </remarks>
internal static IReadOnlyList<UiText.TextRun>? RunsForFragment(
IReadOnlyList<ChatTextSpan> spans,
int fragmentStart,
int fragmentLength,
Vector4 lineColor,
Vector4 tagColor)
{
int fragmentEnd = fragmentStart + fragmentLength;
var runs = new List<UiText.TextRun>();
bool sawTag = false;
int at = 0;
foreach (ChatTextSpan span in spans)
{
int spanStart = at;
int spanEnd = at + span.Text.Length;
at = spanEnd;
int from = Math.Max(spanStart, fragmentStart);
int to = Math.Min(spanEnd, fragmentEnd);
if (to <= from)
continue;
// Retail appends the timestamp at a FIXED colour index (0x0C)
// rather than the message's, so it stays grey whatever colour the
// line is — RecvNotice_DisplayFinalStringInfo @0x004F4640 passes
// 0xc for that run and the line's own type for the body.
bool tagged = span.Tag is not null;
bool stamped = span.Role == ChatSpanRole.Timestamp;
sawTag |= tagged || stamped;
Vector4 color = tagged
? tagColor
: stamped
? TimestampColor
: lineColor;
runs.Add(new UiText.TextRun(
span.Text.Substring(from - spanStart, to - from),
color));
}
return sawTag ? runs : null;
}
/// <summary>
/// The tagged column ranges inside one wrapped fragment, or
/// <see langword="null"/> when it holds none.
/// </summary>
/// <remarks>
/// Columns are relative to the FRAGMENT, because that is what a click
/// resolves to: <c>UiText.HitChar</c> returns a line index into the
/// wrapped list plus a column within that line.
/// </remarks>
internal static IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>?
TaggedRangesForFragment(
IReadOnlyList<ChatTextSpan> spans,
int fragmentStart,
int fragmentLength)
{
int fragmentEnd = fragmentStart + fragmentLength;
List<(int Start, int Length, ChatTextTag Tag)>? ranges = null;
int at = 0;
foreach (ChatTextSpan span in spans)
{
int spanStart = at;
int spanEnd = at + span.Text.Length;
at = spanEnd;
if (span.Tag is not { } tag)
continue;
int from = Math.Max(spanStart, fragmentStart);
int to = Math.Min(spanEnd, fragmentEnd);
if (to <= from)
continue;
(ranges ??= new()).Add((from - fragmentStart, to - from, tag));
}
return ranges;
}
public static List<UiText.Line> BuildLines(
IReadOnlyList<FormattedLine> detailed,
float maxW,
Func<string, float> measure,
Func<uint, bool>? accept,
Vector4 defaultColor)
Vector4 defaultColor,
Vector4? tagColor = null,
List<IReadOnlyList<UiText.TextRun>?>? runsPerLine = null,
List<IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>?>? tagsPerLine = null)
{
var result = new List<UiText.Line>(detailed.Count);
runsPerLine?.Clear();
tagsPerLine?.Clear();
if (detailed.Count == 0)
return result;
@ -82,14 +257,51 @@ internal static class ChatTranscriptRenderer
// (defaultColor), matching retail's DoFontReset — not the color table's
// unrelated index-0x00 slot.
Vector4 currentColor = defaultColor;
foreach (FormattedLine d in detailed)
int firstLine = FirstLineWithinBudget(detailed, accept);
for (int lineIndex = firstLine; lineIndex < detailed.Count; lineIndex++)
{
FormattedLine d = detailed[lineIndex];
if (accept is not null && !accept(d.LogTextType))
continue;
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
currentColor = resolved;
// Wrapping can DROP the space it broke on, so a fragment is not
// simply the next N characters — locate each one in the source
// line to keep the span offsets honest.
int searchFrom = 0;
foreach (string frag in WrapText(d.Text, maxW, measure))
{
result.Add(new UiText.Line(frag, currentColor));
if (runsPerLine is null && tagsPerLine is null)
continue;
if (d.Spans is not { Count: > 0 } spans || frag.Length == 0)
{
runsPerLine?.Add(null);
tagsPerLine?.Add(null);
continue;
}
int at = d.Text.IndexOf(frag, searchFrom, StringComparison.Ordinal);
if (at < 0)
{
// Should not happen; a fragment always comes from the line.
// Fall back to the flat colour rather than mis-colouring.
runsPerLine?.Add(null);
tagsPerLine?.Add(null);
continue;
}
searchFrom = at + frag.Length;
runsPerLine?.Add(RunsForFragment(
spans,
at,
frag.Length,
currentColor,
tagColor ?? currentColor));
tagsPerLine?.Add(TaggedRangesForFragment(spans, at, frag.Length));
}
}
return result;
}

View file

@ -52,6 +52,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
private const uint RootId = 0x10000600u; // gmFloatyMainChatUI window root, 410x100
private const uint TranscriptPanelId = 0x10000010u;
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
/// <summary>
/// Retail's "there is new text you have not seen" indicator, shown only
/// while the transcript is scrolled off the bottom
/// (<c>ChatInterface::ListenToElementMessage @0x004F51C0</c>).
/// </summary>
private const uint UnreadIndicatorId = 0x1000048Cu;
private const uint TrackId = 0x10000012u;
private const uint InputBarId = 0x10000013u;
private const uint MenuId = 0x10000014u;
@ -128,6 +135,35 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// metrics that determine wrapping change. This makes an idle chat window
// allocation-free instead of snapshotting/formatting/wrapping every frame.
private IReadOnlyList<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>();
/// <summary>
/// Per-cached-line runs, index-aligned with
/// <see cref="_cachedTranscriptLines"/>. Null at an index means that line
/// is a single colour and draws through the ordinary flat path.
/// </summary>
private readonly List<IReadOnlyList<UiText.TextRun>?> _cachedTranscriptRuns = new();
/// <summary>
/// Per-cached-line tagged column ranges, index-aligned with
/// <see cref="_cachedTranscriptLines"/>. This is what turns a click into a
/// tag; the runs alongside only decide colour.
/// </summary>
private readonly List<IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>?>
_cachedTranscriptTags = new();
private UiElement? _unreadIndicator;
/// <summary>Test seam: the bound unseen-text indicator, if the layout has one.</summary>
internal UiElement? UnreadIndicatorForTest => _unreadIndicator;
/// <summary>Test seam: force the unseen flag without faking a line arrival.</summary>
internal void SetUnreadForTest(bool unread) => _hasUnseenText = unread;
/// <summary>
/// Set when a line arrives while the transcript is scrolled up, cleared
/// the moment the view is back at the bottom.
/// </summary>
private bool _hasUnseenText;
private long _cachedTranscriptRevision = -1;
private ulong _cachedFilter;
private float _cachedTranscriptWrapWidth = float.NaN;
@ -137,6 +173,21 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
/// <summary>
/// The two non-channel entries retail puts at the head of the talk-focus
/// menu. Both act on the CURRENT SELECTION and carry its name in their
/// label — retail substitutes it via StringInfo::AddVariable_String
/// (gmMainChatUI @0x004CD91C / @0x004CD982), which is why the screenshot
/// reads "Tell to +Acdream" rather than "Tell to Selected".
/// </summary>
private enum TalkFocusSpecial
{
Squelch,
TellToSelected,
}
private string? _tellTarget;
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
{
("Squelch (ignore)", null),
@ -158,6 +209,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
{
ChatChannelKind.Say => "Chat",
ChatChannelKind.Tell => "Tell",
ChatChannelKind.General => "General",
ChatChannelKind.Trade => "Trade",
ChatChannelKind.Lfg => "LFG",
@ -225,7 +277,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
ChatWindowState windowFilters,
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve)
Func<uint, (uint tex, int w, int h)> resolve,
Func<string?>? selectedTargetName = null)
{
ArgumentNullException.ThrowIfNull(windowFilters);
@ -308,6 +361,32 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// fallback — a flat overlay here only mismatched it under the wrong
// (0x21000006) layout, whose transcript panel lacked one.
c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm);
// Index-aligned with the lines the provider above returns, and read
// from the same cache — so a tagged speaker name draws in the
// element's authored tag colour while the rest of its line keeps the
// message colour.
c.Transcript.LineRunsProvider = index =>
index >= 0 && index < c._cachedTranscriptRuns.Count
? c._cachedTranscriptRuns[index]
: null;
// Clicking a tagged name opens a tell to that player, exactly as
// retail does — gmMainChatUI::RecvNotice_TextTag_IIDStringClick
// @0x004CCE10 -> ChatInterface::StartTell @0x004F41F0.
c.Transcript.OnCharClick = pos => c.TryStartTellFromTag(pos);
// ── Unread indicator ─────────────────────────────────────────────
c._unreadIndicator = layout.FindElement(UnreadIndicatorId);
if (c._unreadIndicator is not null)
{
// Driven by authored STATE, not visibility. Retail's own click
// handler ends in SetState(0xD) — Ghosted — which is also the
// element's authored default, and the unread look is state 1
// (Normal). Ghosted authors no media at all, so it draws nothing
// without needing to be hidden.
c.SetUnreadIndicatorState(unread: false);
if (c._unreadIndicator is UiButton unread)
unread.OnClick = c.ScrollToNewestAndClearUnread;
}
// ── Input ────────────────────────────────────────────────────────
// Editable/selectable/one-line semantics and state sprites came from the
@ -319,7 +398,12 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// its own authored background sprite (0x0600113A); same reasoning as the
// transcript above.
c.Input.SpriteResolve = resolve;
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
// Campaign CT slice B2: expand "/r " to "@tell {LastTeller}, " as the
// space lands, so the player sees the target before pressing enter.
c.Input.TextReplacer = text =>
ChatTextReplacements.Expand(text, vm.LastIncomingTellSender);
c.Input.OnSubmit = text => ChatCommandRouter.Submit(
text, vm, busProvider(), c._activeChannel, c._tellTarget);
// Campaign CH user-gate round 1 (item G): the imported field's right
// edge otherwise holds a FIXED absolute pixel position across a
@ -373,19 +457,77 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
menu.PopupBgSprite = MenuPopupBg;
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
menu.Items = System.Array.ConvertAll(ChannelItems,
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
string? SelectedName() => selectedTargetName?.Invoke();
// Retail rebuilds these two labels around the selected name every
// time the menu opens; ours does the same from the live provider.
void RebuildItems()
{
string? target = SelectedName();
var items = new List<UiMenu.MenuItem>(ChannelItems.Length)
{
new(target is null
? "Squelch (ignore)"
: $"Squelch (ignore) {target}",
TalkFocusSpecial.Squelch),
new(target is null
? "Tell to Selected"
: $"Tell to {target}",
TalkFocusSpecial.TellToSelected),
};
foreach ((string label, ChatChannelKind? channel) in ChannelItems)
{
if (channel is { } ch)
items.Add(new UiMenu.MenuItem(label, ch));
}
menu.Items = items.ToArray();
}
RebuildItems();
menu.Selected = (object?)c._activeChannel;
// Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like
// retail; only the talk-CHANNEL items grey when unavailable.
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
// Talk-CHANNEL items grey when the channel is unavailable; the two
// specials grey when nothing is selected to act on — retail's
// SetTalkFocusEnabled(2, 1) @0x004CD9B0 arms the tell slot only
// once a talkable object is selected.
menu.EnabledProvider = p => p switch
{
ChatChannelKind ch => ChannelAvailable(ch),
TalkFocusSpecial => SelectedName() is not null,
_ => true,
};
// The button names the FOCUS, not the target: retail shows "Tell",
// not the person's name.
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
// The widget reports the pick; the controller owns Selected. Only a talk-channel
// payload updates the active channel + highlight — the null-payload specials are
// deferred no-ops (see the chat re-drive deferred list) and leave selection intact.
menu.OnOpen = RebuildItems;
menu.OnSelect = p =>
{
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
switch (p)
{
case ChatChannelKind ch:
c._activeChannel = ch;
c._tellTarget = null;
menu.Selected = p;
break;
// Aims the chat bar at the selected player, so ordinary
// typed text goes to them as a tell.
case TalkFocusSpecial.TellToSelected when SelectedName() is { } name:
c._activeChannel = ChatChannelKind.Tell;
c._tellTarget = name;
menu.Selected = p;
break;
// The /squelch verb is already registered and carries the
// ModifyCharacterSquelch request
// (CM_Communication::Event_ModifyCharacterSquelch
// @0x006A42D0); the menu entry is another way to reach it,
// not a second implementation. Selection is left alone.
case TalkFocusSpecial.Squelch when SelectedName() is { } squelched:
busProvider().Publish(
new ExecuteClientCommandCmd(
ClientCommandId.Squelch, squelched));
break;
}
};
c.Menu = menu;
}
@ -674,6 +816,16 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
return _cachedTranscriptLines;
}
// Sampled BEFORE the rebuild, exactly as retail samples IsAtVerticalEnd
// before the new line lands: if the player had scrolled up, the
// arriving text is unseen and the view must NOT be yanked down.
if (_cachedTranscriptRevision != revision
&& _cachedTranscriptRevision >= 0
&& !Transcript.Scroll.AtEnd)
{
_hasUnseenText = true;
}
var detailed = vm.RecentLinesDetailed();
if (detailed.Count == 0)
{
@ -699,11 +851,155 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// no more accept:null "no user filter" placeholder.
bool Accept(uint logTextType) => _windowFilters.ShouldDisplay(
ChatWindowState.MainWindowId, ChatWindowState.BroadcastTargetWindow, logTextType);
// Campaign CT slice A4: the runs come back alongside the flat lines and
// are cached with them, so the transcript's per-line run lookup costs
// nothing per frame — it reads the same cache the lines do.
_cachedTranscriptRuns.Clear();
_cachedTranscriptTags.Clear();
var result = ChatTranscriptRenderer.BuildLines(
detailed, maxW, measure, Accept, Transcript.DefaultColor);
detailed,
maxW,
measure,
Accept,
Transcript.DefaultColor,
Transcript.TagColor,
_cachedTranscriptRuns,
_cachedTranscriptTags);
return StoreTranscriptLayout(result, revision, filter, maxW, datFont, debugFont);
}
/// <summary>
/// Opens a tell to the player whose name was clicked, if one was.
/// </summary>
/// <remarks>
/// Retail writes "@tell {Name}, " into the chat entry, takes keyboard
/// focus and shows the entry bar (<c>ChatInterface::StartTell
/// @0x004F41F0</c>). It uses the tag's NAME rather than its object id: the
/// id is carried but this handler never reads it, so a tell still
/// addresses correctly for a player who has since moved out of range.
/// </remarks>
internal bool TryStartTellFromTag(UiText.Pos position)
{
if (position.Line < 0 || position.Line >= _cachedTranscriptTags.Count)
return false;
if (_cachedTranscriptTags[position.Line] is not { } ranges)
return false;
foreach ((int start, int length, ChatTextTag tag) in ranges)
{
// A caret slot sits BETWEEN glyphs, so the range is half-open:
// clicking just past the last letter of a name is not that name.
if (position.Col < start || position.Col >= start + length)
continue;
if (!tag.TryGetIidString(out _, out string name) || name.Length == 0)
continue;
StartTell(name);
return true;
}
return false;
}
/// <summary>
/// Jumps to the newest text and clears the unread flag — retail's own
/// handler for a click on the indicator.
/// </summary>
internal void ScrollToNewestAndClearUnread()
{
Transcript.Scroll.ScrollToEnd();
_hasUnseenText = false;
}
/// <summary>
/// Keeps the indicator in step with the view. Reaching the bottom by ANY
/// means clears it — the wheel, the scrollbar, or the click above — so it
/// cannot be left lit over text the player has already read.
/// </summary>
internal void UpdateUnreadIndicator()
{
if (Transcript.Scroll.AtEnd)
_hasUnseenText = false;
SetUnreadIndicatorState(_hasUnseenText);
}
/// <summary>
/// Puts the indicator into its authored unread (<c>Normal</c>) or idle
/// (<c>Ghosted</c>) state.
/// </summary>
/// <remarks>
/// <b>The blink is authored, not coded.</b> The element's Normal state
/// carries SIX image frames (measured: <c>LayoutDump --states</c> on
/// 0x2100006F reports <c>media=13/6</c> for state 1, against <c>0/0</c>
/// for Ghosted), so retail's flashing comes from cycling that media list.
/// Our importer keeps only ONE file per state
/// (<see cref="Layout.ElementInfo.StateMedia"/>), so the indicator
/// currently shows a static frame. Multi-frame state media is its own
/// capability; the state machinery here is right either way, and gains the
/// animation for free once that lands.
/// </remarks>
/// <summary>
/// True once the authored flash has been started for the current batch of
/// unseen text, so it is not restarted every frame.
/// </summary>
private bool _flashStarted;
private void SetUnreadIndicatorState(bool unread)
{
if (_unreadIndicator is null)
return;
if (!unread)
{
_unreadIndicator.Visible = false;
_flashStarted = false;
return;
}
if (!_flashStarted)
{
// Rising edge: start the authored sequence. Set ONCE — restarting
// it every frame would hold it on frame zero and it would never
// appear to blink at all.
_flashStarted = true;
_unreadIndicator.Visible = true;
if (_unreadIndicator is IUiDatStateful starting)
starting.TrySetRetailState(UiButtonStateMachine.Normal);
return;
}
// The sequence ENDS itself: after three blinks it hands off to
// Ghosted, whose authored 0x3B is Invisible. Follow that rather than
// holding the indicator lit — retail's is a transient attention-flash,
// not a badge that stays up until you scroll down.
if (_unreadIndicator is UiButton flashing
&& string.Equals(flashing.ActiveState, "Ghosted", StringComparison.Ordinal))
{
_unreadIndicator.Visible = false;
}
}
/// <summary>Aims the chat entry at <paramref name="name"/> and focuses it.</summary>
internal void StartTell(string name)
{
// SetText already places the caret at the end. An explicit "move to
// the end" on top of it is not merely redundant: MoveCaret takes a
// DELTA, so int.MaxValue overflows _caret + delta to negative and the
// clamp lands the caret at column 0 — the player then has to click the
// bar to get behind their own prefix.
Input.SetText($"@tell {name}, ");
FindRootOf(Input)?.SetKeyboardFocus(Input);
}
private static UiRoot? FindRootOf(UiElement element)
{
for (UiElement? at = element; at is not null; at = at.Parent)
if (at is UiRoot root)
return root;
return null;
}
private IReadOnlyList<UiText.Line> StoreTranscriptLayout(
IReadOnlyList<UiText.Line> lines,
long revision,

View file

@ -845,6 +845,8 @@ public static class DatWidgetFactory
// the build-time default.
if (info.FontColor.HasValue)
t.DefaultColor = info.FontColor.Value;
if (info.TagFontColor.HasValue)
t.TagColor = info.TagFontColor.Value;
// Outline color from dat property 0x22 (ColorBaseProperty). Only 9 elements in the
// whole DAT set author a non-black value; when absent, UiText's own ctor default

View file

@ -131,6 +131,22 @@ public sealed class ElementInfo
/// </summary>
public Vector4? FontColor;
/// <summary>
/// Authored TAG font colour (dat property <c>0x1D</c>), the colour retail
/// gives a tagged glyph run — a clickable speaker name — as distinct from
/// <see cref="FontColor"/> (<c>0x1B</c>) for everything else.
/// </summary>
/// <remarks>
/// The two are parallel index-selected arrays refreshed from the same
/// caller index on every append
/// (<c>UIElement_Text::AppendStringInfoWithFont @0x00469DE0</c>), and a
/// glyph takes this one only while a tag is open. Measured on the chat
/// transcript (0x2100006F / 0x10000011) as RGB(0,178,0); it is AUTHORED
/// per element, not built by the runtime chat colour table, so it does not
/// belong in <c>RetailChatColorTable</c>.
/// </remarks>
public Vector4? TagFontColor;
/// <summary>
/// Outline flag from dat <c>Properties[0x21]</c> (<c>BoolBaseProperty</c>). Retail
/// <c>UIElement_Text::SetOutline @0x0046a81c</c> / <c>m_bitField &amp; 0x10</c>.
@ -576,6 +592,7 @@ public static class ElementReader
// FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base.
// Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base.
FontColor = derived.FontColor ?? base_.FontColor,
TagFontColor = derived.TagFontColor ?? base_.TagFontColor,
// Outline: derived wins when true (the dat property 0x21 was present and read as
// true); otherwise inherit the base. False-derived never overrides a true base —
// matching the FontDid/HJustify "non-default wins" convention.
@ -669,6 +686,25 @@ public static class ElementReader
}
}
// Tag font colour (0x1D) — same shape as 0x1B above, read the same way.
if (info.TryGetEffectiveProperty(0x1Du, out var tagColor))
{
UiPropertyValue? tagValue = tagColor.Kind == UiPropertyKind.Color
? tagColor
: tagColor.Kind == UiPropertyKind.Array
&& tagColor.ArrayValue.Count > 0
&& tagColor.ArrayValue[0].Kind == UiPropertyKind.Color
? tagColor.ArrayValue[0]
: null;
if (tagValue is not null)
{
var t = tagValue.ColorValue;
float alpha = t.Alpha == 0 ? 1f : t.Alpha / 255f;
info.TagFontColor =
new Vector4(t.Red / 255f, t.Green / 255f, t.Blue / 255f, alpha);
}
}
// Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField & 0x10.
if (info.TryGetEffectiveProperty(0x21u, out var outline)
&& outline.Kind == UiPropertyKind.Bool)

View file

@ -0,0 +1,288 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Quests;
using AcDream.Core.Ui;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Journal panel's Contracts page — retail <c>gmContractsUI</c>
/// (element type <c>0x1000004B</c>, page <c>0x100005D4</c>).
/// </summary>
/// <remarks>
/// <para>
/// A two-column list (Contract / Status) over a detail pane. Rows come from
/// <see cref="IRuntimeContractView"/> — live server state — joined to the
/// authored <see cref="ContractCatalog"/> for every word the player reads;
/// the wire itself carries only an id, a stage and two timers.
/// </para>
/// <para>
/// Rebuilds are revision-gated. The tracker changes rarely (accepting or
/// advancing a quest) while the panel ticks every frame, so polling
/// <see cref="RuntimeContractsSnapshot.Revision"/> is what keeps this from
/// rebuilding a template list continuously.
/// </para>
/// </remarks>
public sealed class JournalContractsPageController
{
/// <summary>The layout the row template lives in — authored property
/// <c>0x63</c> on the list's template entry.</summary>
public const uint RowTemplateLayoutId = 0x21000069u;
/// <summary>The row template element — authored <c>0x62</c>, and the id
/// retail passes to <c>AddItemFromTemplateListByID @0x00499747</c>.</summary>
public const uint RowTemplateElementId = 0x100005D7u;
private const uint ListId = 0x100005CFu;
private const uint RowNameId = 0x100005D1u;
private const uint RowStatusId = 0x100005D2u;
private const uint StatusValueId = 0x100005DFu;
private const uint ContactValueId = 0x100005E0u;
private const uint ContactLocationValueId = 0x100005E1u;
private const uint QuestLocationValueId = 0x100005E2u;
private const uint DescriptionId = 0x100005DEu;
private const uint TimedValueId = 0x100005E3u;
private const uint AbandonButtonId = 0x100005DCu;
/// <summary>Live state and services the page reads.</summary>
/// <param name="Contracts">The canonical tracker view.</param>
/// <param name="Catalog">The authored contract text.</param>
/// <param name="Now">
/// The clock the repeat countdown is measured against. Injected rather
/// than read from <see cref="DateTime.UtcNow"/> so the countdown is
/// testable without waiting for it.
/// </param>
/// <param name="TemplateResolver">Builds one row from the authored template.</param>
/// <param name="Abandon">
/// Sends the abandon action for one contract. The row disappears only when
/// the SERVER answers with its own delete, so a refused abandon leaves the
/// quest exactly where it was.
/// </param>
public sealed record Bindings(
IRuntimeContractView Contracts,
Func<ContractCatalog> Catalog,
Func<DateTime> Now,
Func<uint, uint, UiElement?> TemplateResolver,
Action<uint>? Abandon = null);
private readonly Bindings _bindings;
private readonly UiTemplateListBox? _list;
private readonly UiText? _statusValue;
private readonly UiText? _contactValue;
private readonly UiText? _contactLocationValue;
private readonly UiText? _questLocationValue;
private readonly UiText? _description;
private readonly UiText? _timedValue;
/// <summary>
/// Retail's own selection highlight for a listbox row. UiTemplateListBox
/// has no generic selection mechanism (its class doc), so the page opts in
/// directly — the same precedent
/// <see cref="CharacterCreationSkillsPage"/> set.
/// </summary>
private static readonly Vector4 SelectedNameColor = Vector4.One;
private readonly List<uint> _rowContractIds = [];
private readonly List<(uint ContractId, UiText? Name, Vector4 Unselected)> _rows = [];
private long _renderedRevision = -1;
private uint _selectedContractId;
public JournalContractsPageController(UiElement page, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(page);
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_list = UiElement.FindDescendant(page, ListId) as UiTemplateListBox;
if (_list is not null)
_list.TemplateResolver = bindings.TemplateResolver;
_statusValue = UiElement.FindDescendant(page, StatusValueId) as UiText;
_contactValue = UiElement.FindDescendant(page, ContactValueId) as UiText;
_contactLocationValue =
UiElement.FindDescendant(page, ContactLocationValueId) as UiText;
_questLocationValue =
UiElement.FindDescendant(page, QuestLocationValueId) as UiText;
_description = UiElement.FindDescendant(page, DescriptionId) as UiText;
_timedValue = UiElement.FindDescendant(page, TimedValueId) as UiText;
if (UiElement.FindDescendant(page, AbandonButtonId) is UiButton abandon)
abandon.OnClick = AbandonSelected;
Refresh();
}
/// <summary>The contract the detail pane is showing, or 0.</summary>
public uint SelectedContractId => _selectedContractId;
/// <summary>Contract ids in list order, for tests.</summary>
public IReadOnlyList<uint> RowContractIds => _rowContractIds;
public void Tick()
{
// Cheap every frame; a rebuild only when the tracker actually moved.
if (_bindings.Contracts.Snapshot.Revision != _renderedRevision)
Refresh();
else
RefreshDetail(); // the repeat countdown ticks without a rebuild
}
public void Refresh()
{
RuntimeContractsSnapshot snapshot = _bindings.Contracts.Snapshot;
_renderedRevision = snapshot.Revision;
IReadOnlyList<ContractTracker> contracts = _bindings.Contracts.GetContracts();
ContractCatalog catalog = _bindings.Catalog();
DateTime now = _bindings.Now();
// Retail's own default: the server nominates a display contract, and
// otherwise the first row stands.
if (snapshot.DisplayContractId != 0u)
_selectedContractId = snapshot.DisplayContractId;
if (_selectedContractId == 0u && contracts.Count != 0)
_selectedContractId = contracts[0].ContractId;
if (contracts.Count == 0)
_selectedContractId = 0u;
_rowContractIds.Clear();
_rows.Clear();
_list?.FlushPreservingScroll();
foreach (ContractTracker tracker in contracts)
{
_rowContractIds.Add(tracker.ContractId);
if (_list is null)
continue;
UiElement? row = _list.AddItemFromTemplateList(0);
if (row is null)
continue;
ContractEntry entry = catalog.Lookup(tracker.ContractId);
var name = UiElement.FindDescendant(row, RowNameId) as UiText;
if (name is not null)
SetText(name, entry.ContractName);
if (UiElement.FindDescendant(row, RowStatusId) is UiText status)
{
SetText(status, ContractProgressText.Build(
(uint)tracker.Stage, tracker.TimeWhenRepeats,
tracker.ReceivedAt, entry, now));
}
// Captured AFTER SetText, which never touches DefaultColor (it is
// read lazily inside the provider closure), so this is the row's
// own authored colour to restore on deselect.
_rows.Add((tracker.ContractId, name, name?.DefaultColor ?? Vector4.One));
uint captured = tracker.ContractId;
if (row is UiDatElement clickable)
{
// A generic Type-3 container is click-THROUGH by default, so
// without this the handler below never fires and the list looks
// dead. UiDatElement carries the opt-in seam for exactly this.
clickable.ClickThrough = false;
clickable.OnClick = () => Select(captured);
}
}
ApplySelectionHighlight();
RefreshDetail();
}
/// <summary>
/// Abandons the selected contract — game action <c>0x0316</c>.
/// </summary>
/// <remarks>
/// Nothing is removed locally. The server replies with a <c>0x0315</c>
/// carrying <c>DeleteContract</c> and the tracker drops the row then, so a
/// refusal leaves the quest visibly intact rather than vanishing it
/// optimistically and having it reappear.
/// </remarks>
public void AbandonSelected()
{
if (_selectedContractId == 0u)
return;
_bindings.Abandon?.Invoke(_selectedContractId);
}
/// <summary>Points the detail pane at one contract.</summary>
public void Select(uint contractId)
{
_selectedContractId = contractId;
ApplySelectionHighlight();
RefreshDetail();
}
/// <summary>
/// Re-painted rather than merely remembered: a rebuild discards the old row
/// objects, so a preserved selection has to be applied to the new ones.
/// </summary>
private void ApplySelectionHighlight()
{
foreach ((uint contractId, UiText? name, Vector4 unselected) in _rows)
{
if (name is not null)
{
name.DefaultColor =
contractId == _selectedContractId ? SelectedNameColor : unselected;
}
}
}
private void RefreshDetail()
{
ContractCatalog catalog = _bindings.Catalog();
DateTime now = _bindings.Now();
if (_selectedContractId == 0u
|| !_bindings.Contracts.TryGetContract(_selectedContractId, out ContractTracker tracker))
{
SetText(_statusValue, string.Empty);
SetText(_contactValue, string.Empty);
SetText(_contactLocationValue, string.Empty);
SetText(_questLocationValue, string.Empty);
SetText(_description, string.Empty);
SetText(_timedValue, string.Empty);
return;
}
ContractEntry entry = catalog.Lookup(_selectedContractId);
SetText(_statusValue, ContractProgressText.Build(
(uint)tracker.Stage, tracker.TimeWhenRepeats, tracker.ReceivedAt, entry, now));
SetText(_contactValue, entry.NameNpcStart);
SetText(_contactLocationValue, LocationText(entry.LocationNpcStartCell));
SetText(_questLocationValue, LocationText(entry.LocationQuestAreaCell));
SetText(_description, entry.Description);
// "Timed:" is the other wire timer — the one FillProgressString never
// reads. It belongs here, not in the Status column.
SetText(_timedValue, tracker.TimeWhenDone > 0d
? RetailDurationText.Format(
Math.Max(0d, tracker.TimeWhenDone - (now - tracker.ReceivedAt).TotalSeconds))
: string.Empty);
}
/// <summary>
/// Coordinates, or retail's literal <c>"Indoors"</c> when the cell has none
/// (<c>LandDefs::gid_to_lcoord</c> failing, <c>@0x0049937F</c>).
/// </summary>
private static string LocationText(uint cellId)
{
if (cellId == 0u) return string.Empty;
return RetailPositionFormatter.FormatOutdoorCell(cellId) ?? "Indoors";
}
private static void SetText(UiText? text, string value)
{
if (text is null) return;
text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)];
}
}

View file

@ -0,0 +1,317 @@
using System;
using System.Globalization;
using AcDream.Core.Journal;
using AcDream.Core.Ui;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Journal panel's notes page — retail <c>gmJournalUI</c> (element type
/// <c>0x10000048</c>, page <c>0x10000563</c>).
/// </summary>
/// <remarks>
/// <para>
/// A per-character notebook: label, title, free notes, a recorded location and
/// a countdown timer, paged with First/Previous/Next/Last.
/// </para>
/// <para>
/// <b>Every navigation commits the current page first.</b> Retail's
/// <c>ListenToElementMessage @0x004968D0</c> calls <c>SaveThisPage</c> on the
/// way out of every one of the five navigation buttons, which is why paging
/// away never loses what you just typed. Skipping that is the obvious
/// simplification and it silently eats edits.
/// </para>
/// </remarks>
public sealed class JournalNotesPageController
{
// Retail switches on (idElement - 0x10000565), so these two carry no
// caption of their own: they are the prev/next arrows.
private const uint PreviousButtonId = 0x10000565u;
private const uint NextButtonId = 0x10000566u;
private const uint NewButtonId = 0x10000567u;
private const uint LabelFieldId = 0x10000569u;
private const uint TitleFieldId = 0x1000056Bu;
private const uint NotesFieldId = 0x1000056Du;
private const uint FirstButtonId = 0x1000056Fu;
private const uint PageNumberId = 0x10000570u;
private const uint LastButtonId = 0x10000571u;
private const uint LocationTextId = 0x10000573u;
private const uint RecordButtonId = 0x10000574u;
private const uint TimerDaysFieldId = 0x10000576u;
private const uint TimerHoursFieldId = 0x10000578u;
private const uint TimerMinutesFieldId = 0x1000057Au;
// The "d" / "h" / "m" unit labels. Retail's ShowEditableTimer @0x00495770
// toggles each box AND its label — m_pDaysStaticText, m_pHoursStaticText,
// m_pMinutesStaticText — so the running readout, which is authored over
// the same strip, does not draw through them.
private const uint TimerDaysLabelId = 0x10000577u;
private const uint TimerHoursLabelId = 0x10000579u;
private const uint TimerMinutesLabelId = 0x1000057Bu;
private const uint RunningTimerTextId = 0x1000057Cu;
private const uint StartButtonId = 0x1000057Du;
/// <param name="Journal">The canonical owner.</param>
/// <param name="Commands">Mutations, kept off the read view.</param>
/// <param name="PlayerCell">
/// The player's current landcell, for the "Record" button. Returns 0 when
/// there is no valid cell — indoors, or not in the world.
/// </param>
/// <param name="Now">The clock the countdown runs against.</param>
public sealed record Bindings(
IRuntimeJournalView Journal,
RuntimeJournalState Commands,
Func<uint> PlayerCell,
Func<DateTime> Now);
private readonly Bindings _bindings;
private readonly UiField? _label;
private readonly UiField? _title;
private readonly UiField? _notes;
private readonly UiField? _timerDays;
private readonly UiField? _timerHours;
private readonly UiField? _timerMinutes;
private readonly UiText? _pageNumber;
/// <summary>
/// The location readout is authored EDITABLE (<c>0x16</c>), so it builds as
/// a <see cref="UiField"/> — not the <see cref="UiText"/> its placeholder
/// text suggests. Resolving it as text yielded null and threw every write
/// away silently: Record reached the model and the file, and never the
/// screen.
/// </summary>
private readonly UiField? _location;
private readonly UiText? _runningTimer;
private readonly UiElement? _timerDaysLabel;
private readonly UiElement? _timerHoursLabel;
private readonly UiElement? _timerMinutesLabel;
private readonly UiButton? _start;
private long _renderedRevision = -1;
public JournalNotesPageController(UiElement page, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(page);
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_label = UiElement.FindDescendant(page, LabelFieldId) as UiField;
_title = UiElement.FindDescendant(page, TitleFieldId) as UiField;
_notes = UiElement.FindDescendant(page, NotesFieldId) as UiField;
_timerDays = UiElement.FindDescendant(page, TimerDaysFieldId) as UiField;
_timerHours = UiElement.FindDescendant(page, TimerHoursFieldId) as UiField;
_timerMinutes = UiElement.FindDescendant(page, TimerMinutesFieldId) as UiField;
_pageNumber = UiElement.FindDescendant(page, PageNumberId) as UiText;
_location = UiElement.FindDescendant(page, LocationTextId) as UiField;
_runningTimer = UiElement.FindDescendant(page, RunningTimerTextId) as UiText;
_timerDaysLabel = UiElement.FindDescendant(page, TimerDaysLabelId);
_timerHoursLabel = UiElement.FindDescendant(page, TimerHoursLabelId);
_timerMinutesLabel = UiElement.FindDescendant(page, TimerMinutesLabelId);
_start = UiElement.FindDescendant(page, StartButtonId) as UiButton;
// The three timer boxes take digits only. Their authored 0x1E is 2, so
// the width is already handled; this stops a letter reaching an int
// parse that would silently read as zero.
foreach (UiField? field in new[] { _timerDays, _timerHours, _timerMinutes })
{
if (field is not null)
field.CharacterFilter = static c => char.IsAsciiDigit(c);
}
// Committing on focus loss is what makes clicking straight from a text
// box to another tab keep the edit.
foreach (UiField? field in new[] { _label, _title, _notes })
{
if (field is not null)
field.OnFocusLost = _ => CommitText();
}
// Retail's ListenToElementMessage @0x004968D0 ends every one of these
// in Update() — the panel redraws at the moment of the click, not on
// the next frame. Deferring to Tick would work in the client and makes
// the behaviour untestable and a frame late.
Bind(page, NewButtonId, () =>
{
CommitText();
_bindings.Commands.NewPage();
Refresh();
});
Bind(page, FirstButtonId, () => Navigate(1));
Bind(page, LastButtonId, () => Navigate(_bindings.Journal.Snapshot.PageCount));
Bind(page, PreviousButtonId,
() => Navigate(_bindings.Journal.Snapshot.CurrentPage - 1));
Bind(page, NextButtonId,
() => Navigate(_bindings.Journal.Snapshot.CurrentPage + 1));
Bind(page, RecordButtonId, RecordLocation);
Bind(page, StartButtonId, ToggleTimer);
Refresh();
}
/// <summary>
/// Commits the edit boxes into the current page — retail's
/// <c>SaveThisPage @0x00495360</c>.
/// </summary>
public void CommitText()
{
if (_bindings.Journal.Snapshot.CurrentPage == 0)
return;
_bindings.Commands.UpdateCurrent(
_label?.Text ?? string.Empty,
_title?.Text ?? string.Empty,
_notes?.Text ?? string.Empty);
_bindings.Commands.SetTimer(
ParseField(_timerDays),
ParseField(_timerHours),
ParseField(_timerMinutes));
}
public void Tick()
{
if (_bindings.Journal.Snapshot.Revision != _renderedRevision)
Refresh();
else
RefreshTimer(); // the countdown ticks without a page rebuild
}
/// <summary>Called when the page is hidden — retail saves here.</summary>
public void OnHidden() => CommitText();
public void Refresh()
{
RuntimeJournalSnapshot snapshot = _bindings.Journal.Snapshot;
_renderedRevision = snapshot.Revision;
JournalPage page = _bindings.Journal.Current;
_label?.SetText(page.Label);
_title?.SetText(page.Title);
_notes?.SetText(page.Notes);
_timerDays?.SetText(Field(page.TimerDays));
_timerHours?.SetText(Field(page.TimerHours));
_timerMinutes?.SetText(Field(page.TimerMinutes));
// Retail's own "~ N ~". An empty journal shows no number rather than
// "~ 0 ~", which would name a page that does not exist.
SetText(_pageNumber, snapshot.CurrentPage == 0
? string.Empty
: $"~ {snapshot.CurrentPage.ToString(CultureInfo.InvariantCulture)} ~");
_location?.SetText(page.HasLocation
? FormatLocation(page.LocationX, page.LocationY)
: string.Empty);
RefreshTimer();
}
/// <summary>
/// The editable-fields / running-readout swap
/// (<c>ShowEditableTimer @0x00495770</c> versus <c>ShowRunningTimer</c>).
/// </summary>
/// <remarks>
/// The readout is authored at the SAME x as the three number boxes, so
/// showing both at once overlaps them illegibly — the strip is one or the
/// other.
/// </remarks>
private void RefreshTimer()
{
double remaining = _bindings.Journal.RemainingTimerSeconds(_bindings.Now());
bool running = remaining > 0d;
// Each box AND its unit label, exactly the six elements retail toggles.
// Hiding only the boxes leaves "d h m" drawn underneath the readout,
// which is authored across the same strip.
if (_timerDays is not null) _timerDays.Visible = !running;
if (_timerHours is not null) _timerHours.Visible = !running;
if (_timerMinutes is not null) _timerMinutes.Visible = !running;
if (_timerDaysLabel is not null) _timerDaysLabel.Visible = !running;
if (_timerHoursLabel is not null) _timerHoursLabel.Visible = !running;
if (_timerMinutesLabel is not null) _timerMinutesLabel.Visible = !running;
if (_runningTimer is not null) _runningTimer.Visible = running;
SetText(_runningTimer, running
? RetailDurationText.Format(remaining)
: string.Empty);
if (_start is not null)
_start.Label = running ? "Stop" : "Start";
}
private void Navigate(int pageNumber)
{
CommitText();
_bindings.Commands.GotoPage(pageNumber);
Refresh();
}
private void RecordLocation()
{
uint cell = _bindings.PlayerCell();
if (cell == 0u)
return;
// Indoors, retail's own gid_to_lcoord fails and nothing is recorded —
// UpdateLocation @0x004958F0 only ever formats coordinates already
// stored, so there is no "you are indoors" message to port. Silence
// here is faithful, not an omission.
if (!AcDream.Core.Ui.RadarCoordinates.TryFromCell(cell, out var coordinates))
return;
_bindings.Commands.RecordLocation((float)coordinates.X, (float)coordinates.Y);
Refresh();
}
private void ToggleTimer()
{
if (_bindings.Journal.RemainingTimerSeconds(_bindings.Now()) > 0d)
{
_bindings.Commands.ResetTimer();
Refresh();
return;
}
CommitText(); // the fields the countdown reads
_bindings.Commands.StartTimer(_bindings.Now());
Refresh();
}
/// <summary>
/// The journal's own authored placeholder is <c>"00.0S, 00.0W"</c> — with
/// a space after the comma, unlike the radar's own combined form.
/// </summary>
private static string FormatLocation(float x, float y)
{
var coordinates = new AcDream.Core.Ui.RadarCoordinates(x, y);
return $"{coordinates.YText}, {coordinates.XText}";
}
private void Bind(UiElement page, uint elementId, Action action)
{
if (UiElement.FindDescendant(page, elementId) is UiButton button)
button.OnClick = action;
}
private static int ParseField(UiField? field) =>
int.TryParse(
field?.Text,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int value) && value >= 0
? value
: 0;
private static string Field(int value) =>
value == 0 ? string.Empty : value.ToString(CultureInfo.InvariantCulture);
private static void SetText(UiText? text, string value)
{
if (text is null) return;
text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)];
}
}

View file

@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
using AcDream.Core.Journal;
using AcDream.Core.Ui;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Journal panel's Page List — retail <c>gmPageListUI</c> (element type
/// <c>0x10000049</c>, page <c>0x10000564</c>).
/// </summary>
/// <remarks>
/// A searchable index over the journal's pages: number, title, timer and
/// label, with Delete and a search box. Double-clicking a row opens that page
/// on the Journal tab (<c>CheckForDoubleClick @0x00493140</c> →
/// <c>gmJournalUI::GotoPage</c>).
/// </remarks>
public sealed class JournalPageListController
{
/// <summary>The layout the row template lives in — authored <c>0x63</c>.</summary>
public const uint RowTemplateLayoutId = 0x21000067u;
/// <summary>The row template element — authored <c>0x62</c>.</summary>
public const uint RowTemplateElementId = 0x10000589u;
private const uint ListId = 0x10000583u;
private const uint DeleteButtonId = 0x10000585u;
private const uint SearchFieldId = 0x10000587u;
private const uint ResetButtonId = 0x10000588u;
private const uint RowNumberId = 0x1000058Au;
private const uint RowTitleId = 0x1000058Bu;
private const uint RowTimerId = 0x1000058Cu;
private const uint RowLabelId = 0x1000058Du;
private static readonly Vector4 SelectedNameColor = Vector4.One;
/// <param name="Journal">The canonical owner.</param>
/// <param name="Commands">Mutations.</param>
/// <param name="OpenPage">
/// Opens a page on the Journal tab — the panel owns the tab switch, this
/// page owns only the choice.
/// </param>
public sealed record Bindings(
IRuntimeJournalView Journal,
RuntimeJournalState Commands,
Action<int> OpenPage,
Func<uint, uint, UiElement?> TemplateResolver,
Func<DateTime>? Now = null);
private readonly Bindings _bindings;
private readonly UiTemplateListBox? _list;
private readonly UiField? _search;
private readonly List<int> _rowPages = [];
private readonly List<(int Page, UiText? Title, Vector4 Unselected)> _rows = [];
/// <summary>
/// Retail's double-click window — <c>m_LastClickTime + 1.0</c> in
/// <c>CheckForDoubleClick @0x00493158</c>. A full SECOND, not the 500 ms
/// the item-interaction path uses; the two are separate mechanisms and
/// borrowing the wrong constant makes the list feel unresponsive.
/// </summary>
private static readonly TimeSpan DoubleClickWindow = TimeSpan.FromSeconds(1d);
private long _renderedRevision = -1;
private string _renderedSearch = string.Empty;
private int _selectedPage;
private int _lastClickPage;
private DateTime _lastClickAt;
public JournalPageListController(UiElement page, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(page);
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_list = UiElement.FindDescendant(page, ListId) as UiTemplateListBox;
if (_list is not null)
_list.TemplateResolver = bindings.TemplateResolver;
_search = UiElement.FindDescendant(page, SearchFieldId) as UiField;
if (UiElement.FindDescendant(page, DeleteButtonId) is UiButton delete)
delete.OnClick = DeleteSelected;
if (UiElement.FindDescendant(page, ResetButtonId) is UiButton reset)
{
reset.OnClick = () =>
{
_search?.SetText(string.Empty);
Refresh();
};
}
Refresh();
}
/// <summary>The 1-based page the list has selected, or 0.</summary>
public int SelectedPage => _selectedPage;
/// <summary>The 1-based page numbers currently listed, in order.</summary>
public IReadOnlyList<int> RowPages => _rowPages;
/// <summary>
/// Retail's search predicate — <c>PageContainsString @0x00493B60</c>.
/// </summary>
/// <remarks>
/// Matches Label, Title OR Notes, and is CASE-SENSITIVE: retail compares
/// with <c>wcsstr</c> and never lowercases either side. Making it
/// insensitive would be friendlier and would be a divergence.
/// </remarks>
public static bool PageContainsString(JournalPage page, string search)
{
ArgumentNullException.ThrowIfNull(page);
if (string.IsNullOrEmpty(search))
return true;
return page.Label.Contains(search, StringComparison.Ordinal)
|| page.Title.Contains(search, StringComparison.Ordinal)
|| page.Notes.Contains(search, StringComparison.Ordinal);
}
public void Tick()
{
string search = _search?.Text ?? string.Empty;
if (_bindings.Journal.Snapshot.Revision != _renderedRevision
|| !string.Equals(search, _renderedSearch, StringComparison.Ordinal))
{
Refresh();
}
}
public void Refresh()
{
RuntimeJournalSnapshot snapshot = _bindings.Journal.Snapshot;
_renderedRevision = snapshot.Revision;
_renderedSearch = _search?.Text ?? string.Empty;
IReadOnlyList<JournalPage> pages = _bindings.Journal.Pages;
_rowPages.Clear();
_rows.Clear();
_list?.FlushPreservingScroll();
for (int i = 0; i < pages.Count; i++)
{
JournalPage page = pages[i];
if (!PageContainsString(page, _renderedSearch))
continue;
int pageNumber = i + 1;
_rowPages.Add(pageNumber);
if (_list is null)
continue;
UiElement? row = _list.AddItemFromTemplateList(0);
if (row is null)
continue;
SetText(UiElement.FindDescendant(row, RowNumberId) as UiText,
pageNumber.ToString(CultureInfo.InvariantCulture));
var title = UiElement.FindDescendant(row, RowTitleId) as UiText;
SetText(title, page.Title);
SetText(UiElement.FindDescendant(row, RowTimerId) as UiText,
page.IsTimerRunning
? RetailDurationText.Format(page.RunningTimerSeconds)
: page.HasTimer
? RetailDurationText.Format(page.TimerDuration.TotalSeconds)
: string.Empty);
SetText(UiElement.FindDescendant(row, RowLabelId) as UiText, page.Label);
_rows.Add((pageNumber, title, title?.DefaultColor ?? Vector4.One));
int captured = pageNumber;
if (row is UiDatElement clickable)
{
// A Type-3 row is click-through by default; without this the
// list cannot be selected at all.
clickable.ClickThrough = false;
clickable.OnClick = () => Click(captured);
}
}
// A filtered-out selection is dropped: Delete must never act on a row
// the player cannot see.
if (_selectedPage != 0 && !_rowPages.Contains(_selectedPage))
_selectedPage = 0;
ApplySelectionHighlight();
}
/// <summary>
/// One row click. Port of <c>gmPageListUI::CheckForDoubleClick
/// @0x00493140</c>: a second click on the SAME row within the window opens
/// it, and firing resets the tracker so a third click does not re-trigger.
/// </summary>
public void Click(int pageNumber)
{
DateTime now = _bindings.Now?.Invoke() ?? DateTime.UtcNow;
if (pageNumber == _lastClickPage && now - _lastClickAt <= DoubleClickWindow)
{
_lastClickPage = 0;
_lastClickAt = default;
Open(pageNumber);
return;
}
_lastClickPage = pageNumber;
_lastClickAt = now;
Select(pageNumber);
}
public void Select(int pageNumber)
{
_selectedPage = pageNumber;
ApplySelectionHighlight();
}
/// <summary>Opens a page on the Journal tab — retail's double-click.</summary>
public void Open(int pageNumber)
{
Select(pageNumber);
_bindings.OpenPage(pageNumber);
}
private void DeleteSelected()
{
if (_selectedPage == 0)
return;
_bindings.Commands.DeletePage(_selectedPage);
_selectedPage = 0;
Refresh();
}
private void ApplySelectionHighlight()
{
foreach ((int pageNumber, UiText? title, Vector4 unselected) in _rows)
{
if (title is not null)
title.DefaultColor = pageNumber == _selectedPage ? SelectedNameColor : unselected;
}
}
private static void SetText(UiText? text, string value)
{
if (text is null) return;
text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)];
}
}

View file

@ -0,0 +1,208 @@
using System;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Mounts retail's three-tab <b>Journal</b> panel — LayoutDesc
/// <c>0x2100006E</c> slot <c>0x10000559</c>,
/// <see cref="AcDream.App.UI.RetailPanelCatalog"/> id <b>25</b>. Campaign QT
/// slice QT5, built on the OP3/FA3 tab-host recipe.
/// </summary>
/// <remarks>
/// <para>
/// <b>The contract tracker is not a panel of its own.</b> `gmContractsUI` is
/// tab 1 of this panel, which is why the campaign mounts a Journal rather than
/// a Contracts window. The authored tab table (property <c>0x2E</c>, read from
/// the installed dats rather than inferred from x-order — the mistake Campaign
/// FA had to correct):
/// </para>
/// <code>
/// button 0x100005D3 ("Contracts") -&gt; page 0x100005D4 DEFAULT (0x32 = True)
/// button 0x10000560 ("Journal") -&gt; page 0x10000563
/// button 0x10000561 ("Page List") -&gt; page 0x10000564
/// </code>
/// <para>
/// All three pages are live as of Campaign QJ. Contracts is server state;
/// the other two are one per-character notebook and its index, sharing
/// <see cref="AcDream.Runtime.Gameplay.RuntimeJournalState"/>.
/// </para>
/// </remarks>
public sealed class JournalPanelController : IRetainedPanelController
{
/// <summary>The floating host LayoutDesc the tab panel is resolved through.</summary>
public const uint HostLayoutId = 0x2100006Eu;
/// <summary>
/// The Journal panel's slot within <see cref="HostLayoutId"/>'s shared
/// <c>gmPanelUI</c> page stack. Its own authored <c>0x10000029</c> is
/// <c>0x19</c> = 25 — the same byte-verified slot-key recipe Options (10),
/// the social panel (12) and Map/House (16) already use.
/// </summary>
public const uint SlotElementId = 0x10000559u;
/// <summary>The Contracts page — <c>gmContractsUI</c>, element type
/// <c>0x1000004B</c>.</summary>
public const uint ContractsPageId = 0x100005D4u;
/// <summary>The notes page — <c>gmJournalUI</c>, type <c>0x10000048</c>.</summary>
public const uint NotesPageId = 0x10000563u;
/// <summary>The index page — <c>gmPageListUI</c>, type <c>0x10000049</c>.</summary>
public const uint PageListPageId = 0x10000564u;
/// <summary>The panel's own corner button.</summary>
private const uint CloseButtonId = 0x10000562u;
private readonly UiTabPanel _tabPanel;
private readonly JournalContractsPageController? _contracts;
private readonly JournalNotesPageController? _notes;
private JournalPageListController? _pageList;
private readonly Action<uint, uint> _onActivePageChanged;
private bool _disposed;
/// <summary>Root element of the imported panel — the tab host itself.</summary>
public UiElement Root => _tabPanel;
public UiTabPanel TabPanel => _tabPanel;
public JournalContractsPageController? Contracts => _contracts;
public JournalNotesPageController? Notes => _notes;
public JournalPageListController? PageList => _pageList;
private readonly Action _saveJournal;
private JournalPanelController(
UiTabPanel tabPanel,
JournalContractsPageController? contracts,
JournalNotesPageController? notes,
JournalPageListController? pageList,
Action saveJournal)
{
_tabPanel = tabPanel;
_contracts = contracts;
_notes = notes;
_pageList = pageList;
_saveJournal = saveJournal;
// Retail saves the journal when the notes page is HIDDEN
// (gmJournalUI::OnVisibilityChanged @0x004978F0), not only at exit, so
// a crash costs at most the page you are looking at. Leaving the tab
// is that moment here.
_onActivePageChanged = (previous, _) =>
{
if (previous == NotesPageId)
{
_notes?.OnHidden();
_saveJournal();
}
};
_tabPanel.ActivePageChanged += _onActivePageChanged;
}
public sealed record Callbacks(
Action Toggle,
JournalContractsPageController.Bindings Contracts,
JournalNotesPageController.Bindings Notes,
/// <summary>Writes the journal file — retail's save-on-hide.</summary>
Action SaveJournal,
Func<Action<int>, JournalPageListController.Bindings> PageList);
/// <summary>
/// Binds an imported <see cref="HostLayoutId"/>/<see cref="SlotElementId"/>
/// layout to live behavior — the same "import via the host slot, then
/// Build+Bind" shape <see cref="OptionsPanelController.Bind"/> and
/// <see cref="SocialPanelController.Bind"/> use.
/// </summary>
public static JournalPanelController? Bind(ImportedLayout layout, Callbacks callbacks)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(callbacks);
if (layout.Root is not UiTabPanel tabPanel)
{
Console.WriteLine(
"[D.2b] JournalPanelController.Bind: root did not build as UiTabPanel "
+ $"(actual type {layout.Root.GetType().Name}) — journal panel will not open.");
return null;
}
if (layout.FindElement(CloseButtonId) is UiButton close)
close.OnClick = callbacks.Toggle;
JournalContractsPageController? contracts = null;
if (layout.FindElement(ContractsPageId) is { } contractsPage)
contracts = new JournalContractsPageController(contractsPage, callbacks.Contracts);
else
Console.WriteLine("[D.2b] JournalPanelController: contracts page not found.");
JournalNotesPageController? notes = null;
if (layout.FindElement(NotesPageId) is { } notesPage)
notes = new JournalNotesPageController(notesPage, callbacks.Notes);
else
Console.WriteLine("[D.2b] JournalPanelController: notes page not found.");
JournalPageListController? pageList = null;
var built = new JournalPanelController(
tabPanel, contracts, notes, pageList: null, callbacks.SaveJournal);
if (layout.FindElement(PageListPageId) is { } listPage)
{
// The index opens a page on the NOTES tab, so it needs the panel
// that owns the tab switch — hence the deferred binding.
pageList = new JournalPageListController(
listPage,
callbacks.PageList(pageNumber =>
{
notes?.CommitText();
callbacks.Notes.Commands.GotoPage(pageNumber);
built.ShowNotes();
}));
}
else
{
Console.WriteLine("[D.2b] JournalPanelController: page list not found.");
}
built.AttachPageList(pageList);
return built;
}
/// <summary>
/// Runs the authored tab table, which activates the default entry —
/// Contracts (<c>0x32 = True</c>).
/// </summary>
public void ActivateTabs() => _tabPanel.ActivateTabBehavior();
/// <summary>Switches to the Contracts tab.</summary>
public void ShowContracts() => _tabPanel.SwitchTo(ContractsPageId);
/// <summary>Switches to the notes tab — what opening a page from the index does.</summary>
public void ShowNotes() => _tabPanel.SwitchTo(NotesPageId);
/// <summary>
/// Completes construction. The index needs a callback that switches tabs,
/// which needs the panel — so it is attached rather than constructed.
/// </summary>
private void AttachPageList(JournalPageListController? pageList)
=> _pageList = pageList;
public void Tick()
{
if (_disposed) return;
_contracts?.Tick();
_notes?.Tick();
_pageList?.Tick();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_tabPanel.ActivePageChanged -= _onActivePageChanged;
// The panel closing is the other half of retail's save-on-hide.
_notes?.OnHidden();
_saveJournal();
}
}

View file

@ -609,6 +609,31 @@ public static class LayoutImporter
MediaCount = sd.Media.Count,
};
// Keep the WHOLE sequence, in order. A state's media is a small
// program — images interleaved with pauses and jumps — and taking only
// the first image (below) reduces a blinking element to a still frame.
// Unrecognised entries are kept as Other so a jump's index still lands
// on the authored entry.
var steps = new List<UiMediaStep>(sd.Media.Count);
foreach (var m in sd.Media)
{
steps.Add(m switch
{
MediaDescImage i => new UiMediaStep(
UiMediaStepKind.Image, i.File, (int)i.DrawMode, 0f, 0f, 0u, 0f),
MediaDescPause p => new UiMediaStep(
UiMediaStepKind.Pause, 0u, 0, p.MinDuration, p.MaxDuration, 0u, 0f),
MediaDescState st => new UiMediaStep(
UiMediaStepKind.State, 0u, 0, 0f, 0f,
(uint)st.StateId, st.Probability),
MediaDescJump j => new UiMediaStep(
UiMediaStepKind.Jump, 0u, 0, 0f, 0f, j.JumpItemIndex, j.Probability),
_ => new UiMediaStep(
UiMediaStepKind.Other, 0u, 0, 0f, 0f, 0u, 0f, (int)m.MediaType),
});
}
state.MediaSteps = steps;
bool imageRead = false;
foreach (var m in sd.Media)
{

View file

@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The clock authored media animations are sampled against.
/// </summary>
/// <remarks>
/// A single shared clock rather than a timer per element: sampling is a pure
/// function of (steps, elapsed), so an element only needs to remember WHEN its
/// state began. Advanced once per frame by the host — a UI element has no tick
/// of its own.
/// </remarks>
public static class UiMediaClock
{
/// <summary>Seconds since the host started advancing this clock.</summary>
public static double Seconds { get; private set; }
public static void Advance(double deltaSeconds)
{
if (double.IsFinite(deltaSeconds) && deltaSeconds > 0d)
Seconds += deltaSeconds;
}
/// <summary>Test seam: rewind to a known point.</summary>
internal static void ResetForTest() => Seconds = 0d;
}
/// <summary>
/// Plays a retail UI state's media sequence.
/// </summary>
/// <remarks>
/// <para>
/// A state's media is a small program rather than a picture: images
/// interleaved with timed pauses, branches, and a terminal state hand-off.
/// The chat window's unseen-text indicator (<c>0x1000048C</c>) authors this,
/// measured from the installed dats:
/// </para>
/// <code>
/// [ 0] Image 0x06005F0E [ 1] Pause 0.5
/// [ 2] Image 0x06005F0F [ 3] Pause 0.5 two frames alternating,
/// ... three times over three seconds
/// [12] State 13 (Ghosted) p=1 then it hides itself
/// </code>
/// <para>
/// So retail's indicator is a transient attention-flash, not a badge that
/// stays lit. That is behaviour nobody would guess from the code, because
/// there is no blink code — it is entirely in the data.
/// </para>
/// <para>
/// Sampling is a pure function of (steps, elapsed): no playback object holds
/// a cursor, so a caller only has to remember WHEN a state began. That keeps
/// the animation testable without a clock, a GPU or a frame loop.
/// </para>
/// </remarks>
public static class UiMediaSequence
{
/// <summary>
/// Guards a malformed sequence whose jumps form a cycle with no elapsed
/// time — without it, such a sequence would spin forever inside one frame.
/// </summary>
private const int MaximumSteps = 512;
/// <summary>
/// Whether <paramref name="steps"/> does anything over time. A state whose
/// media is a single image is NOT an animation and must keep the ordinary
/// still-frame path.
/// </summary>
public static bool IsAnimated(IReadOnlyList<UiMediaStep>? steps)
{
if (steps is null || steps.Count < 2)
return false;
int images = 0;
foreach (UiMediaStep step in steps)
{
switch (step.Kind)
{
case UiMediaStepKind.Pause:
case UiMediaStepKind.Jump:
case UiMediaStepKind.State:
return true;
case UiMediaStepKind.Image when ++images > 1:
return true;
}
}
return false;
}
/// <summary>
/// The frame showing at <paramref name="elapsedSeconds"/>, and the state
/// the sequence hands off to if it has reached its end.
/// </summary>
/// <returns>
/// <c>File</c> is 0 when no image has been reached yet.
/// <c>TransitionState</c> is null until a terminal State step is due.
/// </returns>
public static (uint File, uint? TransitionState) Sample(
IReadOnlyList<UiMediaStep>? steps,
float elapsedSeconds)
{
if (steps is null || steps.Count == 0)
return (0u, null);
uint file = 0u;
float at = 0f;
int cursor = 0;
for (int guard = 0; guard < MaximumSteps; guard++)
{
if (cursor < 0 || cursor >= steps.Count)
return (file, null);
UiMediaStep step = steps[cursor];
switch (step.Kind)
{
case UiMediaStepKind.Image:
file = step.File;
cursor++;
break;
case UiMediaStepKind.Pause:
// Retail authors a min and a max; every sequence measured
// so far sets them equal. MinDuration is used, and the
// range is left unimplemented rather than guessed at —
// see the divergence register.
at += Math.Max(0f, step.MinDuration);
if (elapsedSeconds < at)
return (file, null); // still holding this frame
cursor++;
break;
case UiMediaStepKind.Jump:
// A probability below 1 is a chance to branch. Treated as
// "always" would loop a sequence retail sometimes lets
// fall through, so anything uncertain falls through here
// instead — the conservative direction, since a sequence
// that ends early stops animating rather than animating
// forever.
if (step.Probability >= 1f)
cursor = (int)step.JumpIndex;
else
cursor++;
break;
case UiMediaStepKind.State:
return (file, step.Probability >= 1f ? step.JumpIndex : null);
default:
cursor++;
break;
}
}
return (file, null);
}
}

View file

@ -110,6 +110,45 @@ public sealed class UiPropertyBag
/// <summary>Primary render-surface media for a retail UI state.</summary>
public readonly record struct UiImageMedia(uint File, int DrawMode);
/// <summary>What one entry of a state's media sequence does.</summary>
public enum UiMediaStepKind
{
/// <summary>Anything we do not act on (sound, movie, message, ...).</summary>
Other,
/// <summary>Show this image.</summary>
Image,
/// <summary>Hold the current image for a duration.</summary>
Pause,
/// <summary>Branch to another entry — what makes a sequence loop.</summary>
Jump,
/// <summary>Hand the element to another STATE when the sequence ends.</summary>
State,
}
/// <summary>
/// One entry of a retail state's media list.
/// </summary>
/// <remarks>
/// A state's media is a SEQUENCE, not a picture: images interleaved with
/// pauses and jumps, which is how retail authors a blinking or cycling
/// element. Unrecognised entries are kept as
/// <see cref="UiMediaStepKind.Other"/> rather than dropped, so a jump's
/// index still lands on the right entry.
/// </remarks>
public readonly record struct UiMediaStep(
UiMediaStepKind Kind,
uint File,
int DrawMode,
float MinDuration,
float MaxDuration,
uint JumpIndex,
float Probability,
int RawType = 0);
/// <summary>
/// Dat-independent state descriptor. DirectState uses
/// <see cref="DirectStateId"/> so it cannot collide with <c>UIStateId.Undef == 0</c>.
@ -123,6 +162,12 @@ public sealed class UiStateInfo
public bool PassToChildren;
public uint IncorporationFlags;
public UiImageMedia? Image;
/// <summary>
/// The state's media list in authored order, or empty when it has none.
/// <see cref="Image"/> remains the first drawable image — the still frame.
/// </summary>
public IReadOnlyList<UiMediaStep> MediaSteps = Array.Empty<UiMediaStep>();
public UiCursorMedia? Cursor;
public UiPropertyBag Properties = new();
@ -155,6 +200,7 @@ public sealed class UiStateInfo
PassToChildren = PassToChildren,
IncorporationFlags = IncorporationFlags,
Image = Image,
MediaSteps = MediaSteps,
Cursor = Cursor,
Properties = Properties.Clone(),
MediaCount = MediaCount,

View file

@ -57,6 +57,17 @@ public static class RetailPanelCatalog
/// </summary>
public const uint MapHouse = 16u;
/// <summary>
/// Campaign QT slice QT5: the three-tab Journal panel (Contracts /
/// Journal / Page List) — <c>gmPanelUI</c> slot key byte-verified from the
/// live installed DATs (host <c>0x2100006E</c> slot <c>0x10000559</c>'s own
/// authored <c>0x10000029 = 0x19</c>). Toolbar button <c>0x1000055A</c>
/// authors the same id, so this one is in BOTH <see cref="Mounted"/> and
/// <see cref="Toolbar"/> — like <see cref="MapHouse"/>, unlike
/// <see cref="SocialPanel"/>.
/// </summary>
public const uint Journal = 25u;
private static readonly (uint PanelId, string WindowName)[] Mounted =
{
(CharacterInformation, WindowNames.CharacterInformation),
@ -71,6 +82,7 @@ public static class RetailPanelCatalog
(Options, WindowNames.Options),
(SocialPanel, WindowNames.SocialPanel),
(MapHouse, WindowNames.MapHouse),
(Journal, WindowNames.Journal),
};
private static readonly (uint PanelId, string WindowName)[] Toolbar =
@ -80,6 +92,11 @@ public static class RetailPanelCatalog
(Magic, WindowNames.Spellbook),
(Options, WindowNames.Options),
(MapHouse, WindowNames.MapHouse),
// Campaign QT slice QT5: toolbar button 0x1000055A authors
// 0x10000029 = 0x19 and has been in ToolbarController.PanelButtonIds
// all along — it simply had no panel behind it, so clicking it did
// nothing.
(Journal, WindowNames.Journal),
};
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;

View file

@ -311,6 +311,26 @@ public sealed record MapHouseRuntimeBindings(
Func<IReadOnlyList<string>>? HouseLines = null,
Action? HouseShown = null);
/// <summary>
/// Campaign QT slice QT5: what the Journal panel's Contracts page reads —
/// the canonical tracker view plus the authored contract text.
/// </summary>
public sealed record QuestRuntimeBindings(
AcDream.Runtime.Gameplay.IRuntimeContractView Contracts,
Func<AcDream.Core.Quests.ContractCatalog> Catalog,
// Campaign QJ (2026-08-21): the notebook the other two tabs share. The
// command owner is passed alongside its own read view because the journal
// is WRITTEN by the panel — unlike contracts, which are server state.
AcDream.Runtime.Gameplay.IRuntimeJournalView Journal,
AcDream.Runtime.Gameplay.RuntimeJournalState JournalCommands,
Func<uint> PlayerCell,
/// <summary>Sends the abandon-contract action (0x0316).</summary>
Action<uint> AbandonContract,
/// <summary>Where the per-character journal file lives.</summary>
string JournalDirectory,
/// <summary>How a load or save failure reaches the player.</summary>
Action<string> Report);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
Func<uint> PlayerGuid,
@ -474,6 +494,7 @@ public sealed record RetailUiRuntimeBindings(
OptionsRuntimeBindings Options,
SocialRuntimeBindings Social,
MapHouseRuntimeBindings MapHouse,
QuestRuntimeBindings Quests,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
@ -569,6 +590,7 @@ public sealed class RetailUiRuntime : IDisposable
MountTooltipPresenter();
MountSocialPanel();
MountMapHousePanel();
MountJournalPanel();
MountCharacter();
MountPlugins();
MountInventory();
@ -692,6 +714,32 @@ public sealed class RetailUiRuntime : IDisposable
public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; }
/// <summary>Campaign QT slice QT5 — the three-tab Journal panel.</summary>
public Layout.JournalPanelController? JournalPanelController { get; private set; }
private JournalPersistence? _journalFile;
/// <summary>
/// Campaign QJ slice QJ5. One owner, here rather than in the session
/// factory, because the panel that writes the journal lives here — two
/// instances would each track their own file path and overwrite each
/// other.
/// </summary>
private JournalPersistence JournalFile =>
_journalFile ??= new JournalPersistence(
_bindings.Quests.JournalCommands,
_bindings.Quests.JournalDirectory,
_bindings.Quests.Report);
/// <summary>Loads a character's journal — called on entering the world.</summary>
public void LoadJournal(string characterName) => JournalFile.Load(characterName);
/// <summary>
/// Writes the journal if anything changed. Retail's own moment is the notes
/// page being hidden, not only session exit.
/// </summary>
public void SaveJournal() => JournalFile.Save(DateTime.UtcNow);
public MapHousePanelController? MapHousePanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
@ -851,6 +899,9 @@ public sealed class RetailUiRuntime : IDisposable
public void Tick(double deltaSeconds)
{
// Authored media sequences (a blinking indicator, any cycling element)
// are sampled against this one clock — UI elements have no tick.
Layout.UiMediaClock.Advance(deltaSeconds);
FpsController?.Tick();
_vividTargetIndicator?.Tick();
_vitalsSideBySide?.Tick();
@ -861,11 +912,16 @@ public sealed class RetailUiRuntime : IDisposable
NegativeEffectsController?.Tick();
LinkStatusUiController?.Tick();
IndicatorBarController?.Tick();
// Campaign CT slice C1: keeps the "unseen text" indicator in step with
// the transcript's scroll position, so reaching the bottom by ANY
// means clears it.
_chatWindowController?.UpdateUnreadIndicator();
JumpPowerbarController?.Tick();
SecureTradeController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick();
SocialPanelController?.Tick();
JournalPanelController?.Tick();
MapHousePanelController?.Tick(deltaSeconds);
_itemCooldownController?.Tick();
_characterManagementMount?.Tick();
@ -1525,7 +1581,17 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Chat.Windows,
_bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite);
_bindings.Assets.ResolveSprite,
// The talk-focus menu's "Tell to X" / "Squelch (ignore) X" act on
// the current selection and carry its name.
selectedTargetName: () =>
{
uint selected = _bindings.Toolbar.Selection.SelectedObjectId ?? 0u;
if (selected == 0u)
return null;
string? name = _bindings.Toolbar.ResolveName(selected);
return string.IsNullOrWhiteSpace(name) ? null : name;
});
if (controller is null)
{
Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F.");
@ -3428,6 +3494,132 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F).");
}
/// <summary>
/// Campaign QT slice QT5: retail's three-tab Journal panel — host
/// <c>0x2100006E</c> slot <c>0x10000559</c>,
/// <see cref="RetailPanelCatalog.Journal"/> id 25. Same import/Build/Bind
/// recipe as <see cref="MountSocialPanel"/>. The Contracts page's list has
/// ONE authored row template resolved out of a DIFFERENT layout
/// (<c>0x21000069</c>) than the panel itself, which is why it needs the
/// caching <see cref="Layout.RowTemplateResolver"/> rather than
/// <see cref="UiTemplateListBox.AddItemFromTemplateList"/>'s own path.
/// </summary>
private void MountJournalPanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
Layout.JournalPanelController.HostLayoutId,
Layout.JournalPanelController.SlotElementId);
var resolver = new DatStringResolver(_bindings.Assets.Dats);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
resolver.Resolve);
}
if (rootInfo is null || layout is null)
{
Console.WriteLine(
"[UI] journal panel: LayoutDesc 0x2100006E slot 0x10000559 not found.");
return;
}
var rowTemplates = new Layout.RowTemplateResolver(
(layoutId, elementId) => LayoutImporter.ImportInfos(
_bindings.Assets.Dats, layoutId, elementId),
info =>
{
var strings = new DatStringResolver(_bindings.Assets.Dats);
return LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
});
var callbacks = new Layout.JournalPanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.Journal),
Contracts: new Layout.JournalContractsPageController.Bindings(
Contracts: _bindings.Quests.Contracts,
Catalog: _bindings.Quests.Catalog,
Now: () => DateTime.UtcNow,
TemplateResolver: (templateLayoutId, templateElementId) =>
{
lock (_bindings.Assets.DatLock)
return rowTemplates.Resolve(templateLayoutId, templateElementId);
},
Abandon: _bindings.Quests.AbandonContract),
Notes: new Layout.JournalNotesPageController.Bindings(
Journal: _bindings.Quests.Journal,
Commands: _bindings.Quests.JournalCommands,
PlayerCell: _bindings.Quests.PlayerCell,
Now: () => DateTime.UtcNow),
SaveJournal: SaveJournal,
PageList: openPage => new Layout.JournalPageListController.Bindings(
Journal: _bindings.Quests.Journal,
Commands: _bindings.Quests.JournalCommands,
OpenPage: openPage,
TemplateResolver: (templateLayoutId, templateElementId) =>
{
lock (_bindings.Assets.DatLock)
return rowTemplates.Resolve(templateLayoutId, templateElementId);
},
Now: () => DateTime.UtcNow));
Layout.JournalPanelController? controller;
lock (_bindings.Assets.DatLock)
controller = Layout.JournalPanelController.Bind(layout, callbacks);
if (controller is null)
{
Console.WriteLine("[UI] journal panel: required root did not build as UiTabPanel.");
return;
}
controller.ActivateTabs();
JournalPanelController = controller;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
controller.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Journal,
Chrome = RetailWindowChrome.NineSlice,
Left = 230f,
Top = 160f,
Visible = false,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
Controller = controller,
});
_panelUi.RegisterMainPanel(
RetailPanelCatalog.Journal,
WindowNames.Journal,
handle,
rootInfo.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
Console.WriteLine(
"[UI] retail journal panel from LayoutDesc importer (0x2100006E slot 0x10000559).");
}
/// <summary>
/// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House
/// panel — host <c>0x2100006E</c> slot <c>0x1000018C</c>,

View file

@ -332,7 +332,22 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
/// Matches <see cref="UiDatElement.ActiveState"/>.
/// </summary>
public string ActiveState { get; set; } = "";
private string _activeState = "";
private double _activeStateStartedAt;
public string ActiveState
{
get => _activeState;
set
{
if (string.Equals(_activeState, value, StringComparison.Ordinal))
return;
_activeState = value;
// An authored media sequence is timed from the moment its state is
// entered, so this is the only thing an element needs to remember.
_activeStateStartedAt = Layout.UiMediaClock.Seconds;
}
}
public uint ActiveRetailStateId
{
@ -491,7 +506,6 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
? repeatInterval
: 0f;
_selected = info.TryGetEffectiveBool(0x0Eu, out bool selected) && selected;
bool disabled = info.TryGetEffectiveBool(0x0Du, out bool ghosted) && ghosted;
// State defaulting matches UiDatElement exactly:
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
@ -501,7 +515,25 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
Enabled = !disabled;
// Property 0x0D was previously read here as "starts disabled"
// (Enabled = !0x0D). That was the one uncited property read in this
// file, and it was wrong. Measured across every installed layout
// (LayoutDump --ghosted): 85 elements author 0x0D and ALL 85 author it
// TRUE — not one False anywhere in the client. No panel ever clears it
// either: the only four SetAttribute_Bool(…, 0xd, …) sites in the
// binary are chargen appearance, the keymap option and the barber, and
// gmContractsUI/gmJournalUI never call SetAttribute at all.
//
// A flag that is only ever authored true, never cleared, and sits on
// elements that must be clickable — New, Record, Start, Delete, Reset,
// the search box — cannot mean "this button is dead". Reading it that
// way disabled every button on the Journal panel: visible, because
// drawing does not consult Enabled, but unclickable, because
// UiElement.HitTest skips disabled elements.
//
// Retail's UIElement_Button::UpdateState_ @0x00471CF0 uses 0x0D to
// pick the Ghosted VISUAL state, which the state machine below already
// models through ActiveState. Input is not its business.
FaceWidth = mediaInfo?.Width ?? info.Width;
FaceHeight = mediaInfo?.Height ?? info.Height;
UpdateVisualState();
@ -592,18 +624,64 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
private static uint ActiveFile(ElementInfo mediaInfo, string mediaState)
=> mediaInfo.StateMedia.TryGetValue(mediaState, out var m) ? m.File : 0u;
/// <summary>
/// The frame this element's active state is showing right now, and the
/// state its sequence hands off to when it ends.
/// </summary>
/// <remarks>
/// A state whose media is a single image is NOT routed through the player;
/// it keeps the still-frame path, so the overwhelming majority of buttons
/// are untouched by this.
/// </remarks>
private uint AnimatedFile(ElementInfo mediaInfo, string mediaState, out uint? handOff)
{
handOff = null;
if (!TryFindStateNamed(mediaInfo, mediaState, out UiStateInfo? state)
|| !Layout.UiMediaSequence.IsAnimated(state!.MediaSteps))
{
return ActiveFile(mediaInfo, mediaState);
}
(uint file, uint? transition) = Layout.UiMediaSequence.Sample(
state.MediaSteps,
(float)(Layout.UiMediaClock.Seconds - _activeStateStartedAt));
handOff = transition;
return file != 0u ? file : ActiveFile(mediaInfo, mediaState);
}
private static bool TryFindStateNamed(
ElementInfo mediaInfo, string mediaState, out UiStateInfo? state)
{
foreach (var (_, candidate) in mediaInfo.States)
{
if (string.Equals(candidate.Name, mediaState, StringComparison.Ordinal))
{
state = candidate;
return true;
}
}
state = null;
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
SyncMediaStates();
// An authored media sequence can end by handing the element to another
// state (the chat unseen-text indicator blinks three times, then hands
// off to Ghosted). Collected here and applied AFTER the draw: changing
// state mid-draw would invalidate the very media being drawn.
uint? pendingHandOff = null;
if (_faceSegments.Length != 0)
{
for (int i = 0; i < _faceSegments.Length; i++)
{
FaceSegment segment = _faceSegments[i];
DrawFace(
ctx,
ActiveFile(segment.Info, _segmentMediaStates[i]),
segment.Rect(Width, Height));
uint frame = AnimatedFile(
segment.Info, _segmentMediaStates[i], out uint? segmentHandOff);
DrawFace(ctx, frame, segment.Rect(Width, Height));
pendingHandOff ??= segmentHandOff;
}
}
else if (ColorKeyFaceResolver is { } colorKeyResolver)
@ -624,7 +702,8 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
}
else
{
uint file = FaceFileOverride ?? ActiveFile(_mediaInfo, _faceMediaState);
uint file = FaceFileOverride
?? AnimatedFile(_mediaInfo, _faceMediaState, out pendingHandOff);
if (file != 0)
{
var (tex, tw, th) = _resolve(file);
@ -640,6 +719,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
}
}
// The sequence has run out and asked for another state. Applied here,
// after every face has drawn this frame.
if (pendingHandOff is { } handOffState)
TrySetRetailState(handOffState);
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
// GF-11c: LabelBox null (every pre-existing button) reduces boxX/

View file

@ -156,6 +156,19 @@ public sealed class UiField : UiElement
// ── Editing primitives ──────────────────────────────────────────────
/// <summary>
/// Offered the field's text each time a SPACE is typed at the end of it;
/// return a replacement to rewrite the field, or <see langword="null"/> to
/// leave it alone.
/// </summary>
/// <remarks>
/// Retail's typed-abbreviation expansion — "/r " becoming
/// "@tell {LastTeller}, " — lives here rather than in the submit path
/// because the player is meant to SEE who they are about to reply to
/// before pressing enter.
/// </remarks>
public Func<string, string?>? TextReplacer { get; set; }
public void InsertChar(char c)
{
if (!Editable) return;
@ -169,6 +182,19 @@ public sealed class UiField : UiElement
_text = _text.Insert(_caret, c.ToString());
_caret++;
_historyIndex = -1;
// Retail expands a typed abbreviation the moment the SPACE lands —
// ChatInterface::HandleTextReplacements @0x004F50D0 runs off the
// character-typed broadcast, keyed on 0x20. Only on space, and only
// when the caret is at the end, so it cannot rewrite text a player is
// editing in the middle of.
if (c == ' '
&& TextReplacer is { } replace
&& _caret == _text.Length
&& replace(_text) is { } replacement)
{
SetText(replacement);
}
}
public void Backspace()
@ -789,6 +815,21 @@ public sealed class UiField : UiElement
bool shift = Selectable && ShiftHeld();
switch (key)
{
// Campaign CT slice C2. Retail maps Escape to input action
// 0x0B, which runs ChatInterface::DeactivateChatEntry
// @0x004F2FC0: RelinquishFocus, then Deactivate. It does
// NOT clear the field — whatever you had typed is still
// there when you come back.
//
// Without this the key was a complete no-op: there is no
// Escape case, and a focused field also reports
// IsEditControl, which makes UiRoot skip its own fallback
// and the input dispatcher withhold game actions. So the
// player had no way out except the mouse.
case Silk.NET.Input.Key.Escape:
FindRoot()?.SetKeyboardFocus(null);
return true;
case Silk.NET.Input.Key.Enter:
case Silk.NET.Input.Key.KeypadEnter:
if (!OneLine)

View file

@ -28,6 +28,17 @@ public sealed class UiMenu : UiElement
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
public Action<object?>? OnSelect { get; set; }
/// <summary>
/// Raised as the popup opens, before the rows are measured or drawn.
/// </summary>
/// <remarks>
/// Retail rebuilds the talk-focus menu's two selection-dependent entries
/// each time it opens (gmMainChatUI @0x004CD8E9), so their labels track
/// whoever is selected right now. A menu whose Items were fixed at Bind
/// could only ever say "Tell to Selected".
/// </remarks>
public Action? OnOpen { get; set; }
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.</summary>
public Func<object?, bool>? EnabledProvider { get; set; }
@ -258,6 +269,9 @@ public sealed class UiMenu : UiElement
private void SetOpen(bool value)
{
if (_open == value) return;
// Before _open flips, so a handler that replaces Items is reflected in
// the very first measure/draw of this opening.
if (value) OnOpen?.Invoke();
_open = value;
if (FindRoot() is not { } root) return;
if (value) root.SetActivePopup(this, () => SetOpen(false));

View file

@ -42,7 +42,10 @@ public sealed class UiText : UiElement, IUiDatStateful
private Action? _onClick;
public override bool HandlesClick
=> OnClick is not null || WheelScrollEnabled || base.HandlesClick;
=> OnClick is not null
|| OnCharClick is not null
|| WheelScrollEnabled
|| base.HandlesClick;
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>
public uint ElementId { get; set; }
@ -58,6 +61,18 @@ public sealed class UiText : UiElement, IUiDatStateful
/// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary>
public readonly record struct Pos(int Line, int Col);
/// <summary>
/// Offered the character under a left click before <see cref="OnClick"/>.
/// Return <see langword="true"/> to consume the click.
/// </summary>
/// <remarks>
/// The seam for retail's clickable tagged runs. Kept separate from
/// <see cref="OnClick"/> because a tag click is positional and an element
/// click is not; folding them together would make every text element with
/// an OnClick swallow tag clicks.
/// </remarks>
public Func<Pos, bool>? OnCharClick { get; set; }
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
@ -68,6 +83,80 @@ public sealed class UiText : UiElement, IUiDatStateful
/// </summary>
public Func<IReadOnlyList<TextRun>>? RunsProvider { get; set; }
/// <summary>
/// Optional per-line inline fragments for the MULTI-line path, by line
/// index. Return <see langword="null"/> for a line that is a single colour.
/// </summary>
/// <remarks>
/// <para>
/// Campaign CT slice A1. Retail's chat transcript is not uniformly
/// coloured per line: a tagged glyph run (a speaker's name) takes the
/// element's TAG colour while the rest of the line takes the ordinary one
/// (<c>UIElement_Text::InqGlyphs @0x00468EA0</c>, properties 0x1D vs 0x1B).
/// This is the seam that lets a line carry more than one colour.
/// </para>
/// <para>
/// Deliberately a SIDECAR rather than a field on <see cref="Line"/>:
/// roughly fifty files construct <c>Line</c>, and changing its shape would
/// put every one of them in the blast radius of a chat feature.
/// </para>
/// <para>
/// <b>Invariant:</b> the concatenated run text MUST equal the line's own
/// <see cref="Line.Text"/>. Selection, hit-testing and the caret all index
/// into that flat string, so a run list that disagrees with it would draw
/// one thing and select another. <see cref="RunsMatchLine"/> states the
/// check; the draw path falls back to the flat line rather than trusting a
/// mismatched list.
/// </para>
/// </remarks>
public Func<int, IReadOnlyList<TextRun>?>? LineRunsProvider { get; set; }
/// <summary>
/// Places a line's runs left-to-right from <paramref name="startX"/>,
/// advancing the pen by each run's measured width.
/// </summary>
/// <remarks>
/// Empty runs contribute no geometry but still advance the pen by their
/// (zero) width, so they cannot shift what follows them. Pure, so the
/// placement is testable without a font atlas or a GPU.
/// </remarks>
internal static List<(string Text, float X, Vector4 Color)> LayoutRuns(
IReadOnlyList<TextRun> runs,
float startX,
Func<string, float> measure)
{
var placed = new List<(string Text, float X, Vector4 Color)>(runs.Count);
float penX = startX;
for (int i = 0; i < runs.Count; i++)
{
TextRun run = runs[i];
if (run.Text.Length != 0)
placed.Add((run.Text, penX, run.Color));
penX += measure(run.Text);
}
return placed;
}
/// <summary>
/// Whether a run list may be drawn in place of <paramref name="line"/> —
/// i.e. whether it says exactly the same characters.
/// </summary>
internal static bool RunsMatchLine(IReadOnlyList<TextRun> runs, string line)
{
int at = 0;
for (int i = 0; i < runs.Count; i++)
{
string text = runs[i].Text;
if (at + text.Length > line.Length
|| string.CompareOrdinal(line, at, text, 0, text.Length) != 0)
{
return false;
}
at += text.Length;
}
return at == line.Length;
}
/// <summary>Font for the transcript; falls back to the context default.</summary>
public BitmapFont? Font { get; set; }
@ -93,6 +182,13 @@ public sealed class UiText : UiElement, IUiDatStateful
/// </summary>
public Vector4 DefaultColor { get; set; } = Vector4.One;
/// <summary>
/// Colour for a TAGGED run (dat property <c>0x1D</c>) — retail's clickable
/// speaker name. Falls back to <see cref="DefaultColor"/> when the element
/// authors none.
/// </summary>
public Vector4? TagColor { get; set; }
/// <summary>
/// Authored <c>UIElement_Text</c> font-color list from LayoutDesc property
/// <c>0x1B</c>. Retail <c>AppendTextWithFont @ 0x00469D70</c> selects an
@ -668,9 +764,31 @@ public sealed class UiText : UiElement, IUiDatStateful
}
}
IReadOnlyList<TextRun>? runs = LineRunsProvider?.Invoke(i);
if (runs is { Count: > 0 } && !RunsMatchLine(runs, text))
runs = null; // never draw text that disagrees with what we select
if (datFont is not null)
{
(datLines ??= new()).Add((text, lineX, y, lines[i].Color));
datLines ??= new();
if (runs is { Count: > 0 })
{
// Several entries at advancing pen-X instead of one. The
// outline/fill batching below is untouched by this: it
// walks whatever entries are here, so a multi-colour line
// still gets its whole outline pass before any fill.
foreach (var placed in LayoutRuns(runs, lineX, datFont.MeasureWidth))
datLines.Add((placed.Text, placed.X, y, placed.Color));
}
else
{
datLines.Add((text, lineX, y, lines[i].Color));
}
}
else if (runs is { Count: > 0 })
{
foreach (var placed in LayoutRuns(runs, lineX, bitmapFont!.MeasureWidth))
ctx.DrawString(placed.Text, placed.X, y, placed.Color, bitmapFont);
}
else
{
@ -806,6 +924,18 @@ public sealed class UiText : UiElement, IUiDatStateful
public override bool OnEvent(in UiEvent e)
{
// Campaign CT slice A5: a click inside the text may land on a TAGGED
// run (a speaker's name), which retail treats as its own affordance —
// UIElement_Text::MouseUp @0x004694F0 resolves the xy to a glyph and
// dispatches through that glyph's tag. Offered the character first;
// a handled tag click does not also fire the element-wide OnClick.
if (e.Type == UiEventType.Click && OnCharClick is not null)
{
// Data1/Data2 = local-to-target coords (UiRoot's Click event).
if (OnCharClick(HitChar(e.Data1, e.Data2)))
return true;
}
if (e.Type == UiEventType.Click && OnClick is not null)
{
OnClick();

View file

@ -44,4 +44,8 @@ public static class WindowNames
/// <summary>Batch C (overnight hover/UI round): the two-tab Map/House
/// panel (<see cref="RetailPanelCatalog.MapHouse"/>).</summary>
public const string MapHouse = "map-house";
/// <summary>Campaign QT slice QT5: the three-tab Contracts/Journal/Page
/// List panel (<see cref="RetailPanelCatalog.Journal"/>).</summary>
public const string Journal = "journal";
}

View file

@ -0,0 +1,72 @@
using System.Collections.Frozen;
using System.Collections.Generic;
using AcDream.Core.Quests;
using DatContractTable = DatReaderWriter.DBObjs.ContractTable;
namespace AcDream.Content;
/// <summary>
/// Projects portal.dat's ContractTable into acdream's presentation-free
/// <see cref="ContractCatalog"/>.
/// </summary>
/// <remarks>
/// Same shape as <see cref="CharGen.ChargenTableReader"/> and
/// <c>MagicCatalog.Load</c>: one static entry point over
/// <see cref="IDatReaderWriter"/>, frozen at projection, and no Chorizite
/// types crossing into the returned model.
/// <para>
/// Nothing read this table before Campaign QT — the only reference in the tree
/// counted its entries in a CLI diagnostic. The installed build holds 322
/// contracts.
/// </para>
/// </remarks>
public static class ContractTableReader
{
/// <summary>
/// Retail's ContractTable dat id (ACE:
/// <c>ACE.DatLoader.FileTypes.ContractTable.FILE_ID</c>).
/// </summary>
public const uint ContractTableDid = 0x0E00001Du;
/// <summary>
/// Loads the installed contract catalog, or
/// <see cref="ContractCatalog.Empty"/> when the table is absent.
/// </summary>
/// <remarks>
/// An absent table is not fatal. It costs the player the contract NAMES,
/// not the tracker: the wire state stands on its own, and the panel still
/// has stages and timers to draw.
/// </remarks>
public static ContractCatalog Load(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
DatContractTable? table = dats.Get<DatContractTable>(ContractTableDid);
if (table is null || table.Contracts.Count == 0)
return ContractCatalog.Empty;
var projected = new Dictionary<uint, ContractEntry>(table.Contracts.Count);
foreach ((uint key, DatReaderWriter.Types.Contract contract) in table.Contracts)
{
projected[key] = new ContractEntry(
contract.Version,
contract.ContractId,
contract.ContractName ?? string.Empty,
contract.Description ?? string.Empty,
contract.DescriptionProgress ?? string.Empty,
contract.NameNPCStart ?? string.Empty,
contract.NameNPCEnd ?? string.Empty,
contract.QuestflagStamped ?? string.Empty,
contract.QuestflagStarted ?? string.Empty,
contract.QuestflagFinished ?? string.Empty,
contract.QuestflagProgress ?? string.Empty,
contract.QuestflagTimer ?? string.Empty,
contract.QuestflagRepeatTime ?? string.Empty,
contract.LocationNPCStart?.CellId ?? 0u,
contract.LocationNPCEnd?.CellId ?? 0u,
contract.LocationQuestArea?.CellId ?? 0u);
}
return new ContractCatalog(projected.ToFrozenDictionary());
}
}

View file

@ -131,7 +131,14 @@ public static class GameEventWiring
Action<GameEvents.HouseData>? onHouseData = null,
Action<uint /*weenieError*/>? onHouseStatus = null,
Action<uint /*rentTime*/>? onHouseUpdateRentTime = null,
Action<IReadOnlyList<GameEvents.HousePayment>>? onHouseUpdateRentPayment = null)
Action<IReadOnlyList<GameEvents.HousePayment>>? onHouseUpdateRentPayment = null,
// Campaign QT (2026-08-21): the contract tracker's two events. Same
// Runtime-owned delegate-hole shape as house/trade above --
// RuntimeContractState is the consumer. Both opcodes have been named
// in GameEventType since the wire catalog with nothing behind them,
// so until this wiring the bytes arrived and were dropped.
Action<IReadOnlyDictionary<uint, ContractTracker>>? onContractTable = null,
Action<ContractTrackerUpdate>? onContractUpdate = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -446,6 +453,27 @@ public static class GameEventWiring
});
}
// Campaign QT (2026-08-21). Arrival is stamped HERE rather than
// inside the parser's caller, because FillProgressString counts a
// repeat timer down from the moment the state arrived and the server
// never sends that moment.
if (onContractTable is not null)
{
registrar.Register(GameEventType.SendClientContractTrackerTable, e =>
{
var p = ContractTrackerMessages.ParseTable(e.Payload.Span, DateTime.UtcNow);
if (p is not null) onContractTable(p);
});
}
if (onContractUpdate is not null)
{
registrar.Register(GameEventType.SendClientContractTracker, e =>
{
var p = ContractTrackerMessages.ParseUpdate(e.Payload.Span, DateTime.UtcNow);
if (p is not null) onContractUpdate(p.Value);
});
}
if (onConfirmationRequest is not null)
{
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>
@ -688,14 +716,54 @@ public static class GameEventWiring
registrar.Register(GameEventType.MagicDispelEnchantment, e =>
{
var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
if (p is null) return;
spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
NotifyOfEnchantmentRemoval((uint)p.Value.SpellId);
});
registrar.Register(GameEventType.MagicDispelMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
if (entries is not null)
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
if (entries is null) return;
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
foreach (var entry in entries)
NotifyOfEnchantmentRemoval((uint)entry.SpellId);
});
// Retail spell id 0x29A, special-cased by name in
// NotifyOfEnchantmentRemoval so the line reads "Vitae penalty has
// expired." rather than "Vitae has expired."
const uint VitaePenaltySpellId = 0x29Au;
// ClientMagicSystem::NotifyOfEnchantmentRemoval @ 0x005686C0. The
// server sends NO text for an enchantment leaving the PLAYER — it is
// the client that announces it. (Enchantments leaving an ITEM are
// different: those arrive as ordinary server chat, which is why item
// spells were the only ones showing up.)
void NotifyOfEnchantmentRemoval(uint spellId)
{
if (onInterfaceText is null)
return;
// Retail's own guards, in order: ids at or above 0x8000 are not
// spells and are skipped outright, and a spell missing from the
// table returns without printing.
if (spellId >= 0x8000
|| !spellbook.TryGetMetadata(spellId, out SpellMetadata meta))
{
return;
}
// Vitae reads "Vitae penalty has expired." — retail appends the
// word to the spell's own name for this one id.
string name = spellId == VitaePenaltySpellId
? meta.Name + " penalty"
: meta.Name;
// AddTextToScroll(text, 7, 1, 0). Retail's literal carries a
// trailing newline because its scroll appends raw text; AddText is
// line-based, so adding one here would print a blank line.
onInterfaceText($"{name} has expired.", RetailLogTextType.Magic);
}
registrar.Register(GameEventType.MagicPurgeEnchantments,
_ => spellbook.OnPurgeAll());
registrar.Register(GameEventType.MagicPurgeBadEnchantments,

View file

@ -25,6 +25,7 @@ public static class ClientCommandRequests
public const uint SetAfkMessageOpcode = 0x0010u;
public const uint EmoteOpcode = 0x01DFu;
public const uint AddFriendOpcode = 0x0018u;
public const uint AbandonContractOpcode = 0x0316u;
public const uint RemoveFriendOpcode = 0x0017u;
public const uint ClearFriendsOpcode = 0x0025u;
public const uint ModifyCharacterSquelchOpcode = 0x0058u;
@ -143,6 +144,15 @@ public static class ClientCommandRequests
public static byte[] BuildAddFriend(uint sequence, string name) =>
BuildString(sequence, AddFriendOpcode, name);
/// <summary>
/// Campaign QJ: abandon a tracked contract — game action <c>0x0316</c>,
/// payload one contract id. The server answers with <c>0x0315</c> carrying
/// <c>DeleteContract</c>, which is why the client does not remove the row
/// itself (ACE: <c>GameActionAbandonContract</c>).
/// </summary>
public static byte[] BuildAbandonContract(uint sequence, uint contractId) =>
BuildUInt32(sequence, AbandonContractOpcode, contractId);
public static byte[] BuildRemoveFriend(uint sequence, uint friendId) =>
BuildUInt32(sequence, RemoveFriendOpcode, friendId);

View file

@ -0,0 +1,196 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// How far along a contract is.
/// </summary>
/// <remarks>
/// Not a dense enum: retail encodes a progress COUNTER by adding the number of
/// completed steps to <see cref="ProgressCounter"/>, so any value at or above 4
/// is "in progress with (value - 4) done". <see cref="ContractTracker.Progress"/>
/// does that arithmetic rather than leaving callers to remember it.
/// </remarks>
public enum ContractStage : uint
{
Available = 1,
InProgress = 2,
DoneOrPendingRepeat = 3,
ProgressCounter = 4,
}
/// <summary>
/// One contract's live state, as the server sees it.
/// </summary>
/// <param name="Version">The dat <c>Contract.Version</c> this state was built against.</param>
/// <param name="ContractId">Key into the <c>ContractTable</c> dat.</param>
/// <param name="Stage">See <see cref="ContractStage"/>.</param>
/// <param name="TimeWhenDone">Seconds until the current cooldown ends.</param>
/// <param name="TimeWhenRepeats">Seconds until the repeat cooldown ends.</param>
/// <param name="ReceivedAt">
/// When this state reached us. NOT on the wire — retail's <c>CContractTracker</c>
/// carries its own <c>_time_of_server_update</c> and
/// <c>gmContractsUI::FillProgressString @0x00498DE0</c> counts down from it
/// (<c>TimeWhenRepeats - (now - timeOfServerUpdate)</c>). Anchoring at parse
/// time is what makes the countdown tick; recomputing from the server value
/// every frame would freeze it.
/// </param>
public readonly record struct ContractTracker(
uint Version,
uint ContractId,
ContractStage Stage,
double TimeWhenDone,
double TimeWhenRepeats,
DateTime ReceivedAt)
{
/// <summary>The wire size of one tracker struct.</summary>
internal const int WireSize = 4 + 4 + 4 + 8 + 8;
/// <summary>
/// Completed steps, when the stage carries a counter; 0 otherwise.
/// </summary>
public uint Progress =>
(uint)Stage >= (uint)ContractStage.ProgressCounter
? (uint)Stage - (uint)ContractStage.ProgressCounter
: 0u;
/// <summary>Whether the stage encodes a progress counter at all.</summary>
public bool HasProgressCounter =>
(uint)Stage >= (uint)ContractStage.ProgressCounter;
}
/// <summary>
/// A single-contract update: <c>0x0315 SendClientContractTracker</c>.
/// </summary>
/// <param name="Delete">Remove this contract from the tracker entirely.</param>
/// <param name="SetAsDisplay">Make this the contract the panel shows.</param>
public readonly record struct ContractTrackerUpdate(
ContractTracker Tracker,
bool Delete,
bool SetAsDisplay);
/// <summary>
/// Parsers for retail's two contract-tracker game events.
/// </summary>
/// <remarks>
/// <para>
/// Both opcodes have been NAMED in <see cref="GameEventType"/> since the wire
/// catalog work without anything parsing them, so the bytes have been arriving
/// and being dropped.
/// </para>
/// <para>
/// Layout confirmed from ACE's writers (<c>ContractTrackerExtensions.Write</c>,
/// <c>GameEventSendClientContractTracker</c>, <c>ContractManager.Write</c>) and
/// cross-checked against the retail client's own
/// <c>PackableHashTable&lt;unsigned long, CContractTracker&gt;</c>
/// instantiations at <c>0x00497C10</c>.
/// </para>
/// </remarks>
public static class ContractTrackerMessages
{
/// <summary>
/// A table larger than this is a decode error rather than a big quest log:
/// retail pcaps top out around 3,208 bytes, i.e. well under a hundred
/// entries.
/// </summary>
private const int MaxTableEntries = 4096;
/// <summary>
/// <c>0x0315</c> — one tracker plus the two flags ACE appends AFTER the
/// struct (they are deliberately not part of <c>Write</c>; see its own
/// commented-out lines).
/// </summary>
public static ContractTrackerUpdate? ParseUpdate(
ReadOnlySpan<byte> payload, DateTime receivedAt)
{
int pos = 0;
try
{
ContractTracker tracker = ReadTracker(payload, ref pos, receivedAt);
uint delete = ReadU32(payload, ref pos);
uint display = ReadU32(payload, ref pos);
return new ContractTrackerUpdate(tracker, delete != 0u, display != 0u);
}
catch (FormatException)
{
return null;
}
}
/// <summary>
/// <c>0x0314</c> — the complete replacement table. No trailing flags on
/// this path.
/// </summary>
/// <returns>
/// The trackers by contract id, or null if the payload does not decode.
/// An EMPTY table is a valid, meaningful answer — it is how the server says
/// "you have no contracts" — so it must not be confused with a decode
/// failure.
/// </returns>
public static IReadOnlyDictionary<uint, ContractTracker>? ParseTable(
ReadOnlySpan<byte> payload, DateTime receivedAt)
{
int pos = 0;
try
{
uint header = ReadU32(payload, ref pos);
ushort count = (ushort)(header & 0xFFFFu);
ushort buckets = (ushort)(header >> 16);
// A zero-bucket table is valid only when it is empty — the early
// return in PackableHashTable::UnPack @0x006B1A86.
if (buckets == 0 && count != 0)
throw new FormatException("invalid contract tracker table");
if (count > MaxTableEntries)
throw new FormatException("implausible contract tracker count");
var result = new Dictionary<uint, ContractTracker>(count);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(payload, ref pos);
result[key] = ReadTracker(payload, ref pos, receivedAt);
}
return result;
}
catch (FormatException)
{
return null;
}
}
private static ContractTracker ReadTracker(
ReadOnlySpan<byte> source, ref int pos, DateTime receivedAt)
{
uint version = ReadU32(source, ref pos);
uint contractId = ReadU32(source, ref pos);
uint stage = ReadU32(source, ref pos);
double whenDone = ReadDouble(source, ref pos);
double whenRepeats = ReadDouble(source, ref pos);
return new ContractTracker(
version,
contractId,
(ContractStage)stage,
whenDone,
whenRepeats,
receivedAt);
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 4) throw new FormatException("truncated u32");
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos, 4));
pos += 4;
return value;
}
private static double ReadDouble(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 8) throw new FormatException("truncated double");
double value = BinaryPrimitives.ReadDoubleLittleEndian(source.Slice(pos, 8));
pos += 8;
return value;
}
}

View file

@ -2597,6 +2597,17 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildHouseQuery(seq));
}
/// <summary>
/// Abandon a tracked contract (0x0316). The row disappears when the server
/// answers with its own 0x0315 delete — the client never removes it
/// locally, so a refused abandon leaves the quest exactly where it was.
/// </summary>
public void SendAbandonContract(uint contractId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAbandonContract(seq, contractId));
}
/// <summary>Query the local character's played time (0x01C2).</summary>
public void SendQueryAge()
{

View file

@ -507,8 +507,9 @@ public readonly record struct ChatEntry(
/// <summary>
/// Phase I.7: severity bucket for <see cref="ChatKind.Combat"/>
/// entries. Null for every other kind. Drives the
/// <see cref="ChatPanel"/>'s <c>TextColored</c> color choice.
/// entries. Null for every other kind. Carried through to the transcript
/// on <c>FormattedLine</c>; the per-message colour itself now comes from
/// the retail colour table keyed by <c>LogTextType</c>.
/// </summary>
public Combat.CombatLineKind? CombatKind { get; init; }

View file

@ -0,0 +1,184 @@
using System;
using System.IO;
using System.Text;
namespace AcDream.Core.Chat;
/// <summary>
/// Retail's <c>@log</c> chat-to-file capture.
/// </summary>
/// <remarks>
/// <para>
/// This is NOT an automatic session transcript. Retail opens a log only when
/// the player asks for one by name — <c>ClientCommunicationSystem::DoSetOutput
/// @0x0057E4F0</c> takes a filename, <c>StartCopyOutputToFile @0x0057C8A0</c>
/// does the <c>fopen(name, "a+")</c>, and running the command again with no
/// argument closes it. The file is APPENDED to, never rotated and never
/// truncated, which is what retail's own help promises: "If this file already
/// exists, it will add the additional text to the end of it."
/// </para>
/// <para>
/// Every line goes out as <c>timestamp + text + "\n"</c>
/// (<c>fprintf(s_pLogFile, "%ls%ls\n", …) @0x00563E5B</c>, inside
/// <c>ClientSystem::AddTextToScroll</c>), with the timestamp present only when
/// the <c>DisplayTimeStamps</c> option is on — the log and the chat window
/// share the one stamp rather than deciding separately.
/// </para>
/// <para>
/// File handling only. What a line SAYS is composed upstream, because retail
/// logs the finished display line rather than re-deriving one.
/// </para>
/// </remarks>
public sealed class ChatSessionLog : IDisposable
{
private readonly string _baseDirectory;
private StreamWriter? _writer;
/// <param name="baseDirectory">
/// Where a bare filename lands. Retail says "your Asheron's Call
/// directory" — its install directory — which acdream cannot use: the
/// launcher replaces the install atomically on update, so a file written
/// there is wiped or blocks the update. The client's own log directory is
/// the equivalent that survives. Rooted paths are still honoured verbatim,
/// as retail's <c>fopen</c> would.
/// </param>
public ChatSessionLog(string baseDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory);
_baseDirectory = baseDirectory;
}
/// <summary>The name the player asked for, or null when nothing is open.</summary>
public string? CurrentName { get; private set; }
public bool IsOpen => _writer is not null;
/// <summary>
/// Retail appends <c>.txt</c> to an extensionless name
/// (<c>PSUtils::get_extension</c> against the empty string, then
/// <c>+= ".txt"</c>). A name that already carries ANY extension is left
/// alone — "chat.old" stays "chat.old" rather than becoming "chat.old.txt".
/// </summary>
public static string EnsureExtension(string name)
=> Path.GetExtension(name).Length == 0 ? name + ".txt" : name;
/// <summary>
/// Opens <paramref name="name"/> for append, closing any log already open
/// first — retail's <c>StartCopyOutputToFile</c> calls <c>CloseLogFile</c>
/// before it does anything else.
/// </summary>
/// <returns>
/// Whether the file opened. Retail reports failure to the player rather
/// than treating it as fatal, so an unwritable path is an ordinary answer
/// here and not an exception.
/// </returns>
public bool Open(string name, out string resolvedName)
{
resolvedName = string.Empty;
Close();
if (string.IsNullOrWhiteSpace(name))
return false;
resolvedName = EnsureExtension(name.Trim());
try
{
string path = Path.IsPathRooted(resolvedName)
? resolvedName
: Path.Combine(_baseDirectory, resolvedName);
string? directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
_writer = new StreamWriter(
new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
{
// Flushed per line: a chat log's whole point is being readable
// while the client is still running, and a crash must not eat
// the tail that explains it.
AutoFlush = true,
};
CurrentName = resolvedName;
return true;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException
or ArgumentException or NotSupportedException)
{
_writer = null;
CurrentName = null;
return false;
}
}
/// <summary>Closes the open log, if any.</summary>
/// <returns>
/// Whether one WAS open. Retail's <c>CloseLogFile</c> returns this and its
/// caller uses it to choose between "closed" and "please specify a file".
/// </returns>
public bool Close()
{
if (_writer is null)
return false;
try
{
_writer.Dispose();
}
catch (IOException)
{
// The line is already gone; failing to flush a closing file is not
// something the player can act on.
}
_writer = null;
CurrentName = null;
return true;
}
/// <summary>
/// Writes one transcript line. A no-op when no log is open, so the caller
/// can hand every line over unconditionally the way retail does.
/// </summary>
public void Write(string? timestampPrefix, string? text)
{
StreamWriter? writer = _writer;
if (writer is null)
return;
try
{
writer.Write(timestampPrefix);
writer.Write(text);
writer.Write('\n');
}
catch (IOException)
{
// A vanished drive or a full disk stops the log; it must not stop
// chat. Retail ignores fprintf's return except to warn about very
// long lines.
}
}
public void Dispose() => Close();
}
/// <summary>
/// What one <c>@log</c> invocation did, so the caller can print retail's
/// replies without reaching into the file handle.
/// </summary>
/// <param name="Opened">A new log was opened.</param>
/// <param name="Closed">
/// A log that WAS open got closed. True both when closing is the whole point
/// and when opening a second log displaced the first — retail's
/// <c>StartCopyOutputToFile</c> closes before it opens, and announces it.
/// </param>
/// <param name="Name">The resolved name of the new log, extension included.</param>
/// <param name="ClosedName">The name of the log that was closed, if any.</param>
public readonly record struct ChatLogResult(
bool Opened,
bool Closed,
string Name,
string? ClosedName);

View file

@ -0,0 +1,165 @@
namespace AcDream.Core.Chat;
/// <summary>
/// One retail text tag: the <c>TYPE</c>, <c>FORMAT</c> and <c>DATA</c> of a
/// <c>&lt;TYPE:FORMAT:DATA&gt;</c> marker.
/// </summary>
/// <param name="Type">
/// The tag kind, e.g. <c>Tell</c>. Retail resolves this through the DAT
/// EnumMapper (category <c>0x18</c>) to the numeric type its colour rule keys
/// on; only <c>Tell</c> is confirmed in the build we target.
/// </param>
/// <param name="Format">
/// The payload shape, e.g. <c>IIDString</c>. Retail has four
/// (<c>DID</c>, <c>IID</c>, <c>IIDEnum</c>, <c>IIDString</c>) but only
/// <c>IIDString</c> has a listener.
/// </param>
/// <param name="Data">Everything after the second colon, unparsed.</param>
public readonly record struct ChatTextTag(string Type, string Format, string Data)
{
/// <summary>
/// The <c>IIDString</c> payload: an object id and a name. Returns
/// <see langword="false"/> for any other format, or a malformed payload.
/// </summary>
/// <remarks>
/// The name may itself contain colons, so only the FIRST colon separates
/// the id from the name — retail's
/// <c>TextTag_IIDString::ParseStartTag @0x00478910</c> reads the id then
/// takes the remainder verbatim.
/// </remarks>
public bool TryGetIidString(out uint objectId, out string name)
{
objectId = 0;
name = string.Empty;
if (!string.Equals(Format, "IIDString", StringComparison.Ordinal))
return false;
int split = Data.IndexOf(':');
if (split <= 0 || split == Data.Length - 1)
return false;
if (!uint.TryParse(
Data.AsSpan(0, split),
System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture,
out objectId))
{
return false;
}
name = Data[(split + 1)..];
return true;
}
}
/// <summary>What a span IS, for colouring purposes.</summary>
public enum ChatSpanRole
{
/// <summary>Ordinary message text; takes the line's own colour.</summary>
Body,
/// <summary>
/// The leading timestamp. Retail appends it as its own run at a FIXED
/// colour index (<c>0x0C</c>) rather than the line's
/// (<c>ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640</c>),
/// so it stays grey whatever colour the message is.
/// </summary>
Timestamp,
}
/// <summary>One stretch of chat text, and the tag covering it (if any).</summary>
public readonly record struct ChatTextSpan(
string Text,
ChatTextTag? Tag,
ChatSpanRole Role = ChatSpanRole.Body);
/// <summary>
/// Parses retail's inline chat tag markup into spans.
/// </summary>
/// <remarks>
/// <para>
/// Retail's client composes chat lines with the markup already embedded —
/// <c>ClientCommunicationSystem::Handle_Communication__HearSpeech
/// @0x005712A0</c> sprintf's it — and the text element recognises it while
/// appending (<c>UIElement_Text::InqGlyphs @0x00468EA0</c>), calling
/// <c>TextTagFactory::MakeTag @0x00478480</c> per marker. A tagged speaker
/// name arrives looking like:
/// </para>
/// <code>
/// &lt;Tell:IIDString:1342177290:Dww&gt;Dww&lt;\Tell&gt; tells you, "hello"
/// </code>
/// <para>
/// <b>What closes a tag is the absence of a colon, not the backslash.</b>
/// <c>MakeTag</c> requires a <c>:</c> to succeed, so ANY bracketed text it
/// cannot parse closes the open tag — the backslash in retail's own closer
/// (<c>TextTag::BuildEndTag @0x00479190</c>) is incidental to that rule, not
/// the mechanism. Porting "a closer starts with a backslash" would look right
/// on every retail line and then diverge on everything else.
/// </para>
/// </remarks>
public static class ChatTagMarkup
{
/// <summary>
/// Splits <paramref name="text"/> into spans, consuming the markers.
/// </summary>
/// <remarks>
/// An unterminated <c>&lt;</c> (no closing <c>&gt;</c> anywhere after it)
/// is ordinary text — there is no marker to consume.
/// </remarks>
public static IReadOnlyList<ChatTextSpan> Parse(string? text)
{
if (string.IsNullOrEmpty(text))
return Array.Empty<ChatTextSpan>();
if (text.IndexOf('<') < 0)
return new[] { new ChatTextSpan(text, null) };
var spans = new List<ChatTextSpan>();
ChatTextTag? open = null;
int runStart = 0;
for (int i = 0; i < text.Length; i++)
{
if (text[i] != '<')
continue;
int close = text.IndexOf('>', i + 1);
if (close < 0)
break; // unterminated: the rest is plain text
// Flush the text before this marker under whatever tag was open.
if (i > runStart)
spans.Add(new ChatTextSpan(text[runStart..i], open));
open = TryParseStartTag(text[(i + 1)..close]);
runStart = close + 1;
i = close;
}
if (runStart < text.Length)
spans.Add(new ChatTextSpan(text[runStart..], open));
return spans;
}
/// <summary>
/// The marker's tag, or <see langword="null"/> if it does not parse as one
/// — which is retail's close.
/// </summary>
private static ChatTextTag? TryParseStartTag(string inner)
{
int first = inner.IndexOf(':');
if (first <= 0 || first == inner.Length - 1)
return null;
int second = inner.IndexOf(':', first + 1);
if (second < 0)
{
// TYPE:FORMAT with no payload. Still a tag — the data is empty.
return new ChatTextTag(inner[..first], inner[(first + 1)..], string.Empty);
}
return new ChatTextTag(
inner[..first],
inner[(first + 1)..second],
inner[(second + 1)..]);
}
}

View file

@ -0,0 +1,219 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
namespace AcDream.Core.Journal;
/// <summary>The outcome of reading a journal file.</summary>
/// <param name="Pages">The pages read, oldest first.</param>
/// <param name="Error">
/// Retail's own message when the file is unusable, or null on success. An
/// ABSENT file is success with no pages, not an error — a character who has
/// never written a page has no file.
/// </param>
public readonly record struct JournalReadResult(
IReadOnlyList<JournalPage> Pages,
string? Error);
/// <summary>
/// Retail's journal file — <c>gmJournalUI::LoadPages @0x00496AC0</c> and
/// <c>SavePages @0x00497270</c>.
/// </summary>
/// <remarks>
/// <para>
/// A plain tagged text file, one tag per line, <c>&lt;NEWP&gt;</c> opening each
/// page. Retail writes it with <c>fopen</c> mode <c>w+</c> — the whole file is
/// rewritten, never appended.
/// </para>
/// <para>
/// A file that does not OPEN with a page marker is refused outright, with its
/// own message. That is retail being strict about a file the player could have
/// hand-edited, and it is ported rather than softened: silently accepting a
/// malformed file would scatter the first page's text into no page at all.
/// </para>
/// </remarks>
public static class JournalFile
{
private const string NewPage = "<NEWP>";
private const string PageNumber = "<PNUM>";
private const string Label = "<LABE>";
private const string Title = "<TITL>";
private const string Notes = "<NOTE>";
private const string Days = "<DAYS>";
private const string Hours = "<HOUR>";
private const string Minutes = "<MINU>";
private const string LocationX = "<LOCX>";
private const string LocationY = "<LOCY>";
private const string RunningTime = "<TIME>";
/// <summary>Retail's own load failure, byte-decoded from the paired binary.</summary>
public const string MalformedFileMessage =
"Problem loading journal: Your journal file does not create a new page!";
/// <summary>
/// The per-character file name — retail's <c>"%s%s-%s-%s.txt"</c> with the
/// literal prefix <c>"Journal"</c> both call sites pass.
/// </summary>
public static string FileNameFor(string serverName, string characterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
return $"Journal-{Sanitize(serverName)}-{Sanitize(characterName)}.txt";
}
/// <summary>
/// Strips anything a file name cannot carry. Retail had no need for this —
/// its server and character names never contained a path separator — but a
/// name is outside data here and must not be able to redirect a write.
/// </summary>
private static string Sanitize(string value)
{
var text = new StringBuilder(value.Length);
foreach (char c in value)
{
text.Append(Array.IndexOf(Path.GetInvalidFileNameChars(), c) >= 0 ? '_' : c);
}
return text.ToString();
}
public static JournalReadResult Read(string text)
{
ArgumentNullException.ThrowIfNull(text);
var pages = new List<JournalPage>();
JournalPage? current = null;
bool sawAnyContent = false;
foreach (string rawLine in text.Split('\n'))
{
string line = rawLine.TrimEnd('\r');
if (line.Length == 0)
continue;
if (!line.StartsWith('<'))
{
// Retail's reader is tag-driven; a line with no tag belongs to
// nothing and is skipped rather than guessed at.
sawAnyContent = true;
continue;
}
string tag = line.Length >= 6 ? line[..6] : line;
string value = line.Length > 7 ? line[7..] : string.Empty;
if (tag == NewPage)
{
if (current is not null)
pages.Add(current);
current = JournalPage.Empty;
sawAnyContent = true;
continue;
}
if (current is null)
{
// Content before the first page marker: the file does not
// create a new page.
return new JournalReadResult([], MalformedFileMessage);
}
sawAnyContent = true;
current = tag switch
{
Label => current with { Label = value },
Title => current with { Title = value },
Notes => current with { Notes = value },
Days => current with { TimerDays = ParseInt(value) },
Hours => current with { TimerHours = ParseInt(value) },
Minutes => current with { TimerMinutes = ParseInt(value) },
LocationX => current with
{
LocationX = ParseFloat(value),
HasLocation = true,
},
LocationY => current with
{
LocationY = ParseFloat(value),
HasLocation = true,
},
RunningTime => current with { RunningTimerSeconds = ParseFloat(value) },
// <PNUM> is written but carries no information the reader
// needs: page order IS file order. Anything unrecognised is
// skipped for the same reason a newer client's tag should not
// make an older one refuse the file.
_ => current,
};
}
if (current is not null)
pages.Add(current);
// An empty file is a character who has never written a page.
if (pages.Count == 0 && sawAnyContent)
return new JournalReadResult([], MalformedFileMessage);
for (int i = 0; i < pages.Count; i++)
pages[i] = pages[i].Clipped();
return new JournalReadResult(pages, null);
}
/// <summary>
/// Writes the pages in retail's tag order. Every page is written whole,
/// including empty fields, because that is what retail's own writer does
/// and a reader keyed on tag presence must not have to infer absence.
/// </summary>
public static string Write(IReadOnlyList<JournalPage> pages)
{
ArgumentNullException.ThrowIfNull(pages);
var text = new StringBuilder();
for (int i = 0; i < pages.Count; i++)
{
JournalPage page = pages[i];
text.Append(NewPage).Append('\n');
Append(text, PageNumber, (i + 1).ToString(CultureInfo.InvariantCulture));
Append(text, Label, page.Label);
Append(text, Title, page.Title);
Append(text, Notes, page.Notes);
Append(text, Days, page.TimerDays.ToString(CultureInfo.InvariantCulture));
Append(text, Hours, page.TimerHours.ToString(CultureInfo.InvariantCulture));
Append(text, Minutes, page.TimerMinutes.ToString(CultureInfo.InvariantCulture));
if (page.HasLocation)
{
Append(text, LocationX, Format(page.LocationX));
Append(text, LocationY, Format(page.LocationY));
}
Append(text, RunningTime, Format(page.RunningTimerSeconds));
}
return text.ToString();
static void Append(StringBuilder text, string tag, string value)
{
// A newline inside a value would read back as a separate line and
// silently truncate the field. Retail's single-line edit boxes
// cannot produce one; the multi-line notes box can, so they are
// folded to spaces rather than corrupting the file.
text.Append(tag).Append(' ')
.Append(value.Replace('\n', ' ').Replace('\r', ' '))
.Append('\n');
}
}
private static string Format(double value) =>
value.ToString("0.######", CultureInfo.InvariantCulture);
private static int ParseInt(string value) =>
int.TryParse(value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int result)
? result
: 0;
private static float ParseFloat(string value) =>
float.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out float result)
? result
: 0f;
}

View file

@ -0,0 +1,77 @@
namespace AcDream.Core.Journal;
/// <summary>
/// One page of retail's per-character journal.
/// </summary>
/// <remarks>
/// Entirely client-authored: no wire, no server, no dat. The player writes it.
/// Retail's own <c>PageInfo</c> (64 bytes, <c>g_JournalPages</c>).
/// </remarks>
/// <param name="Label">Short name, shown in the Page List. Authored max 16 characters.</param>
/// <param name="Title">Page title. Authored max 32.</param>
/// <param name="Notes">Free-form body. Authored max 2048.</param>
/// <param name="TimerDays">Countdown days.</param>
/// <param name="TimerHours">Countdown hours.</param>
/// <param name="TimerMinutes">Countdown minutes.</param>
/// <param name="LocationX">
/// Recorded location, in retail's own north-south / east-west units — what the
/// "Record" button stamps and the page shows as "00.0S, 00.0W".
/// </param>
/// <param name="LocationY">See <paramref name="LocationX"/>.</param>
/// <param name="HasLocation">
/// Whether a location was ever recorded. Distinct from (0, 0), which is a real
/// place.
/// </param>
/// <param name="RunningTimerSeconds">
/// The running countdown's remaining seconds, or 0 when the timer is not
/// running. Retail's <c>&lt;TIME&gt;</c>.
/// </param>
public sealed record JournalPage(
string Label = "",
string Title = "",
string Notes = "",
int TimerDays = 0,
int TimerHours = 0,
int TimerMinutes = 0,
float LocationX = 0f,
float LocationY = 0f,
bool HasLocation = false,
double RunningTimerSeconds = 0d)
{
/// <summary>Authored <c>0x1E</c> on the label edit box.</summary>
public const int MaxLabelLength = 16;
/// <summary>Authored <c>0x1E</c> on the title edit box.</summary>
public const int MaxTitleLength = 32;
/// <summary>Authored <c>0x1E</c> on the notes edit box.</summary>
public const int MaxNotesLength = 2048;
/// <summary>An untouched page — what "New" produces.</summary>
public static readonly JournalPage Empty = new();
/// <summary>Whether the timer fields describe any duration at all.</summary>
public bool HasTimer =>
TimerDays != 0 || TimerHours != 0 || TimerMinutes != 0;
/// <summary>The timer fields as a single duration.</summary>
public TimeSpan TimerDuration =>
new(TimerDays, TimerHours, TimerMinutes, 0);
/// <summary>Whether a countdown is currently running on this page.</summary>
public bool IsTimerRunning => RunningTimerSeconds > 0d;
/// <summary>
/// The page with every field clipped to its authored maximum. Applied at
/// the seams that accept outside text — a load from disk, or a paste.
/// </summary>
public JournalPage Clipped() => this with
{
Label = Clip(Label, MaxLabelLength),
Title = Clip(Title, MaxTitleLength),
Notes = Clip(Notes, MaxNotesLength),
};
private static string Clip(string value, int max) =>
value.Length <= max ? value : value[..max];
}

View file

@ -548,6 +548,20 @@ public sealed class AnimationSequencer
/// Empty when no frame boundary was crossed. Safe to call multiple
/// times per frame; second and subsequent calls return an empty list.
/// </summary>
/// <summary>
/// The hooks that have fired since the last <see cref="ConsumePendingHooks"/>,
/// WITHOUT draining them.
/// </summary>
/// <remarks>
/// Retail's <c>CPhysicsObj::process_hooks</c> executes every queued hook
/// against the owning <c>CPhysicsObj</c>, and a hook may act on BOTH the
/// physics object and presentation — <c>SetOmegaHook::Execute</c>
/// (<c>0x00526F30</c>) writes <c>m_omegaVector</c>. Draining the queue for
/// the presentation sink would hide those hooks from the physics owner, so
/// the owner peeks here and the sink still consumes the complete stream.
/// </remarks>
public IReadOnlyList<AnimationHook> PendingHooks => _pendingHooks;
public IReadOnlyList<AnimationHook> ConsumePendingHooks()
{
if (_pendingHooks.Count == 0)

View file

@ -10,6 +10,19 @@ public sealed class WorldGameState : IGameState
public IReadOnlyList<WorldEntitySnapshot> Entities => _entities;
/// <summary>
/// Where <see cref="Contracts"/> reads from. Set once by the host.
/// </summary>
/// <remarks>
/// A pull-through source rather than a pushed list: contracts change rarely
/// and are already owned canonically elsewhere, so mirroring them here
/// would add a second copy to keep in step for no gain.
/// </remarks>
public Func<IReadOnlyList<ContractSnapshot>>? ContractsSource { get; set; }
public IReadOnlyList<ContractSnapshot> Contracts =>
ContractsSource?.Invoke() ?? [];
/// <summary>
/// Publish the current projection for an entity. Re-hydration replaces the
/// prior snapshot instead of turning the current-state API into history.

View file

@ -0,0 +1,83 @@
using System.Collections.Generic;
namespace AcDream.Core.Quests;
/// <summary>
/// One contract's authored description, from portal.dat's ContractTable.
/// </summary>
/// <remarks>
/// <para>
/// The wire carries only an id, a stage and two timers — every word the player
/// reads comes from here, keyed by <see cref="ContractId"/>.
/// </para>
/// <para>
/// The <c>Questflag*</c> names are the server's own bookkeeping. The client
/// never reads or writes a quest flag (<c>r10-quest-dialogs.md</c> §1.3); it
/// keeps the names because <c>gmContractsUI::FillProgressString @0x00498DE0</c>
/// branches on whether <see cref="QuestflagRepeatTime"/> is EMPTY, which is how
/// it tells "finished for good" from "finished for now".
/// </para>
/// </remarks>
public sealed record ContractEntry(
uint Version,
uint ContractId,
string ContractName,
string Description,
/// <summary>
/// A printf format, NOT a literal — retail feeds it one integer
/// (<c>stage - 4</c>). The installed table has entries like
/// <c>"%d/20 Tuskers"</c>. Rendering it verbatim shows the player a raw
/// format specifier.
/// </summary>
string DescriptionProgress,
string NameNpcStart,
string NameNpcEnd,
string QuestflagStamped,
string QuestflagStarted,
string QuestflagFinished,
string QuestflagProgress,
string QuestflagTimer,
string QuestflagRepeatTime,
/// <summary>
/// Landcell of the NPC who offers the contract. The panel's "Contact
/// Location" row shows this as coordinates, or "Indoors" when the cell has
/// no outdoor coordinates
/// (<c>LandDefs::gid_to_lcoord</c> failing, <c>@0x0049937F</c>).
/// </summary>
uint LocationNpcStartCell = 0u,
uint LocationNpcEndCell = 0u,
/// <summary>Landcell of the quest area — the "Quest Location" row.</summary>
uint LocationQuestAreaCell = 0u)
{
public static readonly ContractEntry Unknown = new(
0u, 0u,
string.Empty, string.Empty, string.Empty,
string.Empty, string.Empty,
string.Empty, string.Empty, string.Empty,
string.Empty, string.Empty, string.Empty);
}
/// <summary>The installed contract catalog, keyed by contract id.</summary>
public sealed class ContractCatalog(IReadOnlyDictionary<uint, ContractEntry> contracts)
{
public static readonly ContractCatalog Empty =
new(new Dictionary<uint, ContractEntry>());
public IReadOnlyDictionary<uint, ContractEntry> Contracts { get; } = contracts;
public int Count => Contracts.Count;
/// <summary>
/// The entry for <paramref name="contractId"/>, or
/// <see cref="ContractEntry.Unknown"/>.
/// </summary>
/// <remarks>
/// A miss is normal rather than exceptional: the server may track a
/// contract this client's dat build has never heard of, and the panel still
/// has to draw the row.
/// </remarks>
public ContractEntry Lookup(uint contractId) =>
Contracts.TryGetValue(contractId, out ContractEntry? entry)
? entry
: ContractEntry.Unknown;
}

View file

@ -0,0 +1,104 @@
using System;
using System.Globalization;
using AcDream.Core.Ui;
namespace AcDream.Core.Quests;
/// <summary>
/// The contract tracker's progress column — a faithful port of
/// <c>gmContractsUI::FillProgressString @0x00498DE0</c>.
/// </summary>
public static class ContractProgressText
{
/// <summary>
/// The progress text for one tracked contract.
/// </summary>
/// <param name="stage">
/// The wire stage. Deliberately a raw <c>uint</c> rather than an enum:
/// retail encodes a progress COUNTER as <c>4 + n</c>, so the values above
/// three are data, not names.
/// </param>
/// <param name="timeWhenRepeats">Seconds until the repeat cooldown ends.</param>
/// <param name="receivedAt">When this state arrived — the countdown anchor.</param>
/// <param name="entry">The authored contract, or
/// <see cref="ContractEntry.Unknown"/>.</param>
/// <param name="now">The current time.</param>
/// <remarks>
/// <para>
/// Two details worth stating because they look like mistakes:
/// </para>
/// <para>
/// <b><c>TimeWhenDone</c> is never read.</b> Only <c>TimeWhenRepeats</c>
/// reaches this text. The other timer is on the wire and simply does not
/// drive the progress column.
/// </para>
/// <para>
/// <b>An empty <c>QuestflagRepeatTime</c> is what distinguishes "Done"
/// from "Available".</b> A contract with no repeat flag is finished for
/// good; one with a repeat flag whose timer has run out is offered again.
/// </para>
/// </remarks>
public static string Build(
uint stage,
double timeWhenRepeats,
DateTime receivedAt,
ContractEntry entry,
DateTime now)
{
ArgumentNullException.ThrowIfNull(entry);
if (stage == 1u) return "Available";
if (stage == 2u) return "In Progress";
if (stage == 3u)
{
if (timeWhenRepeats <= 0d)
{
return entry.QuestflagRepeatTime.Length == 0 ? "Done" : "Available";
}
// Retail counts down from when the state ARRIVED, using its own
// clock — the server never sends that instant.
double elapsed = (now - receivedAt).TotalSeconds;
double remaining = timeWhenRepeats - elapsed;
if (remaining <= 0d) return "Available";
return $"Done ({RetailDurationText.Format(remaining)} to Repeat)";
}
if (stage >= 4u)
{
// A counter with nothing authored to put it in.
if (entry.DescriptionProgress.Length == 0) return "In Progress";
return FormatProgress(entry.DescriptionProgress, stage - 4u);
}
// Stage 0 or anything else: retail returns without writing, leaving the
// caller's string as it found it.
return string.Empty;
}
/// <summary>
/// Substitutes retail's single integer argument into an authored progress
/// format.
/// </summary>
/// <remarks>
/// <c>FillProgressString</c> passes exactly ONE integer, so only the first
/// <c>%d</c> can be honoured — a second specifier would read past the
/// argument in retail too. Measured against the installed table: 89 of 322
/// contracts author a progress format and every one uses exactly one
/// <c>%d</c> (e.g. <c>"%d/20 Tuskers"</c>), so the single-substitution
/// reading covers the whole shipped catalog rather than merely the common
/// case.
/// </remarks>
private static string FormatProgress(string format, uint value)
{
int at = format.IndexOf("%d", StringComparison.Ordinal);
if (at < 0) return format;
return string.Concat(
format.AsSpan(0, at),
value.ToString(CultureInfo.InvariantCulture),
format.AsSpan(at + 2));
}
}

View file

@ -0,0 +1,74 @@
using System;
using System.Globalization;
using System.Text;
namespace AcDream.Core.Ui;
/// <summary>
/// Retail's client-wide duration wording —
/// <c>ClientUISystem::DeltaTimeToString @0x00565E10</c>.
/// </summary>
/// <remarks>
/// Client-wide rather than per-feature: the contract tracker's repeat countdown
/// and the journal page's timer both call it, so both read identically. It
/// lived in the contract code first only because that was its first caller.
/// </remarks>
public static class RetailDurationText
{
private const int SecondsPerMonth = 0x278D00; // 2,592,000 — a 30-day month
private const int SecondsPerDay = 0x15180; // 86,400
private const int SecondsPerHour = 0xE10; // 3,600
private const int SecondsPerMinute = 0x3C; // 60
/// <summary>
/// Port of <c>ClientUISystem::DeltaTimeToString @0x00565E10</c>.
/// </summary>
/// <remarks>
/// <para>
/// Largest-unit-first, each unit omitted when zero, seconds always shown:
/// <c>"2d 3h 4m 5s"</c>, <c>"45s"</c>. A "month" is a flat 30 days.
/// </para>
/// <para>
/// Every part is emitted with a TRAILING space and the final one is then
/// truncated. That truncation is not visible in the decompiler output —
/// the instruction reads as noise — so it was settled by decoding the
/// bytes: at <c>0x00565F0E</c>, <c>mov byte ptr [esp+eax+0x1b], cl</c>
/// with <c>cl == 0</c> and <c>eax == strlen</c> writes the terminator over
/// <c>buffer[len - 1]</c>. Without it, the caller composes
/// <c>"Done (1h 30s to Repeat)"</c> with a double space.
/// </para>
/// </remarks>
public static string Format(double seconds)
{
// Retail's _ftol2 — truncation toward zero, matching a C cast.
long total = (long)seconds;
if (total < 0) total = 0;
long months = total / SecondsPerMonth;
long rest = total % SecondsPerMonth;
long days = rest / SecondsPerDay;
rest %= SecondsPerDay;
long hours = rest / SecondsPerHour;
rest %= SecondsPerHour;
long minutes = rest / SecondsPerMinute;
long secs = rest % SecondsPerMinute;
var text = new StringBuilder();
if (months != 0) Append(text, months, "mo");
if (days != 0) Append(text, days, "d");
if (hours != 0) Append(text, hours, "h");
if (minutes != 0) Append(text, minutes, "m");
Append(text, secs, "s");
// The trailing space the last part just wrote.
return text.ToString(0, text.Length - 1);
static void Append(StringBuilder text, long value, string unit)
{
text.Append(value.ToString(CultureInfo.InvariantCulture));
text.Append(unit);
text.Append(' ');
}
}
}

View file

@ -1250,7 +1250,8 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime.CommunicationOwner.Squelch,
(text, type) => Runtime.CommunicationOwner.AddText(text, type),
Fellowship: Runtime.FellowshipOwner,
Allegiance: Runtime.AllegianceOwner));
Allegiance: Runtime.AllegianceOwner,
Contracts: Runtime.ContractsOwner));
var eventRoute = new HeadlessSessionEventRoute(
route,
Runtime,

View file

@ -99,6 +99,25 @@ internal sealed class HeadlessPluginHost
}
}
/// <summary>
/// Campaign QT slice QT6. Same borrow-don't-own shape as
/// <see cref="Entities"/>: projected from the canonical tracker on read.
/// Names and status text are empty here — a headless host has no dat
/// access — while every numeric field a bot actually branches on
/// (contract id, stage, progress) is present.
/// </summary>
public IReadOnlyList<ContractSnapshot> Contracts
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return AcDream.Runtime.Gameplay.ContractPluginProjection.Project(
_runtime.ContractsOwner.View,
catalog: null,
now: DateTime.UtcNow);
}
}
public event Action<WorldEntitySnapshot> EntitySpawned
{
add

View file

@ -1,14 +1,7 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.8, )",
"resolved": "10.0.8",
"contentHash": "dVbSXGIFNR5nZcv2tOLoWI+a9T4jtFd77IYjuND+QVe360qWgAF7H0WtoopYhRw/+SgpGUTyrkrh+65+ClNnfw=="
}
},
"net10.0": {},
"net10.0/win-x64": {}
}
}

View file

@ -0,0 +1,41 @@
namespace AcDream.Plugin.Abstractions;
/// <summary>
/// One tracked contract, as a plugin sees it.
/// </summary>
/// <remarks>
/// <para>
/// This is the ONLY structured view of quest state a client ever has. The
/// retail client stores no quest flags and is never told one changed; it learns
/// about quests through NPC dialogue, generic error text, and this tracker. A
/// plugin asking "what quests am I on?" is asking this and nothing else.
/// </para>
/// <para>
/// <paramref name="Name"/>, <paramref name="Description"/> and
/// <paramref name="Status"/> come from the installed dat and may be empty on a
/// host with no dat access (a headless bot), or for a contract the installed
/// dat build has never heard of. The numeric fields are always present.
/// </para>
/// </remarks>
/// <param name="ContractId">Key into the dat's ContractTable.</param>
/// <param name="Stage">
/// Raw wire stage: 1 available, 2 in progress, 3 done-or-pending-repeat, and
/// <c>4 + n</c> for a progress counter with n steps done — see
/// <paramref name="Progress"/>.
/// </param>
/// <param name="Progress">Completed steps, or 0 when the stage carries no counter.</param>
/// <param name="IsDisplayed">Whether the server nominated this as the shown contract.</param>
/// <param name="Name">Authored contract name.</param>
/// <param name="Description">Authored long-form description.</param>
/// <param name="Status">
/// The progress text retail's own panel shows — "Available", "In Progress",
/// "5/20 Tuskers", "Done (1h 30s to Repeat)".
/// </param>
public readonly record struct ContractSnapshot(
uint ContractId,
uint Stage,
uint Progress,
bool IsDisplayed,
string Name = "",
string Description = "",
string Status = "");

View file

@ -4,4 +4,15 @@ namespace AcDream.Plugin.Abstractions;
public interface IGameState
{
IReadOnlyList<WorldEntitySnapshot> Entities { get; }
/// <summary>
/// The player's tracked contracts — the client's only structured view of
/// quest state (<c>r10-quest-dialogs.md</c> §1.3). Empty when the server
/// has sent none.
/// </summary>
/// <remarks>
/// Defaulted so a host predating Campaign QT still satisfies the interface;
/// both in-tree hosts implement it.
/// </remarks>
IReadOnlyList<ContractSnapshot> Contracts => [];
}

View file

@ -31,7 +31,8 @@ public static class ChatCommandRouter
string? raw,
IChatCommandFeedback feedback,
ICommandBus bus,
ChatChannelKind defaultChannel)
ChatChannelKind defaultChannel,
string? defaultTellTarget = null)
{
ArgumentNullException.ThrowIfNull(feedback);
ArgumentNullException.ThrowIfNull(bus);
@ -166,7 +167,8 @@ public static class ChatCommandRouter
trimmed,
defaultChannel,
feedback.LastIncomingTellSender,
feedback.LastOutgoingTellTarget);
feedback.LastOutgoingTellTarget,
defaultTellTarget);
if (parsed is { } chat)
{
bus.Publish(new SendChatCmd(chat.Channel, chat.TargetName, chat.Text));

View file

@ -201,7 +201,8 @@ public static class ChatInputParser
string raw,
ChatChannelKind defaultChannel,
string? lastTellSender,
string? lastOutgoingTellTarget = null)
string? lastOutgoingTellTarget = null,
string? defaultTellTarget = null)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var trimmed = raw.Trim();
@ -219,7 +220,12 @@ public static class ChatInputParser
string verb = ExtractVerb(substituted);
if (IsKnownVerb(verb))
{
return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget);
return Parse(
substituted,
defaultChannel,
lastTellSender,
lastOutgoingTellTarget,
defaultTellTarget);
}
// Unknown @-verb — keep the original @ so ACE recognizes
// it server-side. Always emit as Say: ACE's GameActionTalk
@ -281,6 +287,19 @@ public static class ChatInputParser
// Plain speech (no recognized verb): emit on the default channel
// so the user's text round-trips instead of being silently dropped.
//
// A Tell focus needs a target to go with it. Retail's talk-focus menu
// aims the chat bar at the SELECTED player
// (gmMainChatUI::InitTalkFocusMenu -> SetTalkFocusEnabled(2, 1)
// @0x004CD9B0), and without that name a plain line typed under a Tell
// focus is dropped by the router for having no target.
if (defaultChannel == ChatChannelKind.Tell)
{
return string.IsNullOrEmpty(defaultTellTarget)
? null
: new ParsedInput(ChatChannelKind.Tell, defaultTellTarget, trimmed);
}
return new ParsedInput(defaultChannel, null, trimmed);
}

View file

@ -0,0 +1,76 @@
namespace AcDream.Runtime.Chat;
/// <summary>
/// Retail's typed-abbreviation expansion in the chat entry.
/// </summary>
/// <remarks>
/// <para>
/// Typing <c>/r </c> rewrites the entry to <c>@tell {LastTeller}, </c> the
/// moment the space lands, so the player SEES who they are replying to before
/// pressing enter. <c>ChatInterface::HandleTextReplacements @0x004F50D0</c>
/// runs off the character-typed broadcast and delegates to
/// <c>SetReplyTextInChatBox @0x004F4760</c>.
/// </para>
/// <para>
/// This is display sugar, not routing: <c>/r hello</c> already SENT correctly
/// without it (<c>ChatInputParser</c>'s reply aliases). What was missing is
/// that the player could not see the target.
/// </para>
/// </remarks>
public static class ChatTextReplacements
{
/// <summary>
/// The reply abbreviations, recovered from the constant pool rather than
/// the decompiled listing — Binary Ninja renders these as bare
/// <c>data_*</c> references.
/// </summary>
/// <remarks>
/// <c>data_7C4C70</c> = <c>"r "</c>, <c>data_7C4C68</c> = <c>"rp "</c>,
/// <c>data_7C4C58</c> = <c>"reply "</c>. Retail stores them WITHOUT the
/// leading prefix character and checks that separately, which is why both
/// prefixes work.
/// </remarks>
private static readonly string[] ReplyVerbs = ["r", "rp", "reply"];
/// <summary>
/// Retail accepts either command prefix here —
/// <c>SetReplyTextInChatBox</c> tests the first character against
/// <c>0x2F</c> ('/') OR <c>0x40</c> ('@') before comparing the verb.
/// </summary>
private const string CommandPrefixes = "/@";
/// <summary>
/// The expansion for <paramref name="text"/>, or <see langword="null"/>
/// when it is not a reply abbreviation.
/// </summary>
/// <param name="lastTeller">
/// The last person to tell us. When absent, retail performs NO expansion —
/// it leaves the text alone rather than producing a tell addressed to
/// nobody, and the ordinary submit path is what reports
/// "Someone must @tell you first!".
/// </param>
public static string? Expand(string? text, string? lastTeller)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(lastTeller))
return null;
if (CommandPrefixes.IndexOf(text[0]) < 0)
return null;
// The trailing space is part of the trigger: retail expands ON the
// space, so "/r" alone must be left alone — the player may still be
// typing "/roleplay".
foreach (string verb in ReplyVerbs)
{
if (text.Length == verb.Length + 2
&& text[^1] == ' '
&& string.Compare(
text, 1, verb, 0, verb.Length,
StringComparison.OrdinalIgnoreCase) == 0)
{
return $"@tell {lastTeller}, ";
}
}
return null;
}
}

View file

@ -24,6 +24,12 @@ public enum ClientCommandId
ShowLastCorpseLocation,
Die,
ClearChat,
/// <summary>
/// Retail's <c>@log</c>: start or stop copying chat to a file.
/// <c>ClientCommunicationSystem::DoSetOutput @0x0057E4F0</c>.
/// </summary>
ChatLogFile,
SaveUi,
LoadUi,
SaveAutoUi,

View file

@ -159,16 +159,14 @@ public sealed class LiveChatCommandRoute
case ChatChannelKind.Tell:
if (string.IsNullOrEmpty(command.TargetName))
return;
if (!SendIfActive(() =>
bindings.SendTell(command.TargetName, command.Text)))
{
return;
}
bindings.Chat.OnSelfSent(
ChatKind.Tell,
command.Text,
logTextType: (uint)RetailLogTextType.SpeechDirectSend,
targetOrChannel: command.TargetName);
// No local echo. The server sends the "You tell X, ..." line
// back itself (ACE GameActionTell -> GameMessageSystemChat with
// ChatMessageType.OutgoingTell 0x04), and retail's own send
// path just transmits: Event_TalkDirectByName @ 0x00577CF4 has
// no AddTextToScroll beside it. Echoing optimistically printed
// the line twice. Say above already relies on the server echo
// for exactly this reason.
SendIfActive(() => bindings.SendTell(command.TargetName, command.Text));
return;
}

View file

@ -145,6 +145,15 @@ public static class RetailClientCommandCatalog
"/clear [all]",
"/clear [all] - Clears the current chat window, or every chat window.");
/// <summary>
/// Retail takes the whole remainder as the filename — <c>DoSetOutput</c>
/// calls <c>JoinArgs</c> first, so a name with spaces in it works.
/// </summary>
private static readonly Definition ChatLogFile = AnyArguments(
ClientCommandId.ChatLogFile,
"/log [filename]",
"/log [filename] - Echoes chat text to a logfile, or stops if already logging.");
private static readonly Definition SaveUi = AnyArguments(
ClientCommandId.SaveUi,
"/saveui [filename]",
@ -497,6 +506,7 @@ public static class RetailClientCommandCatalog
["cor"] = Corpse,
["die"] = Die,
["clear"] = Clear,
["log"] = ChatLogFile,
["saveui"] = SaveUi,
["loadui"] = LoadUi,
["saveautoui"] = SaveAutoUi,

View file

@ -949,6 +949,12 @@ public static class RetailCommandHelpTable
private static readonly FrozenDictionary<string, string> CatalogVerbDetailByVerb =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
// CT-B4 (2026-08-21): "log" became a catalog verb, so its retail
// help has to be reachable through the CATALOG path too — the
// catalog's own one-line summary is acdream-authored, and showing
// that in place of retail's text is exactly what this table exists
// to prevent.
["log"] = Log,
["lifestone"] = LifestoneDetail,
["lif"] = LifestoneDetail,
["ls"] = LifestoneDetail,

View file

@ -52,6 +52,10 @@ public enum GameRuntimeTeardownStage
TradeDisposed = 1 << 11,
IdentityDisposed = 1 << 12,
EntityObjectsDisposed = 1 << 13,
// Campaign QT (2026-08-21): fourth sibling J-owner, same shape.
ContractsDisposed = 1 << 14,
// Campaign QJ (2026-08-21): fifth — the per-character journal.
JournalDisposed = 1 << 15,
Complete =
HostLeasesReleased
| EventsDetached
@ -65,6 +69,8 @@ public enum GameRuntimeTeardownStage
| FellowshipDisposed
| AllegianceDisposed
| TradeDisposed
| ContractsDisposed
| JournalDisposed
| IdentityDisposed
| EntityObjectsDisposed,
}
@ -109,6 +115,8 @@ internal enum GameRuntimeConstructionPoint
FellowshipCreated,
AllegianceCreated,
TradeCreated,
ContractsCreated,
JournalCreated,
HouseCreated,
MovementCreated,
ActionsCreated,
@ -128,6 +136,8 @@ internal sealed class GameRuntimeConstructionContext
public RuntimeFellowshipState? Fellowship { get; set; }
public RuntimeAllegianceState? Allegiance { get; set; }
public RuntimeTradeState? Trade { get; set; }
public RuntimeContractState? Contracts { get; set; }
public RuntimeJournalState? Journal { get; set; }
public RuntimeHouseState? House { get; set; }
public RuntimeLocalPlayerMovementState? Movement { get; set; }
public RuntimeActionState? Actions { get; set; }
@ -144,7 +154,11 @@ public sealed class GameRuntime
IRuntimeEventSource,
IDisposable
{
private const int TeardownStageCount = 14;
// Campaign QJ (2026-08-21): 16 with the journal owner. This bound and
// GameRuntimeTeardownStage.Complete have to move together — the drain
// loop stops here, so leaving it behind would silently never dispose
// the last owner while the ledger kept demanding its flag.
private const int TeardownStageCount = 16;
private readonly object _lifetimeGate = new();
private readonly Dictionary<long, string> _hostLeases = [];
@ -284,6 +298,26 @@ public sealed class GameRuntime
context,
faultInjection);
// Campaign QT (2026-08-21): fourth sibling J-owner. A pure
// projection of server-pushed contract state — it borrows
// nothing, because the retail client stores no quest state of
// its own.
context.Contracts = new RuntimeContractState();
construction.Own(context.Contracts);
Fault(
GameRuntimeConstructionPoint.ContractsCreated,
context,
faultInjection);
// Campaign QJ (2026-08-21): the per-character journal. Client-
// authored and file-backed — no wire reaches it.
context.Journal = new RuntimeJournalState();
construction.Own(context.Journal);
Fault(
GameRuntimeConstructionPoint.JournalCreated,
context,
faultInjection);
// House tab (Batch C, Map/House toolbar panel, 2026-08-17):
// deliberately minimal owner (ISSUES #413's own sizing note) —
// no live-object side effects, nothing to dispose, so no
@ -351,7 +385,9 @@ public sealed class GameRuntime
context.Fellowship,
context.Allegiance,
context.Trade,
context.House);
context.House,
context.Contracts,
context.Journal);
context.Movement.AttachPhysicsPublication(
new RuntimeLocalPlayerPhysicsPublicationState(
@ -407,6 +443,8 @@ public sealed class GameRuntime
FellowshipOwner = context.Fellowship;
AllegianceOwner = context.Allegiance;
TradeOwner = context.Trade;
ContractsOwner = context.Contracts;
JournalOwner = context.Journal;
HouseOwner = context.House;
MovementOwner = context.Movement;
ActionOwner = context.Actions;
@ -516,6 +554,8 @@ public sealed class GameRuntime
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
public RuntimeTradeState TradeOwner { get; }
public RuntimeContractState ContractsOwner { get; }
public RuntimeJournalState JournalOwner { get; }
/// <summary>Batch C (2026-08-17): House tab minimal owner — see
/// <see cref="RuntimeHouseState"/>'s own class doc for the sizing
@ -572,6 +612,8 @@ public sealed class GameRuntime
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;
public IRuntimeTradeView Trade => TradeOwner.View;
public IRuntimeContractView Contracts => ContractsOwner.View;
public IRuntimeJournalView Journal => JournalOwner.View;
public IRuntimeActionView Actions => ActionOwner.View;
public IRuntimeMovementView Movement => MovementOwner.View;
public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner;
@ -800,21 +842,36 @@ public sealed class GameRuntime
& ~GameRuntimeTeardownStage.FellowshipDisposed
& ~GameRuntimeTeardownStage.AllegianceDisposed
& ~GameRuntimeTeardownStage.TradeDisposed
& ~GameRuntimeTeardownStage.ContractsDisposed
& ~GameRuntimeTeardownStage.JournalDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
10 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.AllegianceDisposed
& ~GameRuntimeTeardownStage.TradeDisposed
& ~GameRuntimeTeardownStage.ContractsDisposed
& ~GameRuntimeTeardownStage.JournalDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
11 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.TradeDisposed
& ~GameRuntimeTeardownStage.ContractsDisposed
& ~GameRuntimeTeardownStage.JournalDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
12 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.ContractsDisposed
& ~GameRuntimeTeardownStage.JournalDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
13 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.JournalDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
14 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
15 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
_ => GameRuntimeTeardownStage.Complete,
};
@ -869,9 +926,15 @@ public sealed class GameRuntime
TradeOwner.Dispose();
return TradeOwner.CaptureOwnership().IsConverged;
case 12:
ContractsOwner.Dispose();
return ContractsOwner.CaptureOwnership().IsConverged;
case 13:
JournalOwner.Dispose();
return JournalOwner.CaptureOwnership().IsConverged;
case 14:
PlayerIdentity.Dispose();
return PlayerIdentity.CaptureOwnership().IsConverged;
case 13:
case 15:
EntityObjects.Dispose();
return EntityObjects.CaptureOwnership().IsConverged
&& EntityObjects.Physics.CaptureOwnership().IsConverged;
@ -895,8 +958,10 @@ public sealed class GameRuntime
9 => FellowshipOwner.CaptureOwnership().IsConverged,
10 => AllegianceOwner.CaptureOwnership().IsConverged,
11 => TradeOwner.CaptureOwnership().IsConverged,
12 => PlayerIdentity.CaptureOwnership().IsConverged,
13 => EntityObjects.CaptureOwnership().IsConverged
12 => ContractsOwner.CaptureOwnership().IsConverged,
13 => JournalOwner.CaptureOwnership().IsConverged,
14 => PlayerIdentity.CaptureOwnership().IsConverged,
15 => EntityObjects.CaptureOwnership().IsConverged
&& EntityObjects.Physics.CaptureOwnership().IsConverged,
_ => true,
};

View file

@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Net.Messages;
using AcDream.Core.Quests;
using AcDream.Plugin.Abstractions;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Projects the canonical contract tracker into the plugin-facing
/// <see cref="ContractSnapshot"/> shape.
/// </summary>
/// <remarks>
/// Lives here rather than in either host because both of them need it and
/// neither owns the tracker. The authored catalog is optional: a headless bot
/// has no dat access, and a contract the installed dat has never heard of still
/// has to appear — a plugin must not silently miss a live quest because the
/// text for it is unavailable.
/// </remarks>
public static class ContractPluginProjection
{
public static IReadOnlyList<ContractSnapshot> Project(
IRuntimeContractView contracts,
ContractCatalog? catalog,
DateTime now)
{
ArgumentNullException.ThrowIfNull(contracts);
IReadOnlyList<ContractTracker> tracked = contracts.GetContracts();
if (tracked.Count == 0)
return [];
uint displayed = contracts.Snapshot.DisplayContractId;
var result = new ContractSnapshot[tracked.Count];
for (int i = 0; i < tracked.Count; i++)
{
ContractTracker tracker = tracked[i];
ContractEntry? entry = catalog?.Lookup(tracker.ContractId);
result[i] = new ContractSnapshot(
tracker.ContractId,
(uint)tracker.Stage,
tracker.Progress,
tracker.ContractId == displayed,
entry?.ContractName ?? string.Empty,
entry?.Description ?? string.Empty,
entry is null
? string.Empty
: ContractProgressText.Build(
(uint)tracker.Stage,
tracker.TimeWhenRepeats,
tracker.ReceivedAt,
entry,
now));
}
return result;
}
}

View file

@ -0,0 +1,201 @@
using AcDream.Core.Net.Messages;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeContractOwnershipSnapshot(
bool IsDisposed,
int ContractCount,
uint DisplayContractId)
{
public bool IsConverged =>
IsDisposed
&& ContractCount == 0
&& DisplayContractId == 0u;
}
/// <summary>Whole-tracker state at one revision.</summary>
public readonly record struct RuntimeContractsSnapshot(
long Revision,
int ContractCount,
uint DisplayContractId);
/// <summary>Borrowed read surface over <see cref="RuntimeContractState"/>.</summary>
public interface IRuntimeContractView
{
RuntimeContractsSnapshot Snapshot { get; }
bool TryGetContract(uint contractId, out ContractTracker tracker);
/// <summary>Every tracked contract, ordered by contract id for stable display.</summary>
IReadOnlyList<ContractTracker> GetContracts();
}
/// <summary>
/// Canonical presentation-independent owner for the player's contract tracker
/// — Campaign QT slice QT3.
/// </summary>
/// <remarks>
/// <para>
/// Session-scoped, and unusually safe to make so: the retail client stores NO
/// quest state of its own (<c>r10-quest-dialogs.md</c> §1.3). Everything here
/// is a projection of what the server pushed, and a fresh session opens with a
/// full <c>0x0314</c> replacement, so clearing at generation reset cannot lose
/// anything the next login will not immediately restate.
/// </para>
/// <para>
/// Three mutation shapes, all from QT1's parsers:
/// <c>0x0314</c> REPLACES the table wholesale; <c>0x0315</c> upserts one
/// contract, or removes it when <c>DeleteContract</c> is set; and either path
/// may nominate the display contract. Every mutation bumps the monotonic
/// revision so consumers poll rather than subscribe.
/// </para>
/// </remarks>
public sealed class RuntimeContractState : IDisposable
{
private readonly object _gate = new();
private readonly Dictionary<uint, ContractTracker> _contracts = [];
private uint _displayContractId;
private long _revision;
private bool _disposed;
public RuntimeContractState() => View = new ContractView(this);
public IRuntimeContractView View { get; }
/// <summary>
/// <c>0x0314</c> — the server's complete list replaces ours.
/// </summary>
/// <remarks>
/// An EMPTY table is meaningful and must clear: it is how the server says
/// "you have no contracts". Treating empty as "nothing to do" would strand
/// contracts on screen after the last one is abandoned.
/// <para>
/// A display contract that is not in the new table is dropped, because a
/// panel pointed at a contract the server no longer tracks has nothing to
/// render.
/// </para>
/// </remarks>
public void ApplyTable(IReadOnlyDictionary<uint, ContractTracker> table)
{
ArgumentNullException.ThrowIfNull(table);
lock (_gate)
{
if (_disposed) return;
_contracts.Clear();
foreach ((uint id, ContractTracker tracker) in table)
_contracts[id] = tracker;
if (_displayContractId != 0u && !_contracts.ContainsKey(_displayContractId))
_displayContractId = 0u;
Bump();
}
}
/// <summary>
/// <c>0x0315</c> — one contract added, changed, or removed.
/// </summary>
/// <remarks>
/// The delete flag is checked BEFORE the upsert. A delete carries a whole
/// tracker struct alongside it (ACE builds one either way), so storing
/// first and deleting second would work, but reading the message as
/// "here is a contract" when it says "remove this contract" is the
/// misreading worth ruling out.
/// </remarks>
public void ApplyUpdate(ContractTrackerUpdate update)
{
lock (_gate)
{
if (_disposed) return;
uint id = update.Tracker.ContractId;
if (update.Delete)
{
bool removed = _contracts.Remove(id);
if (_displayContractId == id)
_displayContractId = 0u;
if (removed) Bump();
return;
}
_contracts[id] = update.Tracker;
if (update.SetAsDisplay)
_displayContractId = id;
Bump();
}
}
public RuntimeContractOwnershipSnapshot CaptureOwnership()
{
lock (_gate)
return new RuntimeContractOwnershipSnapshot(
_disposed,
_contracts.Count,
_displayContractId);
}
/// <summary>
/// Session-scoped: cleared at every generation reset. No disposed guard,
/// matching the sibling owners — the reset transaction is retryable and
/// disposal is terminal, so a throwing guard here could never converge.
/// </summary>
public void ResetSession()
{
lock (_gate) ClearLocked();
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
ClearLocked();
_disposed = true;
}
}
private void ClearLocked()
{
bool changed = _contracts.Count != 0 || _displayContractId != 0u;
_contracts.Clear();
_displayContractId = 0u;
if (changed) Bump();
}
private void Bump() => _revision++;
private sealed class ContractView(RuntimeContractState owner) : IRuntimeContractView
{
public RuntimeContractsSnapshot Snapshot
{
get
{
lock (owner._gate)
return new RuntimeContractsSnapshot(
owner._revision,
owner._contracts.Count,
owner._displayContractId);
}
}
public bool TryGetContract(uint contractId, out ContractTracker tracker)
{
lock (owner._gate)
return owner._contracts.TryGetValue(contractId, out tracker);
}
public IReadOnlyList<ContractTracker> GetContracts()
{
lock (owner._gate)
{
var result = new ContractTracker[owner._contracts.Count];
int i = 0;
foreach (ContractTracker tracker in owner._contracts.Values)
result[i++] = tracker;
Array.Sort(result, static (a, b) => a.ContractId.CompareTo(b.ContractId));
return result;
}
}
}
}

View file

@ -0,0 +1,386 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Journal;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeJournalOwnershipSnapshot(
bool IsDisposed,
int PageCount)
{
public bool IsConverged => IsDisposed && PageCount == 0;
}
/// <summary>Whole-journal state at one revision.</summary>
/// <param name="CurrentPage">1-based, or 0 when the journal is empty.</param>
public readonly record struct RuntimeJournalSnapshot(
long Revision,
int PageCount,
int CurrentPage,
bool IsDirty);
/// <summary>Borrowed read surface over <see cref="RuntimeJournalState"/>.</summary>
public interface IRuntimeJournalView
{
RuntimeJournalSnapshot Snapshot { get; }
/// <summary>Every page, in file order.</summary>
IReadOnlyList<JournalPage> Pages { get; }
/// <summary>The page <see cref="RuntimeJournalSnapshot.CurrentPage"/> names,
/// or <see cref="JournalPage.Empty"/> when the journal is empty.</summary>
JournalPage Current { get; }
/// <summary>
/// Remaining seconds on the current page's countdown at
/// <paramref name="now"/>, or 0 when nothing is running.
/// </summary>
double RemainingTimerSeconds(DateTime now);
}
/// <summary>
/// Canonical owner for retail's per-character journal — Campaign QJ slice QJ2.
/// </summary>
/// <remarks>
/// <para>
/// Purely client-authored: no wire touches this. The player writes the pages,
/// and they persist to a per-character file
/// (<see cref="JournalFile"/>). Retail loads on entering the world and saves
/// whenever the journal page is hidden
/// (<c>gmJournalUI::OnVisibilityChanged @0x004978F0</c>) rather than only at
/// exit, so a crash costs at most the page you are looking at.
/// </para>
/// <para>
/// Pages are 1-BASED throughout, matching retail's <c>m_CurrentPage</c> and the
/// "~ 1 ~" the panel shows. 0 means "no pages".
/// </para>
/// </remarks>
public sealed class RuntimeJournalState : IDisposable
{
private readonly object _gate = new();
private readonly List<JournalPage> _pages = [];
private int _currentPage;
private long _revision;
private bool _dirty;
private bool _disposed;
/// <summary>When the running countdown was started, and what it had left then.</summary>
private DateTime? _timerStartedAt;
private double _timerSecondsAtStart;
public RuntimeJournalState() => View = new JournalView(this);
public IRuntimeJournalView View { get; }
/// <summary>
/// Whether anything has changed since the last <see cref="MarkSaved"/> —
/// what the host checks before writing the file.
/// </summary>
public bool IsDirty
{
get { lock (_gate) return _dirty; }
}
/// <summary>Replaces the whole journal, as a file load does.</summary>
public void Load(IReadOnlyList<JournalPage> pages)
{
ArgumentNullException.ThrowIfNull(pages);
lock (_gate)
{
if (_disposed) return;
_pages.Clear();
_pages.AddRange(pages);
_currentPage = _pages.Count == 0 ? 0 : 1;
_timerStartedAt = null;
_timerSecondsAtStart = 0d;
// A load is not an edit: the file already says this.
_dirty = false;
Bump();
}
}
/// <summary>A snapshot for writing to disk, with the live countdown folded in.</summary>
public IReadOnlyList<JournalPage> CaptureForSave(DateTime now)
{
lock (_gate)
{
var saved = new JournalPage[_pages.Count];
for (int i = 0; i < _pages.Count; i++)
{
saved[i] = i + 1 == _currentPage
? _pages[i] with { RunningTimerSeconds = RemainingLocked(now) }
: _pages[i];
}
return saved;
}
}
public void MarkSaved()
{
lock (_gate) _dirty = false;
}
/// <summary>
/// Retail's "New" — appends a blank page and moves to it
/// (<c>gmJournalUI::NewPage @0x00496880</c>).
/// </summary>
public void NewPage()
{
lock (_gate)
{
if (_disposed) return;
_pages.Add(JournalPage.Empty);
_currentPage = _pages.Count;
ClearTimerLocked();
_dirty = true;
Bump();
}
}
/// <summary>
/// Removes one 1-based page (<c>DeletePage @0x004965D0</c>).
/// </summary>
/// <remarks>
/// Deleting the last remaining page leaves an EMPTY journal rather than a
/// blank page — retail's own <c>ClearCurrentPage</c> handles the "one page
/// left" case separately, and inventing a replacement here would make the
/// journal impossible to empty.
/// </remarks>
public bool DeletePage(int pageNumber)
{
lock (_gate)
{
if (_disposed || pageNumber < 1 || pageNumber > _pages.Count)
return false;
_pages.RemoveAt(pageNumber - 1);
if (_currentPage > _pages.Count)
_currentPage = _pages.Count;
if (_pages.Count == 0)
_currentPage = 0;
ClearTimerLocked();
_dirty = true;
Bump();
return true;
}
}
/// <summary>Moves to a 1-based page (<c>GotoPage @0x00496430</c>).</summary>
public bool GotoPage(int pageNumber)
{
lock (_gate)
{
if (_disposed || pageNumber < 1 || pageNumber > _pages.Count)
return false;
if (_currentPage == pageNumber)
return true;
_currentPage = pageNumber;
// The countdown belongs to the page it was started on.
ClearTimerLocked();
Bump();
return true;
}
}
/// <summary>
/// Commits edited text into the current page
/// (<c>SaveThisPage @0x00495360</c>).
/// </summary>
public void UpdateCurrent(string label, string title, string notes)
{
lock (_gate)
{
if (_disposed || _currentPage == 0) return;
JournalPage updated = (_pages[_currentPage - 1] with
{
Label = label ?? string.Empty,
Title = title ?? string.Empty,
Notes = notes ?? string.Empty,
}).Clipped();
if (updated == _pages[_currentPage - 1])
return;
_pages[_currentPage - 1] = updated;
_dirty = true;
Bump();
}
}
/// <summary>The "Record" button (<c>UpdateLocation @0x004958F0</c>).</summary>
public void RecordLocation(float x, float y)
{
lock (_gate)
{
if (_disposed || _currentPage == 0) return;
_pages[_currentPage - 1] = _pages[_currentPage - 1] with
{
LocationX = x,
LocationY = y,
HasLocation = true,
};
_dirty = true;
Bump();
}
}
public void SetTimer(int days, int hours, int minutes)
{
lock (_gate)
{
if (_disposed || _currentPage == 0) return;
_pages[_currentPage - 1] = _pages[_currentPage - 1] with
{
TimerDays = Math.Max(0, days),
TimerHours = Math.Max(0, hours),
TimerMinutes = Math.Max(0, minutes),
};
_dirty = true;
Bump();
}
}
/// <summary>Starts the current page's countdown from its authored fields.</summary>
public bool StartTimer(DateTime now)
{
lock (_gate)
{
if (_disposed || _currentPage == 0) return false;
JournalPage page = _pages[_currentPage - 1];
double seconds = page.TimerDuration.TotalSeconds;
if (seconds <= 0d)
return false;
_timerStartedAt = now;
_timerSecondsAtStart = seconds;
_pages[_currentPage - 1] = page with { RunningTimerSeconds = seconds };
_dirty = true;
Bump();
return true;
}
}
/// <summary>Stops the countdown (<c>ResetTimer @0x00495150</c>).</summary>
public void ResetTimer()
{
lock (_gate)
{
if (_disposed) return;
bool wasRunning = _timerStartedAt is not null;
ClearTimerLocked();
if (_currentPage != 0)
{
_pages[_currentPage - 1] =
_pages[_currentPage - 1] with { RunningTimerSeconds = 0d };
}
if (wasRunning) _dirty = true;
Bump();
}
}
/// <summary>
/// True when the current page is the last one
/// (<c>IsLastPage @0x00494ED0</c>) — the "Last" button's own gate.
/// </summary>
public bool IsLastPage
{
get { lock (_gate) return _currentPage != 0 && _currentPage == _pages.Count; }
}
public RuntimeJournalOwnershipSnapshot CaptureOwnership()
{
lock (_gate) return new RuntimeJournalOwnershipSnapshot(_disposed, _pages.Count);
}
/// <summary>
/// Cleared at generation reset: the journal is PER-CHARACTER, so carrying
/// it across a reconnect would show one character another's notes. The
/// host saves before the reset and loads after it.
/// </summary>
public void ResetSession()
{
lock (_gate) ClearLocked();
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
ClearLocked();
_disposed = true;
}
}
private void ClearLocked()
{
bool changed = _pages.Count != 0 || _currentPage != 0;
_pages.Clear();
_currentPage = 0;
_dirty = false;
ClearTimerLocked();
if (changed) Bump();
}
private void ClearTimerLocked()
{
_timerStartedAt = null;
_timerSecondsAtStart = 0d;
}
private double RemainingLocked(DateTime now)
{
if (_timerStartedAt is not { } started)
return _currentPage == 0 ? 0d : _pages[_currentPage - 1].RunningTimerSeconds;
double remaining = _timerSecondsAtStart - (now - started).TotalSeconds;
return remaining > 0d ? remaining : 0d;
}
private void Bump() => _revision++;
private sealed class JournalView(RuntimeJournalState owner) : IRuntimeJournalView
{
public RuntimeJournalSnapshot Snapshot
{
get
{
lock (owner._gate)
return new RuntimeJournalSnapshot(
owner._revision,
owner._pages.Count,
owner._currentPage,
owner._dirty);
}
}
public IReadOnlyList<JournalPage> Pages
{
get { lock (owner._gate) return owner._pages.ToArray(); }
}
public JournalPage Current
{
get
{
lock (owner._gate)
return owner._currentPage == 0
? JournalPage.Empty
: owner._pages[owner._currentPage - 1];
}
}
public double RemainingTimerSeconds(DateTime now)
{
lock (owner._gate) return owner.RemainingLocked(now);
}
}
}

View file

@ -69,15 +69,30 @@ public enum RuntimeGenerationResetStage
/// construction-transaction Fault() point).
/// </summary>
House = 15,
BeginEntityRetirement = 16,
RetireEntities = 17,
DrainHostProjection = 18,
CompleteCanonicalEntities = 19,
CompleteHostProjection = 20,
ChatIdentity = 21,
PlayerSnapshots = 22,
PlayerIdentity = 23,
Complete = 24,
/// <summary>
/// Campaign QT (2026-08-21): the contract tracker is a projection of
/// server state and nothing else — the retail client stores no quest
/// state of its own. A fresh session opens with a full 0x0314
/// replacement, so clearing here cannot lose anything the next login
/// will not immediately restate, while NOT clearing would show a
/// previous character's quests.
/// </summary>
Contracts = 16,
/// <summary>
/// Campaign QJ (2026-08-21): the journal is PER-CHARACTER, so carrying it
/// across a reconnect would show one character another's notes. The host
/// saves before the reset and loads again after it.
/// </summary>
Journal = 17,
BeginEntityRetirement = 18,
RetireEntities = 19,
DrainHostProjection = 20,
CompleteCanonicalEntities = 21,
CompleteHostProjection = 22,
ChatIdentity = 23,
PlayerSnapshots = 24,
PlayerIdentity = 25,
Complete = 26,
}
public readonly record struct RuntimeGenerationResetSnapshot(
@ -128,6 +143,8 @@ public sealed class RuntimeGenerationReset
private readonly RuntimeFellowshipState _fellowship;
private readonly RuntimeAllegianceState _allegiance;
private readonly RuntimeTradeState _trade;
private readonly RuntimeContractState _contracts;
private readonly RuntimeJournalState _journal;
private readonly RuntimeHouseState _house;
private ResetState? _state;
private RuntimeGenerationToken _lastCompletedGeneration;
@ -147,7 +164,9 @@ public sealed class RuntimeGenerationReset
RuntimeFellowshipState fellowship,
RuntimeAllegianceState allegiance,
RuntimeTradeState trade,
RuntimeHouseState house)
RuntimeHouseState house,
RuntimeContractState contracts,
RuntimeJournalState journal)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_communication = communication
@ -169,6 +188,9 @@ public sealed class RuntimeGenerationReset
?? throw new ArgumentNullException(nameof(allegiance));
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
_house = house ?? throw new ArgumentNullException(nameof(house));
_contracts = contracts
?? throw new ArgumentNullException(nameof(contracts));
_journal = journal ?? throw new ArgumentNullException(nameof(journal));
}
public RuntimeGenerationToken? ActiveRetiringGeneration =>
@ -348,6 +370,12 @@ public sealed class RuntimeGenerationReset
case RuntimeGenerationResetStage.House:
Advance(state, _house.ResetSession);
break;
case RuntimeGenerationResetStage.Contracts:
Advance(state, _contracts.ResetSession);
break;
case RuntimeGenerationResetStage.Journal:
Advance(state, _journal.ResetSession);
break;
case RuntimeGenerationResetStage.BeginEntityRetirement:
_ = _entityObjects.BeginSessionClear();
state.Retirements = _entityObjects

View file

@ -92,7 +92,10 @@ public sealed record LiveSocialSessionBindings(
// Batch C (Map/House toolbar panel, 2026-08-17): same trailing/optional
// compatibility convention — a minimal owner (RuntimeHouseState's own
// class doc), not a full sibling J-owner.
RuntimeHouseState? House = null);
RuntimeHouseState? House = null,
// Campaign QT (2026-08-21): the fourth sibling J-owner, same
// trailing/optional compatibility convention.
RuntimeContractState? Contracts = null);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
@ -334,6 +337,14 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
: null,
onHouseStatus: social.House is { } houseStatus
? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid())
: null,
// Campaign QT (2026-08-21): same conditional delegate-hole
// discipline as house above.
onContractTable: social.Contracts is { } contractTable
? contractTable.ApplyTable
: null,
onContractUpdate: social.Contracts is { } contractUpdate
? contractUpdate.ApplyUpdate
: null));
ConstructionCheckpoint();

View file

@ -122,7 +122,6 @@ public sealed class KeyBindings
b.Add(new(new KeyChord(Key.F9, ModifierMask.None), InputAction.AcdreamSensitivityUp));
b.Add(new(new KeyChord(Key.M, ModifierMask.Ctrl), InputAction.AcdreamToggleAudioMute));
b.Add(new(new KeyChord(Key.F10, ModifierMask.None), InputAction.AcdreamCycleWeather));
b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.AcdreamToggleFlyMode));
b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.AcdreamTogglePlayerMode));
b.Add(new(new KeyChord(Key.Escape, ModifierMask.None), InputAction.EscapeKey));
@ -368,14 +367,15 @@ public sealed class KeyBindings
// collide with anything retail-faithful.
b.Add(new(new KeyChord(Key.M, ModifierMask.Ctrl), InputAction.AcdreamToggleAudioMute));
// K-fix2 (2026-04-26): free-fly toggle keyboard shortcut.
// Retail leaves Ctrl+Shift+F unbound (retail F = SelectionPickUp,
// Ctrl+F = unused) so this is non-conflicting. Also discoverable
// via View → Camera in the ImGui MainMenuBar and the
// "Toggle Free-Fly Mode" button in the Debug panel.
b.Add(new(
new KeyChord(Key.F, ModifierMask.Ctrl | ModifierMask.Shift),
InputAction.AcdreamToggleFlyMode));
// The free-fly camera has NO key binding. It is a developer camera
// retail never had, and a player who reaches it finds the client in a
// state nothing in the retail UI explains. Its two discovery routes
// named by the old comment here — the ImGui View menu and the Debug
// panel button — both went away with AcDream.UI.ImGui at Campaign V,
// so the shortcut was the last way in.
//
// The mode's implementation is still present and is scheduled for
// removal; unbinding it is what makes it unreachable today.
// K-fix1 (2026-04-26): RMB-hold camera orbit. Coexists with the
// SelectRight Click binding above resolves only after a stationary

View file

@ -1,225 +0,0 @@
using System.Linq;
using System.Numerics;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <summary>
/// The chat panel. Shows the tail of <see cref="ChatLog"/> + an input
/// field at the bottom that submits on Enter.
///
/// <para>
/// Phase I.4 added the input field and slash-command parsing. Supported
/// prefixes (alias-matched against the verb token, not by string-prefix
/// — so <c>/general</c> is NOT <c>/g</c>):
/// <list type="bullet">
/// <item><c>/say &lt;msg&gt;</c> or no prefix → Say (default)</item>
/// <item><c>/t</c> / <c>/tell &lt;name&gt; &lt;msg&gt;</c> → whisper</item>
/// <item><c>/r</c> / <c>/reply &lt;msg&gt;</c> → reply to most recent
/// INCOMING Tell (uses <see cref="ChatVM.LastIncomingTellSender"/>;
/// drops the message if no Tell has arrived yet)</item>
/// <item><c>/g, /f, /a, /m, /p, /v, /cv, /lfg, /trade, /role, /society,
/// /olthoi &lt;msg&gt;</c> → corresponding channel</item>
/// <item>unknown <c>/xyz hello</c> → Say with the literal text intact
/// (matches holtburger fall-through)</item>
/// </list>
/// </para>
///
/// <para>
/// Empty / whitespace-only / target-but-no-message inputs are silently
/// dropped — the input field clears and no command goes out.
/// </para>
/// </summary>
public sealed class ChatPanel : IPanel
{
private const int InputBufferMaxLen = 512;
private readonly ChatVM _vm;
private string _input = string.Empty;
// Phase J Tier 3: tracks the chat-tail size between frames so we
// can auto-scroll the scrollable child to the bottom on new
// entries without yanking the user's manual scroll.
private int _lastRenderedCount;
// Phase K.2: one-shot focus request for the chat input. Set by
// FocusInput() (driven by Tab → ToggleChatEntry); the next Render
// call emits SetKeyboardFocusHere immediately before the input
// field and clears the flag. Without the one-shot semantics, the
// panel would steal focus on every frame and the user could never
// click into another widget.
private bool _focusRequested;
// L.0 follow-up: "Copy mode" — when true, render the chat tail as
// a read-only multi-line text widget the user can click+drag to
// select + Ctrl+C to copy. Trades per-line color for selectability;
// user toggles when they want to grab specific text out of the
// log (item names, coordinates, NPC dialogue, etc).
private bool _copyMode;
public ChatPanel(ChatVM vm)
{
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
}
/// <inheritdoc />
public string Id => "acdream.chat";
/// <inheritdoc />
public string Title => "Chat";
/// <inheritdoc />
public bool IsVisible { get; set; } = true;
/// <summary>
/// Phase K.2: request keyboard focus for the chat input on the
/// NEXT <see cref="Render"/>. One-shot — fires once and resets,
/// so callers (e.g. <c>GameWindow</c>'s Tab handler subscribing to
/// <c>ToggleChatEntry</c>) can drive it on a single key press
/// without trapping the user permanently in the input field.
/// </summary>
public void FocusInput() => _focusRequested = true;
/// <inheritdoc />
public void Render(PanelContext ctx, IPanelRenderer renderer)
{
if (!renderer.Begin(Title))
{
renderer.End();
return;
}
// L.0 follow-up: wrap the entire chat panel body in a single
// outer BeginChild so empty-space clicks anywhere in the body
// (Checkbox row, between Separator and input, etc.) are
// absorbed by BeginChild's drag-trap (an InvisibleButton the
// ImGui renderer adds inside every BeginChild). Without this
// wrapper the chat panel was draggable from any empty body
// pixel — only the inner ##chattail area was protected.
if (!renderer.BeginChild("##chatbody", new System.Numerics.Vector2(0f, 0f)))
{
renderer.EndChild();
renderer.End();
return;
}
// L.0 follow-up: top-of-panel "Copy mode" toggle. When on, the
// chat tail rendering swaps to TextMultilineReadOnly so the
// user can mark + Ctrl+C any text. Off (default) preserves the
// colored per-line render with combat highlights. The checkbox
// sits ABOVE the chat tail (not in the footer) so it's always
// visible regardless of scroll position.
bool copyMode = _copyMode;
if (renderer.Checkbox("Copy mode (select text to Ctrl+C)", ref copyMode))
_copyMode = copyMode;
renderer.Separator();
// Phase J Tier 3: keep the input field at the bottom of the
// window across resizes by reserving footer space and putting
// the chat tail in a scrollable child that fills the rest.
// The reserved footer holds: one Separator + one InputText.
// FrameHeightWithSpacing covers the input; we add a small fudge
// (~6px) for the separator above it.
float footerHeight = renderer.FrameHeightWithSpacing() + 6f;
// Phase I.7: pull the typed-line view so combat entries can
// route through TextColored. Non-combat entries still take
// the plain Text path (visually identical to the I.4 panel).
var lines = _vm.RecentLinesDetailed();
if (_copyMode)
{
// Copy mode: one big read-only multiline text widget
// holding every visible line, joined with newlines. Loses
// per-line color but lets the user click+drag to select
// arbitrary spans of text + Ctrl+C to copy. Sized to fill
// the available space minus the footer.
string joined = lines.Count == 0
? "(no messages yet)"
: string.Join("\n", lines.Select(l => l.Text));
renderer.TextMultilineReadOnly(
"##chattailcopy", joined,
new System.Numerics.Vector2(0f, -footerHeight));
}
else if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight)))
{
if (lines.Count == 0)
{
renderer.Text("(no messages yet)");
}
else
{
for (int i = 0; i < lines.Count; i++)
{
var line = lines[i];
if (line.Kind == ChatKind.Combat)
{
// Campaign CH slice CH1: color combat lines from the
// retail LogTextType table (Combat_Self/Combat_Enemy/
// Default per CombatChatTranslator's ACE-cited
// mapping) instead of the CombatLineKind info/
// warning/error severity bucket. Every LogTextType
// CombatChatTranslator emits is in-range (<0x22), so
// the fallback below is defensive only.
Vector4 color = RetailChatColorTable.TryGetColor(line.LogTextType, out var resolved)
? resolved
: ColorForCombat(line.CombatKind ?? CombatLineKind.Info);
renderer.TextColored(color, line.Text);
}
else
{
renderer.Text(line.Text);
}
}
}
// Auto-scroll to bottom only when a new line was appended
// since the last render. Manual user scroll-up isn't fought
// against; new messages will jump the view back down once
// they arrive.
if (lines.Count > _lastRenderedCount)
{
renderer.SetScrollHereY(1.0f);
}
_lastRenderedCount = lines.Count;
}
if (!_copyMode) renderer.EndChild();
// Phase I.4: input field. Backend implementation clears _input
// on submit per the IPanelRenderer contract.
renderer.Separator();
// Phase K.2: honor a pending FocusInput() request — emit
// SetKeyboardFocusHere immediately before the input widget so
// ImGui (or the future custom backend) applies it to that
// field. One-shot: clear the flag after firing.
if (_focusRequested)
{
renderer.SetKeyboardFocusHere();
_focusRequested = false;
}
if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted)
&& submitted is not null)
{
ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say);
_input = string.Empty;
}
renderer.EndChild(); // outer ##chatbody
renderer.End();
}
/// <summary>
/// Phase I.7: per-severity color for combat-feedback chat lines.
/// Maps onto holtburger's <c>color_for_tags</c> at chat.rs:330-333
/// (info → yellowish, warning → red incoming, error → deep red).
/// </summary>
public static Vector4 ColorForCombat(CombatLineKind kind) => kind switch
{
CombatLineKind.Info => new Vector4(1.0f, 1.0f, 0.6f, 1.0f),
CombatLineKind.Warning => new Vector4(1.0f, 0.5f, 0.5f, 1.0f),
CombatLineKind.Error => new Vector4(1.0f, 0.3f, 0.3f, 1.0f),
_ => new Vector4(1f, 1f, 1f, 1f),
};
}

View file

@ -259,7 +259,66 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
/// Format a single <see cref="ChatEntry"/> for display. Public so tests
/// can assert the per-kind formatting without touching a full log.
/// </summary>
public static string FormatEntry(ChatEntry entry) => entry.Kind switch
public static string FormatEntry(ChatEntry entry)
=> FormatEntry(entry, static sender => sender);
/// <summary>
/// The lowest and highest object ids retail treats as a player, and
/// therefore the only senders it makes clickable.
/// </summary>
/// <remarks>
/// AC1's dynamic/player id range, read off the guard in
/// <c>Handle_Communication__HearSpeech @0x005712A0</c>: outside it, retail
/// emits the sender's name as plain text with no tag at all. Monsters and
/// NPCs therefore never become clickable, which is the behaviour we want
/// and would not get from a "has a name" test.
/// </remarks>
private const uint FirstPlayerObjectId = 0x50000001u;
private const uint LastPlayerObjectId = 0x6FFFFFFFu;
/// <summary>
/// Formats an entry with retail's tag markup around the sender's name,
/// when that sender is a player.
/// </summary>
/// <remarks>
/// Shares its format strings with <see cref="FormatEntry(ChatEntry)"/>
/// through the sender decorator, deliberately: two copies of retail's
/// wording would be two things to keep in step, and the plain and tagged
/// renderings of a line MUST show the same characters — the transcript
/// selects and hit-tests against the flat text.
/// </remarks>
public static string FormatEntryTagged(ChatEntry entry)
=> ShouldTagSender(entry)
? FormatEntry(
entry,
sender =>
$"<Tell:IIDString:{entry.SenderGuid}:{sender}>{sender}<\\Tell>")
: FormatEntry(entry);
/// <summary>Whether this entry's sender is a clickable player.</summary>
/// <remarks>
/// A name containing a markup delimiter is deliberately NOT tagged. The
/// markup has no escape mechanism — retail's has none either, because AC
/// name validation makes the case unreachable there — so a name like
/// <c>Od&lt;d</c> would be re-parsed as a marker and SWALLOW characters
/// out of the visible line. Sender names are server data, so the guard
/// stays: the line renders plain, exactly as it does for any other
/// untaggable sender, instead of rendering corrupted.
/// </remarks>
internal static bool ShouldTagSender(ChatEntry entry)
=> entry.SenderGuid >= FirstPlayerObjectId
&& entry.SenderGuid <= LastPlayerObjectId
&& !string.IsNullOrEmpty(entry.Sender)
&& entry.Sender.IndexOf('<') < 0
&& entry.Sender.IndexOf('>') < 0
&& !IsOwnSpeaker(entry.Sender)
&& entry.Kind is ChatKind.LocalSpeech
or ChatKind.RangedSpeech
or ChatKind.Channel
or ChatKind.Tell;
private static string FormatEntry(
ChatEntry entry, Func<string, string> decorateSender) => entry.Kind switch
{
// Retail style: "Name says, \"text\"" (incoming) /
// "You say, \"text\"" (own echo). Sender is "" for an
@ -268,10 +327,10 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
// collapse to the singular "You say" verb here.
ChatKind.LocalSpeech => IsOwnSpeaker(entry.Sender)
? $"You say, \"{entry.Text}\""
: $"{entry.Sender} says, \"{entry.Text}\"",
: $"{decorateSender(entry.Sender)} says, \"{entry.Text}\"",
ChatKind.RangedSpeech => IsOwnSpeaker(entry.Sender)
? $"You shout, \"{entry.Text}\""
: $"{entry.Sender} shouts, \"{entry.Text}\"",
: $"{decorateSender(entry.Sender)} shouts, \"{entry.Text}\"",
// Channel: "[ChannelName] Sender says, \"text\"". ChannelName
// is populated by callers that know the friendly name (the
// TurbineChat inbound dispatch and OnSelfSent for Channel
@ -283,12 +342,12 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
// empty so the formatter substitutes here).
ChatKind.Channel => IsOwnSpeaker(entry.Sender)
? $"[{ChannelLabel(entry)}] You say, \"{entry.Text}\""
: $"[{ChannelLabel(entry)}] {entry.Sender} says, \"{entry.Text}\"",
: $"[{ChannelLabel(entry)}] {decorateSender(entry.Sender)} says, \"{entry.Text}\"",
// Tell: SenderGuid != 0 means an incoming whisper; == 0 is the
// OnSelfSent echo where Sender carries the target name. Retail
// wording: "You tell Caith, \"hi\"" / "Caith tells you, \"hi\"".
ChatKind.Tell => entry.SenderGuid != 0
? $"{entry.Sender} tells you, \"{entry.Text}\""
? $"{decorateSender(entry.Sender)} tells you, \"{entry.Text}\""
: $"You tell {entry.Sender}, \"{entry.Text}\"",
// Campaign CH user-gate round 1 (item B): retail prints system text
// bare, with no "[System]" prefix — that prefix was acdream's own
@ -334,10 +393,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
: entry.ChannelName;
/// <summary>
/// Phase I.7: snapshot of the chat tail with kind metadata so
/// <see cref="ChatPanel"/> can pick the right rendering primitive
/// per entry (plain <c>Text</c> for most kinds; <c>TextColored</c>
/// for combat lines, with the rgba chosen from
/// Phase I.7: snapshot of the chat tail with kind metadata so the
/// transcript can pick the right rendering per entry (most kinds take one
/// colour; combat lines take the rgba chosen from
/// <see cref="ChatEntry.CombatKind"/>). Campaign CH slice CH1 also
/// carries <see cref="ChatEntry.LogTextType"/> through — the retail
/// color key, keyed independently of <see cref="ChatKind"/>.
@ -358,31 +416,65 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
for (int i = 0; i < count; i++)
{
var entry = snap[start + i];
string text = FormatEntry(entry);
// Compose with retail's tag markup, then split it. Text stays the
// VISIBLE line (markup consumed), so every existing consumer —
// wrapping, selection, hit-testing, the caret — is unaffected;
// Spans is the sidecar that remembers which stretch was the
// speaker's name. Campaign CT slice A3: this is the point where
// sender identity used to die.
bool tagged = ShouldTagSender(entry);
string markup = FormatEntryTagged(entry);
IReadOnlyList<ChatTextSpan>? spans = tagged
? ChatTagMarkup.Parse(markup)
: null;
string text = spans is null
? markup
: string.Concat(spans.Select(span => span.Text));
if (timestamps)
text = ChatLog.FormatTimestampPrefix(entry.Received) + text;
{
string prefix = ChatLog.FormatTimestampPrefix(entry.Received);
// The stamp is its own run: retail appends it at a FIXED
// colour index rather than the message's, so it stays grey
// whatever colour the line is. That means a timestamped line
// needs spans even when its sender is not tagged.
spans = new[] { new ChatTextSpan(prefix, null, ChatSpanRole.Timestamp) }
.Concat(spans ?? new[] { new ChatTextSpan(text, null) })
.ToArray();
text = prefix + text;
}
lines[i] = new FormattedLine(
Text: text,
Kind: entry.Kind,
CombatKind: entry.CombatKind,
LogTextType: entry.LogTextType);
LogTextType: entry.LogTextType,
Spans: spans);
}
return lines;
}
}
/// <summary>
/// Phase I.7: formatted chat line with kind metadata. The
/// <see cref="ChatPanel"/> switches on <see cref="Kind"/> +
/// <see cref="CombatKind"/> to pick a rendering primitive
/// (<c>Text</c> vs <c>TextColored(rgba)</c>).
/// Phase I.7: formatted chat line with kind metadata. The transcript switches
/// on <see cref="Kind"/> + <see cref="CombatKind"/> to pick a colour.
/// </summary>
/// <param name="LogTextType">
/// Campaign CH slice CH1: the retail wire <c>LogTextType</c> that keys
/// <see cref="RetailChatColorTable"/> — see <see cref="ChatEntry.LogTextType"/>.
/// </param>
/// <param name="Spans">
/// Campaign CT slice A3: the line split into stretches, where a stretch may
/// carry a retail text tag (a clickable speaker name). <see langword="null"/>
/// for the ordinary single-colour line, which is most of them.
/// <b>Invariant:</b> concatenating the span text reproduces
/// <paramref name="Text"/> exactly — the transcript wraps, selects and
/// hit-tests against that flat string.
/// </param>
public readonly record struct FormattedLine(
string Text,
ChatKind Kind,
CombatLineKind? CombatKind,
uint LogTextType);
uint LogTextType,
IReadOnlyList<ChatTextSpan>? Spans = null);

View file

@ -79,12 +79,22 @@ public sealed class GameplayInputCommandControllerTests
Assert.Empty(harness.Calls);
}
/// <summary>
/// Escape's priority chain: cancel a target mode, else leave player mode,
/// else close a window.
/// </summary>
/// <remarks>
/// The free-fly rung was REMOVED (2026-08-21, user direction: free-fly
/// "should not be in the client"). The middle row below is the proof — a
/// session that is somehow in fly mode now falls through to the next rung
/// rather than silently exiting a camera the player has no way to enter.
/// </remarks>
[Theory]
[InlineData(true, true, true, "cancel-target")]
[InlineData(false, true, true, "exit-fly")]
[InlineData(false, true, true, "exit-player")]
[InlineData(false, false, true, "exit-player")]
[InlineData(false, false, false, "close")]
public void Escape_PreservesTargetFlyPlayerWindowPriority(
public void Escape_PreservesTargetPlayerWindowPriority(
bool targetMode,
bool flyMode,
bool playerMode,

View file

@ -161,17 +161,18 @@ public sealed class LiveSessionCommandRouterTests
Assert.Equal([("Friend", "hello")], tells);
Assert.Equal([(0x00000800u, "group")], channels);
// CH3 (2026-08-09): Fellowship is one of the ACE server-echoing
// legacy channels (resends with an empty sender) — the router must
// NOT also emit a local optimistic echo, or the line double-prints.
// Only the Tell echo (which the server never resends) survives.
Assert.Collection(
chat.Snapshot(),
entry =>
{
Assert.Equal(ChatKind.Tell, entry.Kind);
Assert.Equal("Friend", entry.Sender);
});
// CH3 (2026-08-09) got the Fellowship half right — ACE resends legacy
// channel lines, so a local echo would double-print — but its stated
// reason for keeping the Tell echo, "which the server never resends",
// was wrong: ACE's GameActionTell replies with a GameMessageSystemChat
// carrying the finished "You tell Friend, ..." line
// (ChatMessageType.OutgoingTell). Tells double-printed in the chat
// window for exactly that reason. Retail's own send path
// @0x00577CF4 has no AddTextToScroll beside it either.
//
// So NEITHER emits a local echo now, and Say has always worked this
// way.
Assert.Empty(chat.Snapshot());
}
[Fact]
@ -836,6 +837,7 @@ public sealed class LiveSessionCommandRouterTests
ShowConfirmation: (_, _) => { },
Suicide: () => { },
ClearChat: _ => { },
SetChatLogFile: _ => default,
SaveUi: _ => { },
LoadUi: _ => { },
SaveAutoUi: () => { },

View file

@ -790,6 +790,94 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
Physics: physics);
}
/// <summary>
/// The ambient flyers: a SetOmega hook must rotate the root of a DAT
/// scenery object, which has no PhysicsBody at all.
/// </summary>
/// <remarks>
/// The first attempt at this fix applied the omega only to
/// <c>owner.Body</c>, and every test passed — because nothing exercised a
/// root rotation on the ServerGuid==0 branch, and BindLiveOwner REFUSES a
/// zero ServerGuid, so DAT scenery never has a body to carry it. Live in
/// the world the birds kept flapping in place. This asserts the rotation on
/// the branch those objects actually take.
/// </remarks>
[Fact]
public void SetOmegaHook_RotatesTheRootOfDatSceneryWithNoPhysicsBody()
{
var loader = new Loader();
loader.Add(AnimationId, OmegaAnimation(new Vector3(0f, 0f, -0.027f)));
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, sequencer) => sequencer.ConsumePendingHooks(),
(_, _, _) => { });
WorldEntity entity = MakeEntity();
Assert.Equal(0u, entity.ServerGuid); // DAT scenery: no body
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: MakeSetup(),
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true));
// Retail runs process_hooks AFTER the grotate, so the first frame
// rotates by the still-zero omega and only banks the hook.
scheduler.Tick(1f / 30f);
Assert.Equal(Quaternion.Identity, entity.Rotation);
scheduler.ProcessHooks();
// ...and from the next frame on it turns.
scheduler.Tick(1f / 30f);
Assert.NotEqual(Quaternion.Identity, entity.Rotation);
// Pure yaw: the authored omegas are all Z, so X and Y stay clean.
Assert.Equal(0f, entity.Rotation.X, 5);
Assert.Equal(0f, entity.Rotation.Y, 5);
Assert.NotEqual(0f, entity.Rotation.Z, 5);
// It keeps turning — this is a continuous circuit, not a one-shot.
Quaternion afterFirst = entity.Rotation;
scheduler.Tick(1f / 30f);
Assert.NotEqual(afterFirst, entity.Rotation);
}
[Fact]
public void WithoutASetOmegaHookTheRootNeverTurns()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, sequencer) => sequencer.ConsumePendingHooks(),
(_, _, _) => { });
WorldEntity entity = MakeEntity();
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: MakeSetup(),
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true));
for (int i = 0; i < 4; i++)
{
scheduler.Tick(1f / 30f);
scheduler.ProcessHooks();
}
// Ordinary animated scenery (a swinging sign, a waterwheel's parts)
// must not acquire a spin it never asked for.
Assert.Equal(Quaternion.Identity, entity.Rotation);
}
private static Animation OmegaAnimation(Vector3 axis)
{
Animation animation = TwoFrameAnimation();
animation.PartFrames[0].Hooks.Add(new SetOmegaHook { Axis = axis });
return animation;
}
private static Animation TwoFrameAnimation()
{
var animation = new Animation();

View file

@ -0,0 +1,57 @@
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// The SetOmega hook is what moves AC's ambient flyers; ignoring it left the
/// birds and butterflies animating in place.
/// </summary>
public sealed class StaticAnimatingOmegaHookTests
{
private static SetOmegaHook Omega(float x, float y, float z)
=> new() { Axis = new Vector3(x, y, z) };
[Fact]
public void NoHooksLeavesOmegaUntouched()
{
Assert.False(
RetailStaticAnimatingObjectScheduler.TryResolveOmega([], out _));
}
[Fact]
public void AHookBatchWithoutSetOmegaLeavesOmegaUntouched()
{
// A batch that carries other hooks must not zero a previously set
// omega: retail's set_omega is only ever called BY the hook.
Assert.False(
RetailStaticAnimatingObjectScheduler.TryResolveOmega(
[new SoundTableHook()], out _));
}
[Fact]
public void SetOmegaIsTakenVerbatim()
{
Assert.True(
RetailStaticAnimatingObjectScheduler.TryResolveOmega(
[Omega(0f, 0f, -0.027f)], out Vector3 omega));
// CPhysicsObj::set_omega @ 0x0050F6D0 assigns the axis outright — no
// scaling, and animate_static_object does not multiply it by elapsed
// time either.
Assert.Equal(new Vector3(0f, 0f, -0.027f), omega);
}
[Fact]
public void TheLastSetOmegaInABatchWins()
{
// Retail executes a hook batch in order and each set_omega overwrites
// the vector outright rather than accumulating.
Assert.True(
RetailStaticAnimatingObjectScheduler.TryResolveOmega(
[Omega(0f, 0f, -0.02f), Omega(0f, 0f, 0.05f)],
out Vector3 omega));
Assert.Equal(new Vector3(0f, 0f, 0.05f), omega);
}
}

View file

@ -0,0 +1,121 @@
using System;
using System.IO;
using AcDream.App.UI;
using AcDream.Core.Chat;
namespace AcDream.App.Tests.UI;
/// <summary>
/// CT-B4: what actually reaches the <c>@log</c> file.
/// </summary>
public sealed class ChatTranscriptLogWriterTests : IDisposable
{
private readonly string _directory =
Path.Combine(Path.GetTempPath(), "acdream-logwriter-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
try { Directory.Delete(_directory, recursive: true); }
catch (IOException) { /* nothing left to say */ }
}
private string Read() => File.ReadAllText(Path.Combine(_directory, "session.txt"));
[Fact]
public void TheComposedDisplayLineIsLoggedRatherThanTheRawMessage()
{
// The entry's own Text is just 'hello' — the speaker and the quotes
// are composition. Retail's log write sits downstream of that
// (fprintf inside AddTextToScroll), so logging entry.Text would give
// a file full of bare fragments with no idea who said them.
using var log = new ChatSessionLog(_directory);
var transcript = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "hello", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"hello\"\n", Read());
}
[Fact]
public void TheTimestampFollowsTheSameOptionTheWindowUses()
{
// Retail computes the stamp ONCE and hands the same string to the
// window and to the file, so the two can never disagree.
using var log = new ChatSessionLog(_directory);
bool stamps = false;
var transcript = new ChatLog { DisplayTimestampsSource = () => stamps };
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "before", 0x02u, false, 0u);
stamps = true;
transcript.OnLocalSpeech("Dww", "after", 0x02u, false, 0u);
log.Close();
string[] lines = Read().Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal("Dww says, \"before\"", lines[0]);
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Dww says, ""after""$", lines[1]);
}
[Fact]
public void NothingIsLoggedBeforeAttachOrAfterDetach()
{
// Retail's help is explicit that logging starts when you type the
// command: only what appears AFTER it is copied.
using var log = new ChatSessionLog(_directory);
var transcript = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
transcript.OnLocalSpeech("Dww", "before attach", 0x02u, false, 0u);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "during", 0x02u, false, 0u);
writer.Detach();
transcript.OnLocalSpeech("Dww", "after detach", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"during\"\n", Read());
}
[Fact]
public void AttachingTwiceDoesNotWriteEveryLineTwice()
{
using var log = new ChatSessionLog(_directory);
var transcript = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(transcript);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "once", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"once\"\n", Read());
}
[Fact]
public void DetachReleasesTheTranscriptItActuallyAttachedTo()
{
// A session teardown replaces the transcript. Detaching from the
// CURRENT one would leave a live handler on the old one, which then
// keeps writing into a file the player believes is closed.
using var log = new ChatSessionLog(_directory);
var first = new ChatLog();
var second = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(first);
writer.Attach(second); // switches transcripts
first.OnLocalSpeech("Dww", "stale", 0x02u, false, 0u);
second.OnLocalSpeech("Dww", "live", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"live\"\n", Read());
}
}

View file

@ -1,4 +1,5 @@
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Physics;
using AcDream.Core.Social;
using AcDream.UI.Abstractions;
@ -432,6 +433,125 @@ public sealed class ClientCommandControllerTests
Assert.Throws<ArgumentOutOfRangeException>(() => controller.Execute(command));
}
// ── CT-B4: retail's @log ────────────────────────────────────────────
[Fact]
public void Log_WithAName_ReportsWhereChatIsGoingAndHowToStop()
{
// Retail's reply, byte-decoded from the paired binary — Binary Ninja
// truncates it at "Copying chat to %s. Run command…". Note the TWO
// spaces after the period; they are retail's.
var messages = new List<string>();
var calls = new List<string>();
ClientCommandController ctrl = NewController(
calls, messages: messages,
chatLog: name => new ChatLogResult(true, false, name, null));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, "aclog.txt"));
Assert.Contains("log:aclog.txt", calls);
Assert.Equal(
"Copying chat to aclog.txt. Run command again with no arguments "
+ "to turn off logging.",
Assert.Single(messages));
}
[Fact]
public void Log_WhenTheFileCannotBeOpened_SaysSoRatherThanClaimingSuccess()
{
var messages = new List<string>();
ClientCommandController ctrl = NewController(
messages: messages,
chatLog: name => new ChatLogResult(false, false, name, null));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, "C:/nope/x.txt"));
Assert.Equal(
"Failed to redirect to file C:/nope/x.txt!",
Assert.Single(messages));
}
[Fact]
public void Log_WithNoArgument_ClosesTheOpenLogAndSaysBothLines()
{
// CloseLogFile announces the file, then DoSetOutput announces the
// redirect. Two lines, in that order.
var messages = new List<string>();
ClientCommandController ctrl = NewController(
messages: messages,
chatLog: _ => new ChatLogResult(false, true, string.Empty, "aclog.txt"));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, ""));
Assert.Equal(
["Chat log aclog.txt closed.", "Chat output now directed only to the screen."],
messages);
}
[Fact]
public void Log_WithNoArgumentAndNothingOpen_AsksForAFileName()
{
// The same verb with the same arguments says something DIFFERENT
// depending on whether a log was running — retail branches on
// CloseLogFile's return value, not on the arguments.
var messages = new List<string>();
ClientCommandController ctrl = NewController(
messages: messages,
chatLog: _ => new ChatLogResult(false, false, string.Empty, null));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, ""));
Assert.Equal(
"Please specify a file to append chat messages to.",
Assert.Single(messages));
}
[Fact]
public void Log_StartingASecondLogAnnouncesThatTheFirstEnded()
{
var messages = new List<string>();
ClientCommandController ctrl = NewController(
messages: messages,
chatLog: name => new ChatLogResult(true, true, name, "old.txt"));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, "new.txt"));
Assert.Equal("Chat log old.txt closed.", messages[0]);
Assert.StartsWith("Copying chat to new.txt.", messages[1]);
}
[Fact]
public void Log_TakesTheWholeRemainderSoASpacedNameSurvives()
{
// Retail JoinArgs the arguments before using them as a filename.
var calls = new List<string>();
ClientCommandController ctrl = NewController(
calls,
chatLog: name => new ChatLogResult(true, false, name, null));
ctrl.Execute(new ExecuteClientCommandCmd(
ClientCommandId.ChatLogFile, "my chat log.txt"));
Assert.Contains("log:my chat log.txt", calls);
}
[Fact]
public void Log_ResolvesFromTheCatalogWithItsWholeRemainderAsTheArgument()
{
// The verb had a help entry since CH4 but no catalog entry, so /log
// printed help and did nothing. This pins the registration.
Assert.True(RetailClientCommandCatalog.TryMatch(
"/log my chat log.txt", out RetailClientCommandCatalog.Match match));
Assert.Equal(ClientCommandId.ChatLogFile, match.Command);
Assert.Equal("my chat log.txt", match.Arguments);
}
private static ClientCommandController NewController(
List<string>? calls = null,
List<uint>? errors = null,
@ -448,7 +568,8 @@ public sealed class ClientCommandControllerTests
// ShowConfirmation calls (e.g. house-abandon's two-stage prompt).
// Defaults to "always accept" so every pre-existing single-stage
// test (Die, etc.) keeps its original behavior unchanged.
Queue<bool>? confirmationResponses = null)
Queue<bool>? confirmationResponses = null,
Func<string, AcDream.Core.Chat.ChatLogResult>? chatLog = null)
{
calls ??= [];
errors ??= [];
@ -483,6 +604,16 @@ public sealed class ClientCommandControllerTests
},
() => calls.Add("suicide"),
all => calls.Add("clear:" + all),
name =>
{
calls.Add("log:" + name);
return chatLog?.Invoke(name)
?? new AcDream.Core.Chat.ChatLogResult(
Opened: name.Length > 0,
Closed: name.Length == 0,
Name: name,
ClosedName: "old.txt");
},
name => calls.Add("saveui:" + name),
name => calls.Add("loadui:" + name),
() => calls.Add("saveautoui"),

View file

@ -0,0 +1,180 @@
using System;
using System.IO;
using System.Linq;
using AcDream.App.UI;
using AcDream.Core.Journal;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign QJ slice QJ5: the per-character journal file.
/// </summary>
public sealed class JournalPersistenceTests : IDisposable
{
private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
private readonly string _directory =
Path.Combine(Path.GetTempPath(), "acdream-journal-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
try { Directory.Delete(_directory, recursive: true); }
catch (IOException) { /* the test already said what it needed to */ }
}
private (RuntimeJournalState Journal, JournalPersistence File, List<string> Reports) New()
{
var journal = new RuntimeJournalState();
var reports = new List<string>();
return (journal, new JournalPersistence(journal, _directory, reports.Add), reports);
}
[Fact]
public void APageWrittenInOneSessionIsThereInTheNext()
{
// The whole point of the feature.
(RuntimeJournalState first, JournalPersistence firstFile, _) = New();
firstFile.Load("Acdream");
first.NewPage();
first.UpdateCurrent("label", "A title", "Some notes.");
Assert.True(firstFile.Save(Now));
first.Dispose();
(RuntimeJournalState second, JournalPersistence secondFile, _) = New();
secondFile.Load("Acdream");
JournalPage page = Assert.Single(second.View.Pages);
Assert.Equal("A title", page.Title);
Assert.Equal("Some notes.", page.Notes);
second.Dispose();
}
[Fact]
public void EachCharacterGetsItsOwnFile()
{
// Sharing one file would show a character another's notes.
(RuntimeJournalState journal, JournalPersistence file, _) = New();
file.Load("Acdream");
journal.NewPage();
journal.UpdateCurrent("a", "Acdream's page", string.Empty);
file.Save(Now);
file.Load("Someone Else");
Assert.Empty(journal.View.Pages);
journal.Dispose();
}
[Fact]
public void ACharacterWithNoFileLoadsAnEmptyJournalWithoutComplaining()
{
// First-time use is the ordinary case, not an error.
(RuntimeJournalState journal, JournalPersistence file, List<string> reports) = New();
file.Load("Newcomer");
Assert.Empty(journal.View.Pages);
Assert.Empty(reports);
journal.Dispose();
}
[Fact]
public void AMalformedFileReportsAndLeavesTheJournalEmpty()
{
// Half-reading a notebook loses pages, and the next save would then
// write that loss back over the original.
(RuntimeJournalState journal, JournalPersistence file, List<string> reports) = New();
Directory.CreateDirectory(_directory);
File.WriteAllText(
Path.Combine(_directory, JournalFile.FileNameFor("acdream", "Acdream")),
"<TITL> no page marker first\n");
file.Load("Acdream");
Assert.Equal(JournalFile.MalformedFileMessage, Assert.Single(reports));
Assert.Empty(journal.View.Pages);
journal.Dispose();
}
[Fact]
public void SavingIsSkippedWhenNothingChanged()
{
// Retail's save fires on every hide; rewriting an unchanged file on
// every tab switch is pure disk churn.
(RuntimeJournalState journal, JournalPersistence file, _) = New();
file.Load("Acdream");
journal.NewPage();
Assert.True(file.Save(Now));
Assert.False(file.Save(Now));
journal.Dispose();
}
[Fact]
public void SavingBeforeAnyCharacterIsLoadedIsANoOp()
{
// The panel can be disposed before a character ever entered the world.
(RuntimeJournalState journal, JournalPersistence file, _) = New();
Assert.False(file.Save(Now));
Assert.False(Directory.Exists(_directory));
journal.Dispose();
}
[Fact]
public void ARunningTimerPersistsItsREMAININGTime()
{
// Saving the value it started at would resurrect the full duration on
// every reload.
(RuntimeJournalState journal, JournalPersistence file, _) = New();
file.Load("Acdream");
journal.NewPage();
journal.SetTimer(0, 1, 0);
journal.StartTimer(Now);
file.Save(Now.AddMinutes(30));
journal.Dispose();
(RuntimeJournalState reloaded, JournalPersistence reloadedFile, _) = New();
reloadedFile.Load("Acdream");
Assert.Equal(1800d, Assert.Single(reloaded.View.Pages).RunningTimerSeconds);
reloaded.Dispose();
}
[Fact]
public void CloseSavesAndForgetsTheCharacter()
{
(RuntimeJournalState journal, JournalPersistence file, _) = New();
file.Load("Acdream");
journal.NewPage();
file.Close(Now);
Assert.Null(file.CurrentPath);
Assert.False(file.Save(Now));
journal.Dispose();
}
[Fact]
public void AnUnwritableDirectoryReportsRatherThanThrowing()
{
// Losing a note is bad; taking the client down while the player is
// writing one is worse.
var journal = new RuntimeJournalState();
var reports = new List<string>();
string blocked = Path.Combine(_directory, "blocked");
Directory.CreateDirectory(_directory);
File.WriteAllText(blocked, "not a directory");
var file = new JournalPersistence(journal, blocked, reports.Add);
file.Load("Acdream");
journal.NewPage();
Assert.False(file.Save(Now));
Assert.NotEmpty(reports);
journal.Dispose();
}
}

View file

@ -0,0 +1,85 @@
using System.Collections.Generic;
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CT slice A5: clicking a speaker's name opens a tell to them.
/// </summary>
public sealed class ChatTagClickTests
{
private static ChatTextTag Tell(string name)
=> new("Tell", "IIDString", $"1342177290:{name}");
private static IReadOnlyList<ChatTextSpan> TellSpans(string name, string rest)
=> new[] { new ChatTextSpan(name, Tell(name)), new ChatTextSpan(rest, null) };
[Fact]
public void TheNamesColumnsAreTaggedAndTheRestIsNot()
{
IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>? ranges =
ChatTranscriptRenderer.TaggedRangesForFragment(
TellSpans("Dww", " tells you"),
fragmentStart: 0,
fragmentLength: "Dww tells you".Length);
(int start, int length, ChatTextTag tag) = Assert.Single(ranges!);
Assert.Equal(0, start);
Assert.Equal(3, length);
Assert.True(tag.TryGetIidString(out _, out string name));
Assert.Equal("Dww", name);
}
[Fact]
public void ColumnsAreRelativeToTheFragmentNotTheWholeLine()
{
// A click resolves to a line index in the WRAPPED list plus a column
// within that line, so ranges must be fragment-relative or every click
// on a wrapped line lands on the wrong characters.
IReadOnlyList<ChatTextSpan> spans = TellSpans("Dww", " tells you now");
// A window starting mid-name: the tagged range starts at column 0 of
// the fragment, not at column 1 of the line.
(int start, int length, _) = Assert.Single(
ChatTranscriptRenderer.TaggedRangesForFragment(spans, 1, 5)!);
Assert.Equal(0, start);
Assert.Equal(2, length); // "ww" — the part of the name inside the window
}
[Fact]
public void AFragmentPastTheNameHasNoTaggedRanges()
{
Assert.Null(ChatTranscriptRenderer.TaggedRangesForFragment(
TellSpans("Dww", " tells you"),
fragmentStart: 6,
fragmentLength: 4));
}
[Fact]
public void AnUntaggedLineHasNoTaggedRanges()
{
Assert.Null(ChatTranscriptRenderer.TaggedRangesForFragment(
new[] { new ChatTextSpan("Welcome.", null) },
fragmentStart: 0,
fragmentLength: 8));
}
[Theory]
// A caret slot sits BETWEEN glyphs, so the range is half-open: the column
// just past the name's last letter belongs to the space after it.
[InlineData(0, true)]
[InlineData(2, true)]
[InlineData(3, false)]
[InlineData(9, false)]
public void OnlyColumnsInsideTheNameCount(int column, bool inside)
{
IReadOnlyList<(int Start, int Length, ChatTextTag Tag)> ranges =
ChatTranscriptRenderer.TaggedRangesForFragment(
TellSpans("Dww", " tells you"), 0, "Dww tells you".Length)!;
(int start, int length, _) = ranges[0];
Assert.Equal(inside, column >= start && column < start + length);
}
}

View file

@ -0,0 +1,295 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CT slice A4: a tagged speaker name draws in the element's authored
/// TAG colour while the rest of its line keeps the message colour.
/// </summary>
public sealed class ChatTranscriptRunsTests
{
private static readonly Vector4 LineColor = new(0.8f, 0.8f, 0.8f, 1f);
/// <summary>Measured from the installed dats: chat 0x2100006F / 0x10000011.</summary>
private static readonly Vector4 TagGreen = new(0f, 178f / 255f, 0f, 1f);
/// <summary>10px per character, so wrap points are predictable.</summary>
private static float Measure(string text) => text.Length * 10f;
private static IReadOnlyList<ChatTextSpan> TellSpans(string name, string rest)
=> new[]
{
new ChatTextSpan(name, new ChatTextTag("Tell", "IIDString", $"1342177290:{name}")),
new ChatTextSpan(rest, null),
};
[Fact]
public void TheNameTakesTheTagColourAndTheRestTakesTheLineColour()
{
IReadOnlyList<UiText.TextRun>? runs = ChatTranscriptRenderer.RunsForFragment(
TellSpans("Dww", " tells you"),
fragmentStart: 0,
fragmentLength: "Dww tells you".Length,
LineColor,
TagGreen);
Assert.NotNull(runs);
Assert.Equal(2, runs!.Count);
Assert.Equal(("Dww", TagGreen), (runs[0].Text, runs[0].Color));
Assert.Equal((" tells you", LineColor), (runs[1].Text, runs[1].Color));
}
[Fact]
public void AFragmentWithNoTagInItGetsNoRunsAtAll()
{
// A single-colour fragment must take the ordinary flat draw path
// rather than a one-run list that means the same thing.
IReadOnlyList<ChatTextSpan> spans = TellSpans("Dww", " tells you, hello there");
Assert.Null(ChatTranscriptRenderer.RunsForFragment(
spans,
fragmentStart: 10, // well past the name
fragmentLength: 5,
LineColor,
TagGreen));
}
[Fact]
public void ATagStraddlingAWrapBreakIsSplitAcrossBothFragments()
{
// The name is long enough to be cut in half by the wrap; both halves
// must still be green, or a name would change colour mid-word.
IReadOnlyList<ChatTextSpan> spans = TellSpans("Bartholomew", " tells you");
IReadOnlyList<UiText.TextRun>? first = ChatTranscriptRenderer.RunsForFragment(
spans, fragmentStart: 0, fragmentLength: 5, LineColor, TagGreen);
IReadOnlyList<UiText.TextRun>? second = ChatTranscriptRenderer.RunsForFragment(
spans, fragmentStart: 5, fragmentLength: 6, LineColor, TagGreen);
Assert.Equal("Barth", Assert.Single(first!).Text);
Assert.Equal(TagGreen, first![0].Color);
Assert.Equal("olomew", Assert.Single(second!).Text);
Assert.Equal(TagGreen, second![0].Color);
}
[Fact]
public void RunsAlwaysReproduceTheFragmentTheyCover()
{
// UiText.RunsMatchLine refuses to draw runs that disagree with the
// line, so a mapping bug here would silently fall back to flat text
// rather than fail loudly. Assert the contract directly.
IReadOnlyList<ChatTextSpan> spans = TellSpans("Dww", " tells you, \"hi\"");
string line = string.Concat(spans.Select(s => s.Text));
for (int start = 0; start < line.Length; start++)
{
for (int len = 1; len <= line.Length - start; len++)
{
IReadOnlyList<UiText.TextRun>? runs =
ChatTranscriptRenderer.RunsForFragment(
spans, start, len, LineColor, TagGreen);
if (runs is null)
continue;
string fragment = line.Substring(start, len);
Assert.True(
UiText.RunsMatchLine(runs, fragment),
$"runs disagree with fragment [{start}..{start + len})");
}
}
}
[Fact]
public void BuildLines_EmitsOneRunEntryPerWrappedFragment()
{
// The run list is index-aligned with the lines the transcript draws;
// if the two ever fall out of step, names colour on the wrong rows.
var detailed = new List<FormattedLine>
{
new("Dww tells you, hello there friend", ChatKind.Tell, null, 0x03u,
TellSpans("Dww", " tells you, hello there friend")),
new("Welcome.", ChatKind.System, null, 0x05u),
};
var runs = new List<IReadOnlyList<UiText.TextRun>?>();
List<UiText.Line> lines = ChatTranscriptRenderer.BuildLines(
detailed,
maxW: 150f, // forces the first entry to wrap
Measure,
accept: null,
defaultColor: LineColor,
tagColor: TagGreen,
runsPerLine: runs);
Assert.True(lines.Count > 2, "the first line should have wrapped");
Assert.Equal(lines.Count, runs.Count);
// The very first fragment holds the name, so it is the one with runs.
Assert.NotNull(runs[0]);
Assert.Equal(TagGreen, runs[0]![0].Color);
// The untagged system line never gets runs.
Assert.Null(runs[^1]);
}
[Fact]
public void BuildLines_WithoutATagColourFallsBackToTheLineColour()
{
// An element that authors no 0x1D must not render the name in a
// colour nobody chose; it renders like the rest of the line.
var detailed = new List<FormattedLine>
{
new("Dww tells you", ChatKind.Tell, null, 0x03u,
TellSpans("Dww", " tells you")),
};
var runs = new List<IReadOnlyList<UiText.TextRun>?>();
ChatTranscriptRenderer.BuildLines(
detailed, maxW: 1000f, Measure, accept: null,
defaultColor: LineColor, tagColor: null, runsPerLine: runs);
IReadOnlyList<UiText.TextRun> line = Assert.Single(runs)!;
Assert.All(line, run => Assert.Equal(line[0].Color, run.Color));
}
// ── CT-B1: retail's transcript character budget ─────────────────────
private static FormattedLine Plain(string text, uint logTextType = 0x02u)
=> new(text, ChatKind.LocalSpeech, null, logTextType);
[Fact]
public void AShortHistoryIsKeptWhole()
{
var detailed = new List<FormattedLine> { Plain("one"), Plain("two") };
Assert.Equal(0, ChatTranscriptRenderer.FirstLineWithinBudget(detailed, accept: null));
}
[Fact]
public void TheOldestLinesDropOnceTheBudgetIsExceeded()
{
// Four lines of 10 characters (+1 newline each = 11) against a budget
// of 25 keeps the newest two and drops the older two.
var detailed = new List<FormattedLine>
{
Plain(new string('a', 10)),
Plain(new string('b', 10)),
Plain(new string('c', 10)),
Plain(new string('d', 10)),
};
Assert.Equal(
2,
ChatTranscriptRenderer.FirstLineWithinBudget(detailed, accept: null, budget: 25));
}
[Fact]
public void FilteredOutLinesDoNotConsumeBudget()
{
// A line this window filters out is not in its buffer at all, so it
// must not push older lines off the top — otherwise turning a filter
// OFF would silently shorten the visible history.
var detailed = new List<FormattedLine>
{
Plain(new string('a', 10), logTextType: 0x02u),
Plain(new string('x', 100), logTextType: 0x06u), // filtered
Plain(new string('b', 10), logTextType: 0x02u),
};
Assert.Equal(
0,
ChatTranscriptRenderer.FirstLineWithinBudget(
detailed, accept: type => type == 0x02u, budget: 25));
}
[Fact]
public void BuildLines_RendersOnlyTheLinesInsideTheBudget()
{
var detailed = new List<FormattedLine>();
for (int i = 0; i < 40; i++)
detailed.Add(Plain(new string((char)('a' + (i % 26)), 500)));
List<UiText.Line> lines = ChatTranscriptRenderer.BuildLines(
detailed, maxW: 100000f, Measure, accept: null, defaultColor: LineColor);
// 40 * 501 = 20,040 characters against retail's 10,000 budget, so
// roughly half survive — and crucially the NEWEST half.
Assert.True(lines.Count < detailed.Count, "the oldest lines should have dropped");
Assert.Equal(detailed[^1].Text, lines[^1].Text);
}
[Fact]
public void TheBudgetIsRetailsOwnNumber()
=> Assert.Equal(0x2710, ChatTranscriptRenderer.MaxTranscriptCharacters);
// ── CT-C3: the timestamp is its own colour ──────────────────────────
[Fact]
public void TheTimestampIsGreyWhateverColourTheMessageIs()
{
// Retail appends the stamp at a FIXED colour index (0x0C, colorGrey)
// rather than the message's, so a red combat line and a white say line
// carry the same grey stamp.
var spans = new[]
{
new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
new ChatTextSpan("Dww says, \"hi\"", null),
};
IReadOnlyList<UiText.TextRun>? runs = ChatTranscriptRenderer.RunsForFragment(
spans,
fragmentStart: 0,
fragmentLength: spans[0].Text.Length + spans[1].Text.Length,
LineColor,
TagGreen);
Assert.NotNull(runs);
Assert.Equal(2, runs!.Count);
Assert.Equal("13:05:09 ", runs[0].Text);
Assert.NotEqual(LineColor, runs[0].Color); // NOT the message colour
Assert.Equal(LineColor, runs[1].Color); // the body still is
}
[Fact]
public void ATimestampedLineGetsRunsEvenWithNoTaggedSender()
{
// The stamp alone is reason enough to need runs — before CT-C3 a line
// only got them when its sender was tagged, so an untagged line's
// stamp took the message colour.
var spans = new[]
{
new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
new ChatTextSpan("Welcome.", null),
};
Assert.NotNull(ChatTranscriptRenderer.RunsForFragment(
spans, 0, spans[0].Text.Length + spans[1].Text.Length,
LineColor, TagGreen));
}
[Fact]
public void ATimestampAndATaggedNameKeepSeparateColours()
{
var spans = new[]
{
new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
new ChatTextSpan("Dww", new ChatTextTag("Tell", "IIDString", "1:Dww")),
new ChatTextSpan(" tells you", null),
};
IReadOnlyList<UiText.TextRun> runs = ChatTranscriptRenderer.RunsForFragment(
spans, 0, 9 + 3 + 10, LineColor, TagGreen)!;
Assert.Equal(3, runs.Count);
Assert.Equal(TagGreen, runs[1].Color);
Assert.NotEqual(TagGreen, runs[0].Color);
Assert.NotEqual(runs[0].Color, runs[2].Color);
}
}

View file

@ -5,6 +5,7 @@ using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.Runtime.Chat;
namespace AcDream.App.Tests.UI.Layout;
@ -122,6 +123,17 @@ public class ChatWindowControllerTests
info.StateMedia["Highlight"] = (0x2u, 1);
return info;
}
// The unseen-text indicator (CT-C1). Authors Normal and Ghosted media
// the way the real element does, so TrySetRetailState can resolve both
// — Ghosted is its authored DEFAULT and is what retail's own click
// handler sets (SetState(0xD)).
var unread = new ElementInfo
{
Id = 0x1000048Cu, Type = 1, X = 0, Y = 57, Width = 16, Height = 16,
};
unread.StateMedia["Normal"] = (0x3u, 1);
unread.StateMedia["Ghosted"] = (0x4u, 1);
var indicator1 = MakeIndicator(0x10000522u, 5);
var indicator2 = MakeIndicator(0x10000523u, 22);
var indicator3 = MakeIndicator(0x10000524u, 39);
@ -134,6 +146,7 @@ public class ChatWindowControllerTests
root.Children.Add(transcriptPanel);
root.Children.Add(inputBar);
root.Children.Add(maxMinNode);
root.Children.Add(unread);
root.Children.Add(indicator1);
root.Children.Add(indicator2);
root.Children.Add(indicator3);
@ -157,6 +170,223 @@ public class ChatWindowControllerTests
Assert.NotNull(ctrl);
}
// ── CT-C1: the unseen-text indicator ────────────────────────────────
private static ChatWindowController BindController()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
ChatWindowController? ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
return ctrl!;
}
[Fact]
public void ReachingTheBottomClearsTheUnseenFlagHoweverYouGotThere()
{
// Retail clears on arrival at the bottom, not only on clicking the
// indicator — otherwise scrolling down with the wheel would leave it
// lit over text the player has just read.
ChatWindowController ctrl = BindController();
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
ctrl.Transcript.Scroll.SetScrollY(0); // scrolled up
Assert.False(ctrl.Transcript.Scroll.AtEnd);
ctrl.Transcript.Scroll.ScrollToEnd();
ctrl.UpdateUnreadIndicator();
Assert.True(ctrl.Transcript.Scroll.AtEnd);
}
[Fact]
public void TheIndicatorFollowsItsAuthoredPerStateVisibility()
{
// The element authors dat property 0x3B ("Invisible") per state, and
// it is what decides whether the thing is on screen:
// state 13 Ghosted 0x3B = True -> hidden
// state 1 Normal 0x3B = False -> shown
// (measured with LayoutDump --props on 0x2100006F).
//
// UiDatElement applies 0x3B on a state change; UiButton does not, and
// this element builds as a button — so driving the state ALONE leaves
// it hidden forever, which is exactly the regression this pins.
ChatWindowController ctrl = BindController();
UiElement indicator = Assert.IsAssignableFrom<UiElement>(
ctrl.UnreadIndicatorForTest);
Assert.False(indicator.Visible); // Ghosted at rest
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
ctrl.Transcript.Scroll.SetScrollY(0);
ctrl.SetUnreadForTest(true);
ctrl.UpdateUnreadIndicator();
Assert.True(indicator.Visible); // Normal once text is unseen
ctrl.Transcript.Scroll.ScrollToEnd();
ctrl.UpdateUnreadIndicator();
Assert.False(indicator.Visible); // back to Ghosted
}
[Fact]
public void TheAuthoredHandOffEndsTheFlashEvenWhileStillScrolledUp()
{
// The blink is not code, it is DATA: the Normal state's media authors
// six image frames, three pauses' worth of alternation, and then a
// State step back to Ghosted (measured with LayoutDump --media on
// 0x1000048C). So retail's indicator is a three-second attention
// FLASH that hides itself — not a badge that stays lit until you
// scroll down. The controller has to let the sequence finish rather
// than re-lighting it every frame.
ChatWindowController ctrl = BindController();
var indicator = Assert.IsType<UiButton>(ctrl.UnreadIndicatorForTest);
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
ctrl.Transcript.Scroll.SetScrollY(0);
ctrl.SetUnreadForTest(true);
ctrl.UpdateUnreadIndicator();
Assert.True(indicator.Visible);
// Stand in for the sequence reaching its terminal State step.
indicator.TrySetRetailState(UiButtonStateMachine.Ghosted);
ctrl.UpdateUnreadIndicator();
Assert.False(indicator.Visible);
Assert.False(ctrl.Transcript.Scroll.AtEnd); // still scrolled up
}
[Fact]
public void TheFlashIsStartedOnceRatherThanEveryFrame()
{
// Re-setting Normal every frame would restart the sequence, pinning it
// on frame zero — it would sit there lit and never blink at all. That
// is the whole bug this pins, and it is invisible to a test that only
// checks the indicator is showing.
ChatWindowController ctrl = BindController();
var indicator = Assert.IsType<UiButton>(ctrl.UnreadIndicatorForTest);
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
ctrl.Transcript.Scroll.SetScrollY(0);
ctrl.SetUnreadForTest(true);
ctrl.UpdateUnreadIndicator();
// Mid-sequence the player owns the state; the controller must not
// touch it again until the unseen flag is cleared and re-raised.
indicator.TrySetRetailState(UiButtonStateMachine.Ghosted);
for (int frame = 0; frame < 5; frame++)
ctrl.UpdateUnreadIndicator();
Assert.Equal("Ghosted", indicator.ActiveState);
}
[Fact]
public void ClickingTheIndicatorJumpsToTheNewestText()
{
ChatWindowController ctrl = BindController();
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
ctrl.Transcript.Scroll.SetScrollY(0);
Assert.False(ctrl.Transcript.Scroll.AtEnd);
ctrl.ScrollToNewestAndClearUnread();
Assert.True(ctrl.Transcript.Scroll.AtEnd);
}
[Fact]
public void StartTell_PrefillsTheEntryAndPutsTheCaretAtTheEnd()
{
// Retail's ChatInterface::StartTell @0x004F41F0 writes "@tell {Name}, "
// into the chat entry and takes focus, so the player can type straight
// into a reply. The trailing space matters: without it the first thing
// they type joins the comma.
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
ChatWindowController? ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
ctrl!.StartTell("Dww");
Assert.Equal("@tell Dww, ", ctrl.Input.Text);
// ...and the caret sits AFTER the prefix, ready to type into. It landed
// at column 0 in the first cut because an explicit MoveCaret(int.MaxValue)
// was stacked on top of SetText, which already ends there — MoveCaret
// takes a delta, so that overflowed negative and clamped to zero.
Assert.Equal("@tell Dww, ".Length, ctrl.Input.CaretPos);
}
// ── Talk-focus specials: "Tell to X" / "Squelch (ignore) X" ─────────────
/// <summary>
/// Both entries act on the CURRENT SELECTION and carry its name, and both
/// used to be deliberate no-ops.
/// </summary>
[Fact]
public void TalkFocusSpecials_CarryTheSelectedName_AndActOnIt()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
string? selected = "Dww";
ChatWindowController? ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex,
selectedTargetName: () => selected);
Assert.NotNull(ctrl);
UiMenu menu = Assert.IsType<UiMenu>(layout.FindElement(0x10000014u));
// Retail substitutes the name into both labels
// (StringInfo::AddVariable_String @0x004CD91C / @0x004CD982).
menu.OnOpen!.Invoke();
Assert.Equal("Squelch (ignore) Dww", menu.Items[0].Label);
Assert.Equal("Tell to Dww", menu.Items[1].Label);
// Picking "Tell to Dww" aims the chat bar at them, so an ordinary typed
// line is sent as a tell rather than being dropped for want of a target.
menu.OnSelect!.Invoke(menu.Items[1].Payload);
ctrl!.Input.OnSubmit!.Invoke("hello");
SendChatCmd tell = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
Assert.Equal(ChatChannelKind.Tell, tell.Channel);
Assert.Equal("Dww", tell.TargetName);
Assert.Equal("hello", tell.Text);
// Squelch reaches the already-registered /squelch verb rather than
// reimplementing the request.
bus.Published.Clear();
menu.OnSelect.Invoke(menu.Items[0].Payload);
ExecuteClientCommandCmd squelch =
Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.Squelch, squelch.Command);
Assert.Equal("Dww", squelch.Arguments);
}
[Fact]
public void TalkFocusSpecials_AreInertAndUnnamedWithNothingSelected()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
ChatWindowController? ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex,
selectedTargetName: () => null);
Assert.NotNull(ctrl);
UiMenu menu = Assert.IsType<UiMenu>(layout.FindElement(0x10000014u));
menu.OnOpen!.Invoke();
Assert.Equal("Squelch (ignore)", menu.Items[0].Label);
Assert.Equal("Tell to Selected", menu.Items[1].Label);
// Retail arms the tell slot only once a talkable object is selected
// (SetTalkFocusEnabled(2, 1) @0x004CD9B0).
Assert.False(menu.EnabledProvider!(menu.Items[0].Payload));
Assert.False(menu.EnabledProvider(menu.Items[1].Payload));
menu.OnSelect!.Invoke(menu.Items[1].Payload);
menu.OnSelect.Invoke(menu.Items[0].Payload);
Assert.Empty(bus.Published);
}
// ── Test 2: Transcript is placed as a child of the transcript panel ──────
[Fact]

View file

@ -0,0 +1,549 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
using AcDream.Core.Quests;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign QT slice QT5: the Journal panel's Contracts page.
/// </summary>
public sealed class JournalContractsPageControllerTests
{
private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
// The authored ids, from the installed dats.
private const uint ListId = 0x100005CFu;
private const uint RowNameId = 0x100005D1u;
private const uint RowStatusId = 0x100005D2u;
private const uint StatusValueId = 0x100005DFu;
private const uint ContactValueId = 0x100005E0u;
private const uint ContactLocationValueId = 0x100005E1u;
private const uint QuestLocationValueId = 0x100005E2u;
private const uint DescriptionId = 0x100005DEu;
private const uint TimedValueId = 0x100005E3u;
/// <summary>
/// The authored row name/status colour. Deliberately NOT white: the
/// selection highlight paints white, so a fixture that starts white cannot
/// observe the highlight at all.
/// </summary>
private static readonly System.Numerics.Vector4 AuthoredTextColor =
new(0.8f, 0.8f, 0.8f, 1f);
private static UiText Text(uint id) => new()
{
DatElementId = id,
Width = 200f,
Height = 18f,
DefaultColor = AuthoredTextColor,
};
/// <summary>
/// A page carrying the same child ids the real one does, so the controller
/// resolves exactly what production resolves.
/// </summary>
private static (UiElement Page, UiTemplateListBox List) BuildPage()
{
var listInfo = new ElementInfo
{
Id = ListId, Type = 5, Width = 270, Height = 298,
};
var list = new UiTemplateListBox(
listInfo,
static _ => (0u, 0, 0),
[new UiTemplateListEntry(
JournalContractsPageController.RowTemplateLayoutId,
JournalContractsPageController.RowTemplateElementId)],
scrollbarElementId: 0u)
{
// LayoutImporter.Build sets this in production; a directly
// constructed widget has it at 0 and FindDescendant never sees it.
DatElementId = ListId,
};
var page = new UiPanel { Width = 300f, Height = 500f };
page.AddChild(list);
foreach (uint id in new[]
{
StatusValueId, ContactValueId, ContactLocationValueId,
QuestLocationValueId, DescriptionId, TimedValueId,
})
{
page.AddChild(Text(id));
}
return (page, list);
}
/// <summary>
/// One row: a name text and a status text, as retail authors.
/// </summary>
/// <remarks>
/// The root is a <see cref="UiDatElement"/> because that is what the real
/// Type-3 template resolves to through DatWidgetFactory's generic fallback
/// arm — and its click-through default is the entire point of one of the
/// tests below. A UiPanel here would make that test pass vacuously.
/// </remarks>
private static UiElement? RowTemplate(uint layoutId, uint elementId)
{
if (layoutId != JournalContractsPageController.RowTemplateLayoutId
|| elementId != JournalContractsPageController.RowTemplateElementId)
{
return null;
}
var row = new UiDatElement(
new ElementInfo
{
Id = JournalContractsPageController.RowTemplateElementId,
Type = 3,
Width = 270,
Height = 20,
},
static _ => (0u, 0, 0));
row.AddChild(Text(RowNameId));
row.AddChild(Text(RowStatusId));
return row;
}
private static ContractCatalog Catalog(params ContractEntry[] entries)
=> new(entries.ToDictionary(e => e.ContractId));
private static ContractEntry Entry(
uint id,
string name,
string description = "",
string contact = "",
string progressFormat = "",
string repeatFlag = "",
uint contactCell = 0u,
uint questCell = 0u)
=> ContractEntry.Unknown with
{
ContractId = id,
ContractName = name,
Description = description,
NameNpcStart = contact,
DescriptionProgress = progressFormat,
QuestflagRepeatTime = repeatFlag,
LocationNpcStartCell = contactCell,
LocationQuestAreaCell = questCell,
};
private static JournalContractsPageController Bind(
UiElement page,
RuntimeContractState state,
ContractCatalog catalog,
DateTime? now = null)
=> new(page, new JournalContractsPageController.Bindings(
Contracts: state.View,
Catalog: () => catalog,
Now: () => now ?? Now,
TemplateResolver: RowTemplate));
private static void Track(
RuntimeContractState state,
uint contractId,
uint stage,
double whenRepeats = 0d,
double whenDone = 0d,
bool setAsDisplay = false)
=> state.ApplyUpdate(new ContractTrackerUpdate(
new ContractTracker(1u, contractId, (ContractStage)stage, whenDone, whenRepeats, Now),
Delete: false,
SetAsDisplay: setAsDisplay));
private static string TextOf(UiElement page, uint id)
{
var text = UiElement.FindDescendant(page, id) as UiText;
return text?.LinesProvider?.Invoke().FirstOrDefault().Text ?? string.Empty;
}
[Fact]
public void RowsCarryTheAuthoredNameAndTheRetailProgressText()
{
// The wire sends only an id and a stage; the name comes from the dat
// and the status from FillProgressString. Getting either from the
// wrong source is the failure this pins.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
JournalContractsPageController page1 = Bind(
page, state, Catalog(Entry(0x10u, "Aerlinthe Recall Ring")));
Assert.Equal(new[] { 0x10u }, page1.RowContractIds.ToArray());
UiText name = Assert.IsType<UiText>(Assert.Single(
UiElement.FindDescendant(page, ListId)!.Children.SelectMany(Flatten),
e => (e as UiText)?.DatElementId == RowNameId));
Assert.Equal("Aerlinthe Recall Ring", name.LinesProvider!()[0].Text);
}
private static IEnumerable<UiElement> Flatten(UiElement e)
{
yield return e;
foreach (UiElement child in e.Children)
{
foreach (UiElement descendant in Flatten(child))
yield return descendant;
}
}
[Fact]
public void TheDetailPaneShowsTheSelectedContract()
{
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
Track(state, 0x20u, stage: 1u);
JournalContractsPageController controller = Bind(page, state, Catalog(
Entry(0x10u, "First", description: "Kill the thing.", contact: "Bob"),
Entry(0x20u, "Second", description: "Find the other thing.", contact: "Alice")));
controller.Select(0x20u);
Assert.Equal("Find the other thing.", TextOf(page, DescriptionId));
Assert.Equal("Alice", TextOf(page, ContactValueId));
Assert.Equal("Available", TextOf(page, StatusValueId));
}
[Fact]
public void TheServersDisplayContractIsWhatOpensSelected()
{
// SetAsDisplayContract is the server nominating what to show. Ignoring
// it and always selecting the first row would show the wrong quest
// right after the one the player just accepted.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
Track(state, 0x20u, stage: 2u, setAsDisplay: true);
JournalContractsPageController controller = Bind(page, state, Catalog(
Entry(0x10u, "First"), Entry(0x20u, "Second")));
Assert.Equal(0x20u, controller.SelectedContractId);
}
[Fact]
public void WithNoDisplayContractTheFirstRowStands()
{
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x30u, stage: 2u);
Track(state, 0x10u, stage: 2u);
JournalContractsPageController controller = Bind(page, state, Catalog(
Entry(0x10u, "First"), Entry(0x30u, "Third")));
// GetContracts orders by id, so 0x10 is first.
Assert.Equal(0x10u, controller.SelectedContractId);
}
[Fact]
public void AnEmptyTrackerClearsTheDetailPaneRatherThanStrandingText()
{
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
JournalContractsPageController controller = Bind(
page, state, Catalog(Entry(0x10u, "First", description: "Kill it.")));
Assert.Equal("Kill it.", TextOf(page, DescriptionId));
state.ApplyTable(new Dictionary<uint, ContractTracker>());
controller.Tick();
Assert.Equal(0u, controller.SelectedContractId);
Assert.Equal(string.Empty, TextOf(page, DescriptionId));
Assert.Empty(controller.RowContractIds);
}
[Fact]
public void TheListRebuildsOnlyWhenTheTrackerActuallyMoved()
{
// Tick runs every frame while the tracker changes rarely. A rebuild
// per frame would re-Build a template row list continuously and reset
// the player's scroll under them.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
int builds = 0;
var controller = new JournalContractsPageController(
page,
new JournalContractsPageController.Bindings(
Contracts: state.View,
Catalog: () => Catalog(Entry(0x10u, "First")),
Now: () => Now,
TemplateResolver: (l, e) => { builds++; return RowTemplate(l, e); }));
Track(state, 0x10u, stage: 2u);
controller.Tick();
int afterFirstChange = builds;
for (int frame = 0; frame < 10; frame++)
controller.Tick();
Assert.Equal(afterFirstChange, builds);
}
[Fact]
public void AContractTheDatHasNeverHeardOfStillGetsARow()
{
// The server can track a contract this dat build does not carry. The
// row must still appear — dropping it would hide a live quest.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0xDEADu, stage: 2u);
JournalContractsPageController controller =
Bind(page, state, ContractCatalog.Empty);
Assert.Equal(new[] { 0xDEADu }, controller.RowContractIds.ToArray());
Assert.Equal("In Progress", TextOf(page, StatusValueId));
}
[Fact]
public void ALocationWithNoOutdoorCoordinatesReadsAsIndoors()
{
// Retail's literal string when LandDefs::gid_to_lcoord fails
// (@0x0049937F). An indoor cell must not render as blank or as raw
// numbers.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
Bind(page, state, Catalog(Entry(0x10u, "First", contactCell: 0x01020304u)));
Assert.Equal("Indoors", TextOf(page, ContactLocationValueId));
}
[Fact]
public void AnUnsetLocationIsBlankRatherThanIndoors()
{
// A contract that authors no location at all is different from one
// whose location is inside a dungeon.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
Bind(page, state, Catalog(Entry(0x10u, "First")));
Assert.Equal(string.Empty, TextOf(page, QuestLocationValueId));
}
[Fact]
public void TheTimedRowUsesTimeWhenDoneNotTimeWhenRepeats()
{
// The two wire timers mean different things and land in different
// places: TimeWhenRepeats drives the Status column, TimeWhenDone this
// row. Swapping them shows a plausible-looking wrong number.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u, whenDone: 3661d, whenRepeats: 90d);
Bind(page, state, Catalog(Entry(0x10u, "First")));
Assert.Equal("1h 1m 1s", TextOf(page, TimedValueId));
}
[Fact]
public void TheRepeatCountdownTicksWithoutRebuildingTheList()
{
// Nothing on the wire changes while a cooldown runs down, so a
// revision-gated rebuild alone would freeze the timer on screen.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 3u, whenRepeats: 600d);
DateTime now = Now;
var controller = new JournalContractsPageController(
page,
new JournalContractsPageController.Bindings(
Contracts: state.View,
Catalog: () => Catalog(Entry(0x10u, "First", repeatFlag: "f")),
Now: () => now,
TemplateResolver: RowTemplate));
Assert.Equal("Done (10m 0s to Repeat)", TextOf(page, StatusValueId));
now = Now.AddMinutes(5);
controller.Tick();
Assert.Equal("Done (5m 0s to Repeat)", TextOf(page, StatusValueId));
}
[Fact]
public void RowsOptOutOfClickThroughOrTheListIsDead()
{
// The authored row template is a Type-3 generic container, and those
// are click-THROUGH by default — the click sails past the row to
// whatever is behind it and the handler never runs. Binding OnClick
// without clearing ClickThrough compiles, looks right, and produces a
// list you cannot select anything in.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
Bind(page, state, Catalog(Entry(0x10u, "First")));
UiDatElement row = Assert.IsAssignableFrom<UiDatElement>(Assert.Single(
UiElement.FindDescendant(page, ListId)!.Children.SelectMany(Flatten),
e => e is UiDatElement { OnClick: not null }));
Assert.False(row.ClickThrough);
}
[Fact]
public void ClickingARowSelectsIt()
{
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
Track(state, 0x20u, stage: 2u);
JournalContractsPageController controller = Bind(page, state, Catalog(
Entry(0x10u, "First"), Entry(0x20u, "Second", description: "Second desc.")));
Assert.Equal(0x10u, controller.SelectedContractId);
UiDatElement second = Assert.IsAssignableFrom<UiDatElement>(
UiElement.FindDescendant(page, ListId)!.Children
.SelectMany(Flatten)
.Where(e => e is UiDatElement { OnClick: not null })
.ElementAt(1));
second.OnClick!();
Assert.Equal(0x20u, controller.SelectedContractId);
Assert.Equal("Second desc.", TextOf(page, DescriptionId));
}
[Fact]
public void TheSelectedRowIsHighlightedAndTheOthersAreNot()
{
// Without this the player cannot tell which row the detail pane is
// describing.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
Track(state, 0x20u, stage: 2u);
JournalContractsPageController controller = Bind(page, state, Catalog(
Entry(0x10u, "First"), Entry(0x20u, "Second")));
UiText[] names = UiElement.FindDescendant(page, ListId)!.Children
.SelectMany(Flatten)
.OfType<UiText>()
.Where(t => t.DatElementId == RowNameId)
.ToArray();
Assert.Equal(2, names.Length);
// Row 0 opens selected (no display contract, so the first row stands).
System.Numerics.Vector4 selectedColor = names[0].DefaultColor;
System.Numerics.Vector4 unselectedColor = names[1].DefaultColor;
Assert.NotEqual(unselectedColor, selectedColor);
controller.Select(0x20u);
// The highlight MOVES: the old row goes back to its authored colour
// rather than both rows ending up lit.
Assert.Equal(unselectedColor, names[0].DefaultColor);
Assert.Equal(selectedColor, names[1].DefaultColor);
}
[Fact]
public void TheHighlightSurvivesARebuild()
{
// A rebuild discards the row objects, so a remembered selection has to
// be re-PAINTED onto the new ones rather than merely kept.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
Track(state, 0x20u, stage: 2u);
JournalContractsPageController controller = Bind(page, state, Catalog(
Entry(0x10u, "First"), Entry(0x20u, "Second")));
controller.Select(0x20u);
Track(state, 0x30u, stage: 2u); // forces a rebuild
controller.Tick();
Assert.Equal(0x20u, controller.SelectedContractId);
UiText selected = UiElement.FindDescendant(page, ListId)!.Children
.SelectMany(Flatten)
.OfType<UiText>()
.Where(t => t.DatElementId == RowNameId)
.ElementAt(1);
UiText unselected = UiElement.FindDescendant(page, ListId)!.Children
.SelectMany(Flatten)
.OfType<UiText>()
.Where(t => t.DatElementId == RowNameId)
.ElementAt(0);
Assert.NotEqual(unselected.DefaultColor, selected.DefaultColor);
}
// ── Abandon (game action 0x0316) ────────────────────────────────────
[Fact]
public void AbandonSendsTheSelectedContractAndRemovesNothingLocally()
{
// The row disappears when the SERVER answers with its own 0x0315
// delete. Removing it optimistically would vanish a quest the server
// refused to drop, and it would reappear on the next full table.
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 2u);
var abandoned = new List<uint>();
var controller = new JournalContractsPageController(
page,
new JournalContractsPageController.Bindings(
Contracts: state.View,
Catalog: () => Catalog(Entry(0x10u, "First")),
Now: () => Now,
TemplateResolver: RowTemplate,
Abandon: abandoned.Add));
controller.AbandonSelected();
Assert.Equal(new[] { 0x10u }, abandoned.ToArray());
Assert.Equal(1, state.View.Snapshot.ContractCount);
}
[Fact]
public void AbandonWithNothingSelectedSendsNothing()
{
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
var abandoned = new List<uint>();
var controller = new JournalContractsPageController(
page,
new JournalContractsPageController.Bindings(
Contracts: state.View,
Catalog: () => ContractCatalog.Empty,
Now: () => Now,
TemplateResolver: RowTemplate,
Abandon: abandoned.Add));
controller.AbandonSelected();
Assert.Empty(abandoned);
}
[Fact]
public void AProgressCounterRendersThroughTheAuthoredFormat()
{
(UiElement page, _) = BuildPage();
using var state = new RuntimeContractState();
Track(state, 0x10u, stage: 9u); // ProgressCounter + 5
Bind(page, state, Catalog(
Entry(0x10u, "First", progressFormat: "%d/20 Tuskers")));
Assert.Equal("5/20 Tuskers", TextOf(page, StatusValueId));
}
}

View file

@ -0,0 +1,296 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Journal;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign QJ slice QJ3: the journal's notes page.
/// </summary>
public sealed class JournalNotesPageControllerTests
{
private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
private const uint PreviousButtonId = 0x10000565u;
private const uint NextButtonId = 0x10000566u;
private const uint NewButtonId = 0x10000567u;
private const uint LabelFieldId = 0x10000569u;
private const uint TitleFieldId = 0x1000056Bu;
private const uint NotesFieldId = 0x1000056Du;
private const uint FirstButtonId = 0x1000056Fu;
private const uint PageNumberId = 0x10000570u;
private const uint LastButtonId = 0x10000571u;
private const uint LocationFieldId = 0x10000573u;
private const uint RecordButtonId = 0x10000574u;
private const uint TimerDaysFieldId = 0x10000576u;
private const uint TimerHoursFieldId = 0x10000578u;
private const uint TimerMinutesFieldId = 0x1000057Au;
private const uint TimerDaysLabelId = 0x10000577u;
private const uint TimerHoursLabelId = 0x10000579u;
private const uint TimerMinutesLabelId = 0x1000057Bu;
private const uint RunningTimerTextId = 0x1000057Cu;
private const uint StartButtonId = 0x1000057Du;
/// <summary>An outdoor landcell, so Record has coordinates to stamp.</summary>
private const uint OutdoorCell = 0xA9B4001Fu;
private static UiField Field(uint id) => new() { ElementId = id, DatElementId = id, Width = 100f, Height = 18f };
private static UiText Text(uint id) => new() { DatElementId = id, Width = 120f, Height = 18f };
private static UiButton Button(uint id) => new(
new ElementInfo { Id = id, Type = 1, Width = 60, Height = 20 },
static _ => (0u, 0, 0))
{
DatElementId = id,
};
/// <summary>
/// The page, with each child built as the type the REAL layout produces —
/// the location is a field, not a text element.
/// </summary>
private static UiElement BuildPage()
{
var page = new UiPanel { Width = 300f, Height = 500f };
foreach (uint id in new[]
{
LabelFieldId, TitleFieldId, NotesFieldId, LocationFieldId,
TimerDaysFieldId, TimerHoursFieldId, TimerMinutesFieldId,
})
{
page.AddChild(Field(id));
}
page.AddChild(Text(PageNumberId));
page.AddChild(Text(RunningTimerTextId));
page.AddChild(Text(TimerDaysLabelId));
page.AddChild(Text(TimerHoursLabelId));
page.AddChild(Text(TimerMinutesLabelId));
foreach (uint id in new[]
{
PreviousButtonId, NextButtonId, NewButtonId,
FirstButtonId, LastButtonId, RecordButtonId, StartButtonId,
})
{
page.AddChild(Button(id));
}
return page;
}
private static (JournalNotesPageController Controller, RuntimeJournalState State,
UiElement Page) Bind(Func<DateTime>? now = null, params JournalPage[] pages)
{
var state = new RuntimeJournalState();
state.Load(pages);
UiElement page = BuildPage();
var controller = new JournalNotesPageController(
page,
new JournalNotesPageController.Bindings(
Journal: state.View,
Commands: state,
PlayerCell: () => OutdoorCell,
Now: now ?? (() => Now)));
return (controller, state, page);
}
private static void Click(UiElement page, uint id)
=> (UiElement.FindDescendant(page, id) as UiButton)!.OnClick!();
private static UiField FieldOf(UiElement page, uint id)
=> (UiField)UiElement.FindDescendant(page, id)!;
private static string TextOf(UiElement page, uint id)
{
var text = UiElement.FindDescendant(page, id) as UiText;
return text?.LinesProvider?.Invoke().FirstOrDefault().Text ?? string.Empty;
}
// ── Record ──────────────────────────────────────────────────────────
[Fact]
public void RecordPutsTheLocationOnSCREENAndNotJustInTheModel()
{
// The readout is authored EDITABLE, so it builds as a UiField. Binding
// it as UiText yielded null and threw the write away: the value
// reached the model and the file, and the player saw nothing.
var (_, state, page) = Bind(pages: new JournalPage());
Click(page, RecordButtonId);
Assert.True(state.View.Current.HasLocation);
Assert.NotEqual(string.Empty, FieldOf(page, LocationFieldId).Text);
state.Dispose();
}
[Fact]
public void RecordOnAnEmptyJournalDoesNothing()
{
// No page to record onto. Retail's own guard.
var (_, state, page) = Bind();
Click(page, RecordButtonId);
Assert.Equal(string.Empty, FieldOf(page, LocationFieldId).Text);
state.Dispose();
}
// ── the timer ───────────────────────────────────────────────────────
[Fact]
public void StartingATimerSwapsTheFieldsForTheRunningReadout()
{
// The readout is authored at the SAME x as the three number boxes, so
// both visible at once overlaps illegibly.
var (_, state, page) = Bind(pages: new JournalPage());
FieldOf(page, TimerHoursFieldId).SetText("1");
Click(page, StartButtonId);
Assert.False(FieldOf(page, TimerDaysFieldId).Visible);
Assert.False(FieldOf(page, TimerHoursFieldId).Visible);
Assert.False(FieldOf(page, TimerMinutesFieldId).Visible);
Assert.True(UiElement.FindDescendant(page, RunningTimerTextId)!.Visible);
Assert.Equal("1h 0s", TextOf(page, RunningTimerTextId));
// The unit labels go with their boxes. Leaving them drew "d h m"
// through the readout: "1d 2h 51sh m".
Assert.False(UiElement.FindDescendant(page, TimerDaysLabelId)!.Visible);
Assert.False(UiElement.FindDescendant(page, TimerHoursLabelId)!.Visible);
Assert.False(UiElement.FindDescendant(page, TimerMinutesLabelId)!.Visible);
state.Dispose();
}
[Fact]
public void StartWithNoDurationEnteredDoesNothing()
{
// Empty boxes mean no countdown; the strip must stay editable rather
// than swapping to a readout of nothing.
var (_, state, page) = Bind(pages: new JournalPage());
Click(page, StartButtonId);
Assert.True(FieldOf(page, TimerDaysFieldId).Visible);
Assert.Equal(string.Empty, TextOf(page, RunningTimerTextId));
state.Dispose();
}
[Fact]
public void TheRunningTimerCountsDownOnTick()
{
DateTime now = Now;
var (controller, state, page) = Bind(now: () => now, pages: new JournalPage());
FieldOf(page, TimerHoursFieldId).SetText("1");
Click(page, StartButtonId);
Assert.Equal("1h 0s", TextOf(page, RunningTimerTextId));
now = Now.AddMinutes(30);
controller.Tick();
Assert.Equal("30m 0s", TextOf(page, RunningTimerTextId));
state.Dispose();
}
[Fact]
public void PressingStartAgainStopsTheCountdownAndRestoresTheFields()
{
var (_, state, page) = Bind(pages: new JournalPage());
FieldOf(page, TimerHoursFieldId).SetText("1");
Click(page, StartButtonId);
Click(page, StartButtonId);
Assert.True(FieldOf(page, TimerDaysFieldId).Visible);
Assert.True(UiElement.FindDescendant(page, TimerDaysLabelId)!.Visible);
Assert.Equal(string.Empty, TextOf(page, RunningTimerTextId));
state.Dispose();
}
// ── navigation ──────────────────────────────────────────────────────
[Fact]
public void NewAppendsAPageAndShowsIt()
{
var (_, state, page) = Bind();
Click(page, NewButtonId);
Assert.Single(state.View.Pages);
Assert.Equal("~ 1 ~", TextOf(page, PageNumberId));
state.Dispose();
}
[Fact]
public void EveryNavigationCommitsTheCurrentPageFirst()
{
// Retail calls SaveThisPage on the way out of all five navigation
// buttons. Without it, typing and then paging away eats the edit.
var (_, state, page) = Bind(pages: new[] { new JournalPage(), new JournalPage() });
FieldOf(page, TitleFieldId).SetText("typed but not committed");
Click(page, NextButtonId);
Assert.Equal("typed but not committed", state.View.Pages[0].Title);
state.Dispose();
}
[Fact]
public void FirstAndLastJumpToTheEnds()
{
var (_, state, page) = Bind(
pages: new[] { new JournalPage(), new JournalPage(), new JournalPage() });
Click(page, LastButtonId);
Assert.Equal("~ 3 ~", TextOf(page, PageNumberId));
Click(page, FirstButtonId);
Assert.Equal("~ 1 ~", TextOf(page, PageNumberId));
state.Dispose();
}
[Fact]
public void PreviousAndNextStopAtTheEndsRatherThanWrapping()
{
var (_, state, page) = Bind(pages: new[] { new JournalPage(), new JournalPage() });
Click(page, PreviousButtonId); // already on page 1
Assert.Equal("~ 1 ~", TextOf(page, PageNumberId));
Click(page, NextButtonId);
Click(page, NextButtonId); // already on the last
Assert.Equal("~ 2 ~", TextOf(page, PageNumberId));
state.Dispose();
}
[Fact]
public void AnEmptyJournalShowsNoPageNumber()
{
// "~ 0 ~" would name a page that does not exist.
var (_, state, page) = Bind();
Assert.Equal(string.Empty, TextOf(page, PageNumberId));
state.Dispose();
}
[Fact]
public void SwitchingPagesShowsThatPagesText()
{
var (_, state, page) = Bind(pages: new[]
{
new JournalPage(Title: "first", Notes: "one"),
new JournalPage(Title: "second", Notes: "two"),
});
Click(page, NextButtonId);
Assert.Equal("second", FieldOf(page, TitleFieldId).Text);
Assert.Equal("two", FieldOf(page, NotesFieldId).Text);
state.Dispose();
}
}

View file

@ -0,0 +1,305 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Journal;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign QJ slice QJ4: the journal's searchable index
/// (<c>gmPageListUI</c>).
/// </summary>
public sealed class JournalPageListControllerTests
{
private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
private const uint ListId = 0x10000583u;
private const uint SearchFieldId = 0x10000587u;
private const uint DeleteButtonId = 0x10000585u;
private const uint RowNumberId = 0x1000058Au;
private const uint RowTitleId = 0x1000058Bu;
// ── the search predicate, ported from PageContainsString ────────────
[Fact]
public void SearchMatchesLabelTitleOrNotes()
{
var page = new JournalPage(Label: "lab", Title: "tit", Notes: "not");
Assert.True(JournalPageListController.PageContainsString(page, "lab"));
Assert.True(JournalPageListController.PageContainsString(page, "tit"));
Assert.True(JournalPageListController.PageContainsString(page, "not"));
Assert.False(JournalPageListController.PageContainsString(page, "zzz"));
}
[Fact]
public void SearchIsCaseSensitiveBecauseRetailUsesWcsstr()
{
// Making it insensitive would be friendlier and would be a divergence.
var page = new JournalPage(Title: "Aerlinthe");
Assert.True(JournalPageListController.PageContainsString(page, "Aer"));
Assert.False(JournalPageListController.PageContainsString(page, "aer"));
}
[Fact]
public void AnEmptySearchMatchesEverything()
{
Assert.True(JournalPageListController.PageContainsString(
new JournalPage(), string.Empty));
}
// ── the list ────────────────────────────────────────────────────────
private static UiText Text(uint id) => new()
{
DatElementId = id,
Width = 90f,
Height = 20f,
DefaultColor = new System.Numerics.Vector4(0.8f, 0.8f, 0.8f, 1f),
};
private static UiElement? RowTemplate(uint layoutId, uint elementId)
{
if (layoutId != JournalPageListController.RowTemplateLayoutId
|| elementId != JournalPageListController.RowTemplateElementId)
{
return null;
}
// A UiDatElement, as the real Type-3 template resolves to.
var row = new UiDatElement(
new ElementInfo { Type = 3, Width = 270, Height = 20 },
static _ => (0u, 0, 0));
row.AddChild(Text(RowNumberId));
row.AddChild(Text(RowTitleId));
return row;
}
private static (UiElement Page, UiField Search) BuildPage()
{
var list = new UiTemplateListBox(
new ElementInfo { Id = ListId, Type = 5, Width = 270, Height = 430 },
static _ => (0u, 0, 0),
[new UiTemplateListEntry(
JournalPageListController.RowTemplateLayoutId,
JournalPageListController.RowTemplateElementId)],
scrollbarElementId: 0u)
{
DatElementId = ListId,
};
var search = new UiField { ElementId = SearchFieldId, Width = 118f, Height = 18f };
search.DatElementId = SearchFieldId;
var deleteButton = new UiButton(
new ElementInfo { Id = DeleteButtonId, Type = 1, Width = 60, Height = 18 },
static _ => (0u, 0, 0))
{
DatElementId = DeleteButtonId,
};
var page = new UiPanel { Width = 300f, Height = 500f };
page.AddChild(list);
page.AddChild(search);
page.AddChild(deleteButton);
return (page, search);
}
private static (JournalPageListController Controller, RuntimeJournalState State,
UiElement Page, UiField Search, List<int> Opened) Bind(params JournalPage[] pages)
{
var state = new RuntimeJournalState();
state.Load(pages);
(UiElement page, UiField search) = BuildPage();
var opened = new List<int>();
var controller = new JournalPageListController(
page,
new JournalPageListController.Bindings(
Journal: state.View,
Commands: state,
OpenPage: opened.Add,
TemplateResolver: RowTemplate,
Now: () => Now));
return (controller, state, page, search, opened);
}
[Fact]
public void EveryPageIsListedWithItsNumber()
{
var (controller, state, page, _, _) = Bind(
new JournalPage(Title: "one"), new JournalPage(Title: "two"));
Assert.Equal(new[] { 1, 2 }, controller.RowPages.ToArray());
string[] numbers = Flatten(page)
.OfType<UiText>()
.Where(t => t.DatElementId == RowNumberId)
.Select(t => t.LinesProvider!()[0].Text)
.ToArray();
Assert.Equal(new[] { "1", "2" }, numbers);
state.Dispose();
}
[Fact]
public void SearchingFiltersTheListButKeepsRealPageNumbers()
{
// The row number must name the page in the JOURNAL, not its position
// in the filtered list — otherwise opening row 1 of a filtered list
// opens the wrong page.
var (controller, state, _, search, _) = Bind(
new JournalPage(Title: "alpha"),
new JournalPage(Title: "beta"),
new JournalPage(Title: "gamma"));
search.SetText("beta");
controller.Tick();
Assert.Equal(new[] { 2 }, controller.RowPages.ToArray());
state.Dispose();
}
[Fact]
public void AFilteredOutSelectionIsDroppedSoDeleteCannotHitAHiddenPage()
{
var (controller, state, _, search, _) = Bind(
new JournalPage(Title: "alpha"), new JournalPage(Title: "beta"));
controller.Select(1);
search.SetText("beta");
controller.Tick();
Assert.Equal(0, controller.SelectedPage);
state.Dispose();
}
[Fact]
public void DeleteWithNothingSelectedDoesNothing()
{
var (_, state, page, _, _) = Bind(new JournalPage(Title: "only"));
(UiElement.FindDescendant(page, DeleteButtonId) as UiButton)!.OnClick!();
Assert.Single(state.View.Pages);
state.Dispose();
}
[Fact]
public void DeleteRemovesTheSelectedPage()
{
var (controller, state, page, _, _) = Bind(
new JournalPage(Title: "one"), new JournalPage(Title: "two"));
controller.Select(1);
(UiElement.FindDescendant(page, DeleteButtonId) as UiButton)!.OnClick!();
Assert.Equal("two", Assert.Single(state.View.Pages).Title);
Assert.Equal(0, controller.SelectedPage);
state.Dispose();
}
// ── CheckForDoubleClick ─────────────────────────────────────────────
[Fact]
public void OneClickSelectsAndDoesNotOpen()
{
var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one"));
controller.Click(1);
Assert.Equal(1, controller.SelectedPage);
Assert.Empty(opened);
state.Dispose();
}
[Fact]
public void TwoClicksOnTheSameRowOpenIt()
{
var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one"));
controller.Click(1);
controller.Click(1);
Assert.Equal(new[] { 1 }, opened.ToArray());
state.Dispose();
}
[Fact]
public void AThirdClickDoesNotReopenBecauseFiringResetsTheTracker()
{
// Retail clears m_LastClickIndex on a successful double-click
// (@0x0049318A). Without that, every click after the second re-opens.
var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one"));
controller.Click(1);
controller.Click(1);
controller.Click(1);
Assert.Single(opened);
state.Dispose();
}
[Fact]
public void ClicksOnDifferentRowsAreNotADoubleClick()
{
var (controller, state, _, _, opened) = Bind(
new JournalPage(Title: "one"), new JournalPage(Title: "two"));
controller.Click(1);
controller.Click(2);
Assert.Empty(opened);
Assert.Equal(2, controller.SelectedPage);
state.Dispose();
}
[Fact]
public void TheDoubleClickWindowIsAFullSecond()
{
// Retail's is m_LastClickTime + 1.0 (@0x00493158) — NOT the 500 ms the
// item-interaction path uses. Borrowing the wrong constant makes the
// list feel unresponsive.
var state = new RuntimeJournalState();
state.Load([new JournalPage(Title: "one")]);
(UiElement page, _) = BuildPage();
var opened = new List<int>();
DateTime now = Now;
var controller = new JournalPageListController(
page,
new JournalPageListController.Bindings(
Journal: state.View,
Commands: state,
OpenPage: opened.Add,
TemplateResolver: RowTemplate,
Now: () => now));
controller.Click(1);
now = Now.AddMilliseconds(900);
controller.Click(1);
Assert.Single(opened);
opened.Clear();
now = Now.AddSeconds(10);
controller.Click(1);
now = now.AddMilliseconds(1100);
controller.Click(1);
Assert.Empty(opened);
state.Dispose();
}
private static IEnumerable<UiElement> Flatten(UiElement e)
{
yield return e;
foreach (UiElement child in e.Children)
{
foreach (UiElement descendant in Flatten(child))
yield return descendant;
}
}
}

View file

@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Every element the Journal panel's controllers bind must build as the widget
/// type they cast it to.
/// </summary>
/// <remarks>
/// <para>
/// A controller resolves children with <c>FindDescendant(...) as UiText</c>.
/// When the authored element is editable (<c>0x16</c>) it builds as a
/// <see cref="UiField"/> instead, the cast yields null, and every later write
/// is silently discarded — the value reaches the model and the file, and never
/// appears on screen.
/// </para>
/// <para>
/// That is exactly what happened to the location readout, which is authored
/// EDITABLE and was being resolved as text. This sweep covers the whole panel
/// so the next one fails here instead of in front of a player.
/// </para>
/// </remarks>
[Trait("Lane", "InstalledDat")]
public sealed class JournalPanelBoundWidgetTypesTests
{
private static readonly (uint Id, Type Expected, string Name)[] Bound =
[
// Contracts page — all read-only.
(0x100005CFu, typeof(UiTemplateListBox), "contracts list"),
(0x100005DFu, typeof(UiText), "contract status value"),
(0x100005E0u, typeof(UiText), "contract contact"),
(0x100005E1u, typeof(UiText), "contract contact location"),
(0x100005E2u, typeof(UiText), "contract quest location"),
(0x100005DEu, typeof(UiText), "contract description"),
(0x100005E3u, typeof(UiText), "contract timed value"),
// Notes page.
(0x10000569u, typeof(UiField), "journal label field"),
(0x1000056Bu, typeof(UiField), "journal title field"),
(0x1000056Du, typeof(UiField), "journal notes field"),
(0x10000570u, typeof(UiText), "journal page number"),
(0x10000573u, typeof(UiField), "journal location readout (AUTHORED EDITABLE)"),
(0x10000576u, typeof(UiField), "journal timer days"),
(0x10000578u, typeof(UiField), "journal timer hours"),
(0x1000057Au, typeof(UiField), "journal timer minutes"),
(0x1000057Cu, typeof(UiText), "journal running timer readout"),
// Page list.
(0x10000583u, typeof(UiTemplateListBox), "page list"),
(0x10000587u, typeof(UiField), "page list search box"),
];
private static ImportedLayout BuildPanel()
{
string? datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (string.IsNullOrWhiteSpace(datDir) || !Directory.Exists(datDir))
{
datDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
}
if (!Directory.Exists(datDir))
{
Assert.Fail(
"Lane=InstalledDat requires an installed retail DAT directory; "
+ "see docs/release-gate.md.");
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ElementInfo? root = LayoutImporter.ImportInfos(
adapter,
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
Assert.NotNull(root);
var strings = new DatStringResolver(adapter);
return LayoutImporter.Build(root!, _ => (0u, 0, 0), null, _ => null, strings.Resolve);
}
[Fact]
public void EveryBoundElementBuildsAsTheWidgetItsControllerCastsItTo()
{
ImportedLayout layout = BuildPanel();
var wrong = new List<string>();
foreach ((uint id, Type expected, string name) in Bound)
{
UiElement? element = layout.FindElement(id);
if (element is null)
{
wrong.Add($"{name} (0x{id:X8}) is missing from the layout");
continue;
}
if (!expected.IsInstanceOfType(element))
{
wrong.Add(
$"{name} (0x{id:X8}) built as {element.GetType().Name}, "
+ $"controller expects {expected.Name}");
}
}
Assert.Empty(wrong);
}
}

View file

@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// The Journal panel's buttons must be clickable when built from the REAL
/// authored layout.
/// </summary>
/// <remarks>
/// <para>
/// Every button on this panel authors property <c>0x0D</c>, which was read as
/// "starts disabled" — so all of them built visible and dead. The hermetic
/// UiButton tests could not catch it because they construct their own
/// ElementInfo; only the installed layout carries the property.
/// </para>
/// <para>
/// This asserts the two things the player actually experiences: the button is
/// enabled, and a click at its centre reaches IT rather than falling through to
/// whatever is behind.
/// </para>
/// </remarks>
[Trait("Lane", "InstalledDat")]
public sealed class JournalPanelButtonsAreClickableTests
{
private static readonly (uint Id, string Name)[] Buttons =
[
(0x100005DCu, "Abandon (contracts)"),
(0x10000567u, "New"),
(0x1000056Fu, "First"),
(0x10000571u, "Last"),
(0x10000565u, "Previous"),
(0x10000566u, "Next"),
(0x10000574u, "Record"),
(0x1000057Du, "Start"),
(0x10000585u, "Delete (page list)"),
(0x10000588u, "Reset (page list)"),
];
private static ImportedLayout BuildPanel()
{
string? datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (string.IsNullOrWhiteSpace(datDir) || !Directory.Exists(datDir))
{
datDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
}
if (!Directory.Exists(datDir))
{
Assert.Fail(
"Lane=InstalledDat requires an installed retail DAT directory; "
+ "see docs/release-gate.md.");
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ElementInfo? root = LayoutImporter.ImportInfos(
adapter,
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
Assert.NotNull(root);
var strings = new DatStringResolver(adapter);
return LayoutImporter.Build(root!, _ => (0u, 0, 0), null, _ => null, strings.Resolve);
}
[Fact]
public void EveryJournalPanelButtonBuildsEnabled()
{
ImportedLayout layout = BuildPanel();
var dead = new List<string>();
foreach ((uint id, string name) in Buttons)
{
if (layout.FindElement(id) is not UiButton button)
{
dead.Add($"{name} (0x{id:X8}) did not build as a UiButton");
continue;
}
if (!button.Enabled)
dead.Add($"{name} (0x{id:X8}) built DISABLED");
}
Assert.Empty(dead);
}
[Fact]
public void EveryJournalPanelButtonIsReachableByAClickAtItsCentre()
{
// Enabled alone is not enough — the click has to actually land on it
// rather than pass through to something behind.
ImportedLayout layout = BuildPanel();
var unreachable = new List<string>();
foreach ((uint id, string name) in Buttons)
{
if (layout.FindElement(id) is not UiButton button)
continue;
UiElement? hit = HitTestSelf(button);
if (!ReferenceEquals(hit, button))
unreachable.Add($"{name} (0x{id:X8}) hit-tested to {hit?.GetType().Name ?? "nothing"}");
}
Assert.Empty(unreachable);
}
/// <summary>Hit-tests a button at its own centre, in its own local space.</summary>
private static UiElement? HitTestSelf(UiButton button)
{
var hitTest = typeof(UiElement).GetMethod(
"HitTest",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
return hitTest.Invoke(
button,
[button.Width / 2f, button.Height / 2f]) as UiElement;
}
}

View file

@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using AcDream.Core.Journal;
using AcDream.Runtime.Gameplay;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// The controllers must actually WIRE the real panel.
/// </summary>
/// <remarks>
/// Everything else so far tested the layout (do the widgets build right?) or
/// the controller against a hand-built page (does the logic work?). Neither
/// proves the controller finds its elements in the REAL tree — which is the
/// one step where an id typo or a subtree assumption silently produces a panel
/// where nothing responds.
/// </remarks>
[Trait("Lane", "InstalledDat")]
public sealed class JournalPanelLiveBindTests
{
private const uint OutdoorCell = 0xA9B4001Fu;
private static ImportedLayout BuildPanel()
{
string? datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (string.IsNullOrWhiteSpace(datDir) || !Directory.Exists(datDir))
{
datDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
}
if (!Directory.Exists(datDir))
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ElementInfo? root = LayoutImporter.ImportInfos(
adapter,
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
Assert.NotNull(root);
var strings = new DatStringResolver(adapter);
return LayoutImporter.Build(root!, _ => (0u, 0, 0), null, _ => null, strings.Resolve);
}
private static (JournalNotesPageController Controller, RuntimeJournalState State,
UiElement Page) BindNotes(ImportedLayout layout, Func<uint>? playerCell = null)
{
UiElement? page = layout.FindElement(JournalPanelController.NotesPageId);
Assert.NotNull(page);
var state = new RuntimeJournalState();
state.Load([new JournalPage()]);
var controller = new JournalNotesPageController(
page!,
new JournalNotesPageController.Bindings(
Journal: state.View,
Commands: state,
PlayerCell: playerCell ?? (() => OutdoorCell),
Now: () => new DateTime(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc)));
return (controller, state, page!);
}
[Fact]
public void TheNotesPageWiresEveryButtonItOwns()
{
// If FindDescendant misses one — wrong id, or the element is not in the
// subtree the controller searches — that button silently does nothing
// and nothing else fails.
ImportedLayout layout = BuildPanel();
(_, RuntimeJournalState state, UiElement page) = BindNotes(layout);
var unwired = new List<string>();
foreach ((uint id, string name) in new[]
{
(0x10000565u, "Previous"), (0x10000566u, "Next"), (0x10000567u, "New"),
(0x1000056Fu, "First"), (0x10000571u, "Last"),
(0x10000574u, "Record"), (0x1000057Du, "Start"),
})
{
if (UiElement.FindDescendant(page, id) is not UiButton button)
unwired.Add($"{name} (0x{id:X8}) not found under the notes page");
else if (button.OnClick is null)
unwired.Add($"{name} (0x{id:X8}) has no OnClick");
}
Assert.Empty(unwired);
state.Dispose();
}
[Fact]
public void RecordOnTheRealPageStampsAndDisplaysTheLocation()
{
ImportedLayout layout = BuildPanel();
(_, RuntimeJournalState state, UiElement page) = BindNotes(layout);
(UiElement.FindDescendant(page, 0x10000574u) as UiButton)!.OnClick!();
Assert.True(state.View.Current.HasLocation);
var readout = UiElement.FindDescendant(page, 0x10000573u) as UiField;
Assert.NotNull(readout);
Assert.NotEqual(string.Empty, readout!.Text);
state.Dispose();
}
[Fact]
public void RecordIndoorsDoesNothingBecauseThereAreNoCoordinates()
{
// retail's own gid_to_lcoord failure. Worth pinning because it looks
// identical to a broken button from the player's side.
ImportedLayout layout = BuildPanel();
(_, RuntimeJournalState state, UiElement page) =
BindNotes(layout, playerCell: () => 0x01020304u);
(UiElement.FindDescendant(page, 0x10000574u) as UiButton)!.OnClick!();
Assert.False(state.View.Current.HasLocation);
state.Dispose();
}
[Fact]
public void StartOnTheRealPageSwapsToTheRunningReadout()
{
ImportedLayout layout = BuildPanel();
(_, RuntimeJournalState state, UiElement page) = BindNotes(layout);
var hours = UiElement.FindDescendant(page, 0x10000578u) as UiField;
Assert.NotNull(hours);
hours!.SetText("1");
(UiElement.FindDescendant(page, 0x1000057Du) as UiButton)!.OnClick!();
Assert.False(hours.Visible);
UiElement? readout = UiElement.FindDescendant(page, 0x1000057Cu);
Assert.NotNull(readout);
Assert.True(readout!.Visible);
state.Dispose();
}
[Fact]
public void ThePageListWiresItsButtonsToo()
{
ImportedLayout layout = BuildPanel();
UiElement? page = layout.FindElement(JournalPanelController.PageListPageId);
Assert.NotNull(page);
var state = new RuntimeJournalState();
state.Load([new JournalPage(Title: "one")]);
_ = new JournalPageListController(
page!,
new JournalPageListController.Bindings(
Journal: state.View,
Commands: state,
OpenPage: _ => { },
TemplateResolver: (_, _) => null));
var unwired = new List<string>();
foreach ((uint id, string name) in new[]
{
(0x10000585u, "Delete"), (0x10000588u, "Reset"),
})
{
if (UiElement.FindDescendant(page!, id) is not UiButton button)
unwired.Add($"{name} (0x{id:X8}) not found");
else if (button.OnClick is null)
unwired.Add($"{name} (0x{id:X8}) has no OnClick");
}
Assert.Empty(unwired);
state.Dispose();
}
}

View file

@ -0,0 +1,186 @@
using System;
using System.IO;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign QT slice QT5: pins the authored facts the Journal panel is built
/// on, against the live installed DATs rather than a committed fixture.
/// </summary>
/// <remarks>
/// These are the facts a reader would otherwise have to take on trust from a
/// commit message: the panel's slot key, its three-tab table, and which tab is
/// the authored default. The FA campaign had to CORRECT a tab pairing that had
/// been inferred from x-order, which is why the pairing is asserted here from
/// the authored <c>0x2E</c> table itself.
/// </remarks>
[Trait("Lane", "InstalledDat")]
public sealed class JournalPanelSlotProbeTests
{
private const uint SlotKeyPropertyId = 0x10000029u;
private const uint TabTablePropertyId = 0x2Eu;
private const uint TabButtonPropertyId = 0x30u;
private const uint TabPagePropertyId = 0x31u;
private const uint TabDefaultPropertyId = 0x32u;
private const uint ToolbarLayoutId = 0x21000016u;
private const uint JournalToolbarButtonId = 0x1000055Au;
private static ElementInfo Import(uint layoutId, uint rootElementId = 0u)
{
string? datDir = ContentConformanceDatDir();
if (datDir is null)
{
Assert.Fail(
"Lane=InstalledDat requires an installed retail DAT directory; "
+ "see docs/release-gate.md.");
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ElementInfo? info = rootElementId == 0u
? LayoutImporter.ImportInfos(adapter, layoutId)
: LayoutImporter.ImportInfos(adapter, layoutId, rootElementId);
Assert.NotNull(info);
return info!;
}
private static string? ContentConformanceDatDir()
{
string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
string def = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(def) ? def : null;
}
private static UiPropertyValue? Property(ElementInfo info, uint propertyId)
{
foreach (UiStateInfo state in info.States.Values)
{
if (state.Properties.Values.TryGetValue(propertyId, out UiPropertyValue? value))
return value;
}
return null;
}
[Fact]
public void TheSlotAuthorsTheCatalogPanelId()
{
ElementInfo slot = Import(
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
UiPropertyValue? key = Property(slot, SlotKeyPropertyId);
Assert.NotNull(key);
Assert.Equal(RetailPanelCatalog.Journal, (uint)key!.UnsignedValue);
}
[Fact]
public void TheAuthoredTabTablePairsContractsWithTheGmContractsUiPage()
{
// Read from the table, never inferred from x-order.
ElementInfo slot = Import(
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
UiPropertyValue? tabs = Property(slot, TabTablePropertyId);
Assert.NotNull(tabs);
Assert.Equal(3, tabs!.ArrayValue.Count);
UiPropertyValue first = tabs.ArrayValue[0];
Assert.Equal(
0x100005D3u,
(uint)first.StructValue[TabButtonPropertyId].UnsignedValue);
Assert.Equal(
JournalPanelController.ContractsPageId,
(uint)first.StructValue[TabPagePropertyId].UnsignedValue);
}
[Fact]
public void ContractsIsTheAuthoredDefaultTab()
{
// Opening on the wrong tab would look like the panel is empty.
ElementInfo slot = Import(
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
UiPropertyValue tabs = Property(slot, TabTablePropertyId)!;
UiPropertyValue defaultTab = Assert.Single(
tabs.ArrayValue,
t => t.StructValue.TryGetValue(TabDefaultPropertyId, out UiPropertyValue? d)
&& d.BoolValue);
Assert.Equal(
JournalPanelController.ContractsPageId,
(uint)defaultTab.StructValue[TabPagePropertyId].UnsignedValue);
}
[Fact]
public void TheToolbarButtonAuthorsTheSamePanelId()
{
// The button was already in ToolbarController.PanelButtonIds with no
// panel behind it, so clicking it did nothing. This is the fact that
// makes it work rather than a keybind.
ElementInfo toolbar = Import(ToolbarLayoutId);
ElementInfo? button = Find(toolbar, JournalToolbarButtonId);
Assert.NotNull(button);
UiPropertyValue? key = Property(button!, SlotKeyPropertyId);
Assert.NotNull(key);
Assert.Equal(RetailPanelCatalog.Journal, (uint)key!.UnsignedValue);
}
[Fact]
public void TheContractsPageCarriesEveryChildTheControllerResolves()
{
// A renamed or missing child would silently leave a blank row rather
// than fail, because the controller binds defensively.
ElementInfo slot = Import(
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
ElementInfo page = Assert.IsType<ElementInfo>(
Find(slot, JournalPanelController.ContractsPageId));
foreach (uint childId in new[]
{
0x100005CFu, // the list
0x100005DEu, // description
0x100005DFu, // status value
0x100005E0u, // contact value
0x100005E1u, // contact location
0x100005E2u, // quest location
0x100005E3u, // timed value
0x100005DCu, // Abandon
})
{
Assert.True(
Find(page, childId) is not null,
$"contracts page is missing authored child 0x{childId:X8}");
}
}
private static ElementInfo? Find(ElementInfo root, uint id)
{
if (root.Id == id) return root;
foreach (ElementInfo child in root.Children)
{
ElementInfo? hit = Find(child, id);
if (hit is not null) return hit;
}
return null;
}
}

View file

@ -0,0 +1,139 @@
using System.Collections.Generic;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Retail's state media is a small program, and this plays it.
/// </summary>
public sealed class UiMediaSequenceTests
{
private static UiMediaStep Image(uint file)
=> new(UiMediaStepKind.Image, file, 1, 0f, 0f, 0u, 0f);
private static UiMediaStep Pause(float seconds)
=> new(UiMediaStepKind.Pause, 0u, 0, seconds, seconds, 0u, 0f);
private static UiMediaStep State(uint state, float probability = 1f)
=> new(UiMediaStepKind.State, 0u, 0, 0f, 0f, state, probability);
private static UiMediaStep Jump(uint index, float probability = 1f)
=> new(UiMediaStepKind.Jump, 0u, 0, 0f, 0f, index, probability);
/// <summary>
/// The chat unseen-text indicator's authored sequence, verbatim from the
/// installed dats (0x2100006F / 0x1000048C, state 1): two frames
/// alternating every half second, three times, then hand off to Ghosted.
/// </summary>
private static IReadOnlyList<UiMediaStep> BlinkSequence() =>
[
Image(0x06005F0Eu), Pause(0.5f),
Image(0x06005F0Fu), Pause(0.5f),
Image(0x06005F0Eu), Pause(0.5f),
Image(0x06005F0Fu), Pause(0.5f),
Image(0x06005F0Eu), Pause(0.5f),
Image(0x06005F0Fu), Pause(0.5f),
State(13u),
];
[Theory]
[InlineData(0.0f, 0x06005F0Eu)]
[InlineData(0.4f, 0x06005F0Eu)]
[InlineData(0.5f, 0x06005F0Fu)] // first flip, exactly on the boundary
[InlineData(0.9f, 0x06005F0Fu)]
[InlineData(1.0f, 0x06005F0Eu)]
[InlineData(2.75f, 0x06005F0Fu)]
public void TheFrameAlternatesEveryHalfSecond(float elapsed, uint expected)
{
(uint file, uint? transition) = UiMediaSequence.Sample(BlinkSequence(), elapsed);
Assert.Equal(expected, file);
Assert.Null(transition);
}
[Fact]
public void AfterThreeSecondsItHandsOffToGhosted()
{
// This is the behaviour nobody would guess from the code: retail's
// indicator is a transient attention-flash, not a badge that stays
// lit. Three seconds of blinking, then it hides itself.
(uint _, uint? transition) = UiMediaSequence.Sample(BlinkSequence(), 3.0f);
Assert.Equal(13u, transition);
}
[Fact]
public void TheHandOffDoesNotFireEarly()
{
Assert.Null(UiMediaSequence.Sample(BlinkSequence(), 2.99f).TransitionState);
}
[Fact]
public void ASingleImageIsNotAnAnimation()
{
// A still frame must keep the ordinary draw path rather than being
// routed through a player that would only ever return the same file.
Assert.False(UiMediaSequence.IsAnimated([Image(1u)]));
Assert.False(UiMediaSequence.IsAnimated([]));
Assert.False(UiMediaSequence.IsAnimated(null));
Assert.True(UiMediaSequence.IsAnimated([Image(1u), Pause(1f)]));
Assert.True(UiMediaSequence.IsAnimated([Image(1u), Image(2u)]));
}
[Fact]
public void AJumpLoopsAndKeepsRunningForever()
{
IReadOnlyList<UiMediaStep> looping =
[
Image(0xAAu), Pause(1f),
Image(0xBBu), Pause(1f),
Jump(0u),
];
Assert.Equal(0xAAu, UiMediaSequence.Sample(looping, 0.5f).File);
Assert.Equal(0xBBu, UiMediaSequence.Sample(looping, 1.5f).File);
Assert.Equal(0xAAu, UiMediaSequence.Sample(looping, 2.5f).File); // looped
Assert.Equal(0xBBu, UiMediaSequence.Sample(looping, 101.5f).File); // still going
}
[Fact]
public void AZeroTimeJumpCycleTerminatesInsteadOfHanging()
{
// A malformed sequence — a jump cycle with no pause in it — would spin
// forever inside one frame. It must return, not hang the client.
IReadOnlyList<UiMediaStep> pathological = [Image(0xAAu), Jump(0u)];
Assert.Equal(0xAAu, UiMediaSequence.Sample(pathological, 1f).File);
}
[Fact]
public void AnUncertainBranchFallsThroughRatherThanLooping()
{
// Falling through ends the sequence; treating it as "always" would
// animate forever. The conservative direction is to stop.
IReadOnlyList<UiMediaStep> maybe =
[
Image(0xAAu), Pause(1f), Jump(0u, probability: 0.5f), Image(0xBBu),
];
Assert.Equal(0xBBu, UiMediaSequence.Sample(maybe, 1.5f).File);
}
[Fact]
public void UnknownStepsAreSteppedOverWithoutBreakingJumpIndices()
{
// Sound, movie and message entries are kept as Other precisely so a
// jump's index still lands on the authored entry.
IReadOnlyList<UiMediaStep> withOther =
[
new(UiMediaStepKind.Other, 0u, 0, 0f, 0f, 0u, 0f),
Image(0xCCu),
Pause(1f),
Jump(1u),
];
Assert.Equal(0xCCu, UiMediaSequence.Sample(withOther, 0.5f).File);
Assert.Equal(0xCCu, UiMediaSequence.Sample(withOther, 5f).File);
}
}

View file

@ -142,8 +142,21 @@ public class UiButtonTests
}
[Fact]
public void DisabledProperty_SelectsGhostedAndSuppressesClick()
public void Property0x0DDoesNotDisableTheButton()
{
// This test previously asserted the opposite, encoding the same
// uncited assumption the constructor made: that 0x0D means "starts
// disabled". Measured across every installed layout
// (LayoutDump --ghosted), 85 elements author 0x0D and ALL 85 author it
// TRUE — never once False — and no panel ever clears it: the only
// SetAttribute_Bool(…, 0xd, …) sites in the whole binary are chargen
// appearance, the keymap option and the barber.
//
// A flag that is only ever true, never cleared, and sits on New,
// Record, Start, Delete and Reset cannot mean "dead button". Reading
// it that way made every button on the Journal panel visible but
// unclickable, because drawing ignores Enabled while
// UiElement.HitTest skips disabled elements.
var info = ButtonInfo("Normal", "Ghosted");
AddBoolProperty(info, 0x0Du, true);
var b = CreateButton(info);
@ -151,8 +164,24 @@ public class UiButtonTests
b.OnEvent(new UiEvent(0, b, UiEventType.Click));
Assert.True(b.Enabled);
Assert.True(_clicked);
}
[Fact]
public void AnExplicitGhostedStateStillSuppressesTheClick()
{
// The state machine's own Ghosted transition is a separate mechanism
// from the authored 0x0D property and is NOT what changed — a panel
// that deliberately ghosts a button still gets a dead button.
var info = ButtonInfo("Normal", "Ghosted");
var b = CreateButton(info);
b.OnClick = () => _clicked = true;
b.TrySetRetailState(UiButtonStateMachine.Ghosted);
b.OnEvent(new UiEvent(0, b, UiEventType.Click));
Assert.False(b.Enabled);
Assert.Equal("Ghosted", b.ActiveState);
Assert.False(_clicked);
}

View file

@ -200,4 +200,91 @@ public class UiFieldTests
Assert.Equal("a\nb", input.Text);
Assert.Equal(0, submissions);
}
// ── CT-B2: typed-abbreviation expansion ─────────────────────────────
[Fact]
public void TypingASpaceOffersTheTextToTheReplacer()
{
var input = new UiField();
input.TextReplacer = text => text == "/r " ? "@tell Dww, " : null;
foreach (char c in "/r ")
input.InsertChar(c);
Assert.Equal("@tell Dww, ", input.Text);
Assert.Equal("@tell Dww, ".Length, input.CaretPos);
}
[Fact]
public void ANonSpaceCharacterNeverTriggersTheReplacer()
{
// Retail keys the expansion on 0x20 specifically; running it on every
// keystroke would rewrite text out from under someone mid-word.
int calls = 0;
var input = new UiField();
input.TextReplacer = _ => { calls++; return null; };
foreach (char c in "/reply")
input.InsertChar(c);
Assert.Equal(0, calls);
}
[Fact]
public void EditingInTheMiddleOfALineIsNotRewritten()
{
// The caret is not at the end, so the player is editing existing text
// rather than typing an abbreviation — expanding here would corrupt a
// sentence they are part way through fixing.
var input = new UiField();
input.SetText("/r hello");
input.MoveCaret(-5); // caret sits just after "/r"
input.TextReplacer = _ => "@tell Dww, ";
input.InsertChar(' ');
Assert.Equal("/r hello", input.Text);
}
[Fact]
public void AReplacerReturningNullLeavesTheTextExactlyAsTyped()
{
var input = new UiField();
input.TextReplacer = _ => null;
foreach (char c in "hi ")
input.InsertChar(c);
Assert.Equal("hi ", input.Text);
}
// ── CT-C2: Escape leaves the chat entry ─────────────────────────────
[Fact]
public void EscapeIsHandledAndKeepsWhatWasTyped()
{
// Retail's DeactivateChatEntry @0x004F2FC0 relinquishes focus and
// deactivates; it does NOT clear the field, so a half-written message
// survives stepping away from the bar.
var input = new UiField();
input.SetText("half written");
bool handled = input.OnEvent(new UiEvent(
0, input, UiEventType.KeyDown, Data0: (int)Silk.NET.Input.Key.Escape));
Assert.True(handled);
Assert.Equal("half written", input.Text);
}
[Fact]
public void EscapeIsIgnoredWhenTheFieldIsNotEditable()
{
// A read-only field returns early before the key switch, so Escape
// must not acquire behaviour there.
var input = new UiField { Editable = false };
Assert.True(input.OnEvent(new UiEvent(
0, input, UiEventType.KeyDown, Data0: (int)Silk.NET.Input.Key.Escape)));
}
}

View file

@ -8,6 +8,87 @@ namespace AcDream.App.Tests.UI;
public class UiTextTests
{
// ── Campaign CT slice A1: per-line coloured runs ────────────────────
private static UiText.TextRun Run(string text)
=> new(text, Vector4.One);
[Fact]
public void LayoutRuns_AdvancesThePenAcrossRuns()
{
// 10px per character, so the offsets are checkable by hand.
var placed = UiText.LayoutRuns(
[
new UiText.TextRun("Dww", new Vector4(0f, 0.698f, 0f, 1f)),
new UiText.TextRun(" tells you", Vector4.One),
],
startX: 4f,
measure: text => text.Length * 10f);
Assert.Equal(2, placed.Count);
Assert.Equal(4f, placed[0].X); // first run starts at the line origin
Assert.Equal(34f, placed[1].X); // 4 + 3 chars * 10
Assert.Equal(new Vector4(0f, 0.698f, 0f, 1f), placed[0].Color);
Assert.Equal(Vector4.One, placed[1].Color);
}
[Fact]
public void LayoutRuns_EmptyRunsDrawNothingAndShiftNothing()
{
var placed = UiText.LayoutRuns(
[
new UiText.TextRun(string.Empty, Vector4.One),
new UiText.TextRun("ab", Vector4.One),
new UiText.TextRun(string.Empty, Vector4.One),
new UiText.TextRun("cd", Vector4.One),
],
startX: 0f,
measure: text => text.Length * 10f);
// The two empties contribute no geometry, and because they measure zero
// they cannot displace what follows.
Assert.Equal(2, placed.Count);
Assert.Equal(0f, placed[0].X);
Assert.Equal(20f, placed[1].X);
}
[Fact]
public void RunsMatchLine_AcceptsAnExactSplitOfTheLine()
{
Assert.True(UiText.RunsMatchLine(
[Run("Dww"), Run(" tells you, \"hi\"")],
"Dww tells you, \"hi\""));
// Order matters: the same pieces rearranged are a different line.
Assert.False(UiText.RunsMatchLine(
[Run(" tells you, \"hi\""), Run("Dww")],
"Dww tells you, \"hi\""));
}
[Fact]
public void RunsMatchLine_RejectsAnythingThatWouldDesyncSelection()
{
// Selection, hit-testing and the caret all index into the FLAT line, so
// a run list that says something else would draw one thing and select
// another. Every one of these must be refused.
const string line = "Dww tells you";
Assert.False(UiText.RunsMatchLine([Run("Dww")], line)); // short
Assert.False(UiText.RunsMatchLine([Run(line), Run("!")], line)); // long
Assert.False(UiText.RunsMatchLine([Run("Dwx"), Run(" tells you")], line)); // altered
Assert.False(UiText.RunsMatchLine([], line)); // empty vs text
}
[Fact]
public void RunsMatchLine_HandlesTheDegenerateCases()
{
Assert.True(UiText.RunsMatchLine([], string.Empty));
Assert.True(UiText.RunsMatchLine([Run(string.Empty)], string.Empty));
// An empty run between real ones is harmless — it contributes nothing
// to the text and advances the pen by nothing.
Assert.True(UiText.RunsMatchLine([Run("ab"), Run(string.Empty), Run("cd")], "abcd"));
}
[Fact]
public void WrapWords_UsesMeasuredWidthAndPreservesParagraphs()
{

View file

@ -0,0 +1,103 @@
using System;
using System.Linq;
using AcDream.Core.Quests;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.Content.Tests;
/// <summary>
/// Installed-DAT gate for <see cref="ContractTableReader"/>: proves the real
/// ContractTable (portal.dat <c>0x0E00001D</c>) loads through the SAME
/// <see cref="DatCollectionAdapter"/> production uses. Nothing read this table
/// before Campaign QT, so "does Chorizite decode it at all, or merely declare
/// the type?" was a live question — it decodes it.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class ContractTableReaderInstalledDatTests
{
private static ContractCatalog Load()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
{
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
Assert.Fail(
"Lane=InstalledDat requires an installed retail DAT directory; "
+ "see docs/release-gate.md.");
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
return ContractTableReader.Load(adapter);
}
[Fact]
public void TheInstalledTableLoadsWithItsFullContractRoster()
{
ContractCatalog catalog = Load();
// 322 in the installed build. Asserted as a floor rather than an
// equality so a different dat revision is not a failure — the point is
// that the table decodes at all.
Assert.True(
catalog.Count >= 300,
$"expected the full contract roster, got {catalog.Count}");
}
[Fact]
public void EveryContractCarriesTheNameThePanelDraws()
{
// The wire sends only an id; if these come back empty the panel has
// nothing to show, and the failure would look like a UI bug.
ContractCatalog catalog = Load();
int named = catalog.Contracts.Values.Count(c => c.ContractName.Length > 0);
Assert.True(
named > catalog.Count / 2,
$"only {named} of {catalog.Count} contracts have a name");
}
[Fact]
public void EveryAuthoredProgressFormatUsesExactlyOneIntegerSpecifier()
{
// ContractProgressText substitutes only the FIRST %d, because retail's
// FillProgressString passes exactly one argument. This is the
// measurement that makes that reading safe rather than merely
// convenient — if a future dat ships a format with two specifiers, the
// single-substitution port needs revisiting and this fails first.
ContractCatalog catalog = Load();
var offenders = catalog.Contracts.Values
.Where(c => c.DescriptionProgress.Length > 0)
.Where(c => CountSpecifiers(c.DescriptionProgress) != 1)
.Select(c => $"0x{c.ContractId:X8} \"{c.DescriptionProgress}\"")
.ToArray();
Assert.Empty(offenders);
}
[Fact]
public void AMissingContractResolvesToTheUnknownEntryRatherThanThrowing()
{
// The server may track a contract this dat build has never heard of,
// and the panel still has to draw the row.
ContractCatalog catalog = Load();
ContractEntry entry = catalog.Lookup(0xDEADBEEFu);
Assert.Same(ContractEntry.Unknown, entry);
}
private static int CountSpecifiers(string format)
{
int count = 0;
for (int i = 0; i < format.Length - 1; i++)
{
if (format[i] == '%' && format[i + 1] != '%')
count++;
}
return count;
}
}

View file

@ -0,0 +1,100 @@
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
namespace AcDream.Content.Tests;
/// <summary>
/// Pins the data the ambient flyers (circling birds, flitting butterflies)
/// depend on, against the installed retail DATs.
/// </summary>
/// <remarks>
/// <para>
/// A Static object whose Setup declares a DefaultAnimation joins retail's
/// <c>CPhysics::static_animating_objects</c> workset
/// (<c>CPhysicsObj::InitDefaults @ 0x00513A7B</c>) and is driven by
/// <c>animate_static_object @ 0x00513DF0</c>, whose only motion step is
/// <c>Frame::grotate(&amp;m_position.frame, &amp;m_omegaVector)</c>. That
/// vector is written by exactly one thing:
/// <c>SetOmegaHook::Execute @ 0x00526F30</c> → <c>CPhysicsObj::set_omega
/// @ 0x0050F6D0</c>.
/// </para>
/// <para>
/// So these objects move ONLY if the SetOmega hook is honoured. acdream
/// decoded the hook and then ignored it, leaving m_omegaVector at zero: the
/// wings animated and nothing flew.
/// </para>
/// <para>
/// This test exists because the fix rests on two claims about shipped data
/// that are invisible from the code: that the authored omegas are pure YAW,
/// and that the meshes hang far off the axis they spin about. Rotation only
/// reads as flight because of the second one — a bird spinning about its own
/// centre would just pirouette. If a dat ever contradicts either, the fix is
/// wrong and this should say so rather than the behaviour quietly changing.
/// </para>
/// </remarks>
[Trait("Lane", "InstalledDat")]
public sealed class InstalledStaticAnimatingOmegaTests
{
[Fact]
public void EverySetOmegaAnimationIsPureYawOnAMeshOffsetFromItsAxis()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var found = new List<(uint Setup, float Yaw, double Radius)>();
foreach (uint id in dats.Portal.Tree.Select(e => e.Id).Where(i => (i >> 24) == 0x02))
{
if (!dats.Portal.TryGet<Setup>(id, out Setup? setup) || setup is null)
continue;
uint animId = setup.DefaultAnimation?.DataId ?? 0u;
if (animId == 0
|| !dats.Portal.TryGet<Animation>(animId, out Animation? anim)
|| anim is null)
{
continue;
}
foreach (var frame in anim.PartFrames)
foreach (var hook in frame.Hooks)
{
if (hook is not SetOmegaHook set)
continue;
// Pure yaw: a non-zero X or Y would tumble the object, and
// the circle-tracing reading of this mechanism would fail.
Assert.Equal(0f, set.Axis.X, 5);
Assert.Equal(0f, set.Axis.Y, 5);
Assert.NotEqual(0f, set.Axis.Z);
double radius = 0d;
foreach (var placement in setup.PlacementFrames.Values)
foreach (var af in placement.Frames)
radius = Math.Max(
radius,
Math.Sqrt((af.Origin.X * af.Origin.X)
+ (af.Origin.Y * af.Origin.Y)));
found.Add((id, set.Axis.Z, radius));
}
}
Assert.NotEmpty(found);
// Every one of them hangs metres off its own spin axis — that offset IS
// the flight radius. One metre would be a pirouette, not a circuit.
foreach ((uint setupId, _, double radius) in found)
{
Assert.True(
radius > 1d,
$"setup 0x{setupId:X8} spins about an axis only {radius:0.###}m "
+ "from its mesh; rotation would not read as flight.");
}
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Text;
using AcDream.Core.Chat;
@ -1813,6 +1814,99 @@ public sealed class GameEventWiringTests
Assert.Empty(rentPayment);
}
/// <summary>
/// An enchantment leaving the PLAYER is announced by the client, not the
/// server — so nothing printed at all until this was wired.
/// </summary>
/// <remarks>
/// ACE splits the two cases: an enchantment expiring on an ITEM arrives as
/// ordinary server chat ("The spell X on Y has expired."), which is why
/// item spells were the only ones the player ever saw. One expiring on the
/// player arrives as GameEventMagicDispelEnchantment carrying NO text,
/// because retail's client writes the line itself —
/// ClientMagicSystem::NotifyOfEnchantmentRemoval @0x005686C0, printed at
/// LogTextType 7 (Magic).
/// </remarks>
[Fact]
public void DispelledEnchantment_AnnouncesItsExpiryAtTheRetailTextType()
{
var lines = new List<(string Text, RetailLogTextType Type)>();
var dispatcher = new GameEventDispatcher();
GameEventWiring.WireAll(
dispatcher,
new ClientObjectTable(),
new CombatState(),
SpellbookWithNames(),
new ChatLog(),
onInterfaceText: (text, type) => lines.Add((text, type)));
dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
GameEventType.MagicDispelEnchantment,
DispelPayload(spellId: 1234, layer: 1)))!.Value);
Assert.Equal(
("Fire Protection Self has expired.", RetailLogTextType.Magic),
Assert.Single(lines));
}
[Fact]
public void DispelledVitaeReadsAsAPenalty()
{
// Retail appends " penalty" to this one spell's own name, so the line
// reads "Vitae penalty has expired." rather than "Vitae has expired."
var lines = new List<(string Text, RetailLogTextType Type)>();
var dispatcher = new GameEventDispatcher();
GameEventWiring.WireAll(
dispatcher,
new ClientObjectTable(),
new CombatState(),
SpellbookWithNames(),
new ChatLog(),
onInterfaceText: (text, type) => lines.Add((text, type)));
dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
GameEventType.MagicDispelEnchantment,
DispelPayload(spellId: 0x29A, layer: 1)))!.Value);
Assert.Equal("Vitae penalty has expired.", Assert.Single(lines).Text);
}
[Fact]
public void ADispelledSpellMissingFromTheTablePrintsNothing()
{
// Retail returns without printing when InqSpellBase fails, rather than
// announcing a blank name.
var lines = new List<(string Text, RetailLogTextType Type)>();
var dispatcher = new GameEventDispatcher();
GameEventWiring.WireAll(
dispatcher,
new ClientObjectTable(),
new CombatState(),
SpellbookWithNames(),
new ChatLog(),
onInterfaceText: (text, type) => lines.Add((text, type)));
dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
GameEventType.MagicDispelEnchantment,
DispelPayload(spellId: 4321, layer: 1)))!.Value);
Assert.Empty(lines);
}
private static Spellbook SpellbookWithNames()
=> new(SpellTable.LoadFromReader(new System.IO.StringReader(
"Spell ID,Name,Flags [Hex]\n"
+ "1234,Fire Protection Self,0x4\n"
+ "666,Vitae,0x4\n")));
private static byte[] DispelPayload(ushort spellId, ushort layer)
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt16LittleEndian(payload, spellId);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(2), layer);
return payload;
}
private static byte[] BuildEnchantment(
ushort spellId,
ushort layer,

View file

@ -0,0 +1,207 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Campaign QT slice QT1: retail's two contract-tracker game events.
/// </summary>
public sealed class ContractTrackerMessagesTests
{
private static readonly DateTime Arrival = new(2026, 8, 21, 13, 5, 9, DateTimeKind.Utc);
/// <summary>Writes one tracker struct exactly as ACE's writer does.</summary>
private static byte[] Tracker(
uint version, uint contractId, uint stage, double whenDone, double whenRepeats)
{
var buffer = new byte[28];
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0), version);
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4), contractId);
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(8), stage);
BinaryPrimitives.WriteDoubleLittleEndian(buffer.AsSpan(12), whenDone);
BinaryPrimitives.WriteDoubleLittleEndian(buffer.AsSpan(20), whenRepeats);
return buffer;
}
private static byte[] Concat(params byte[][] parts)
{
var result = new List<byte>();
foreach (byte[] part in parts) result.AddRange(part);
return [.. result];
}
private static byte[] U32(uint value)
{
var buffer = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
return buffer;
}
/// <summary>The u16 count / u16 buckets header, packed into one dword.</summary>
private static byte[] HashHeader(ushort count, ushort buckets)
=> U32((uint)count | ((uint)buckets << 16));
// ── 0x0315, the single update ───────────────────────────────────────
[Fact]
public void AnUpdateDecodesEveryFieldInOrder()
{
byte[] payload = Concat(
Tracker(3u, 0x1234u, 2u, 120.5, 86400.0),
U32(0u), // DeleteContract
U32(1u)); // SetAsDisplayContract
ContractTrackerUpdate update =
ContractTrackerMessages.ParseUpdate(payload, Arrival)!.Value;
Assert.Equal(3u, update.Tracker.Version);
Assert.Equal(0x1234u, update.Tracker.ContractId);
Assert.Equal(ContractStage.InProgress, update.Tracker.Stage);
Assert.Equal(120.5, update.Tracker.TimeWhenDone);
Assert.Equal(86400.0, update.Tracker.TimeWhenRepeats);
Assert.False(update.Delete);
Assert.True(update.SetAsDisplay);
}
[Fact]
public void TheTwoFlagsAreWidenedBoolsNotBytes()
{
// ACE writes Convert.ToUInt32(bool) AFTER the struct — reading them as
// bytes would decode the delete flag out of the wrong four bytes and
// silently drop contracts.
byte[] payload = Concat(Tracker(1u, 7u, 1u, 0, 0), U32(1u), U32(0u));
ContractTrackerUpdate update =
ContractTrackerMessages.ParseUpdate(payload, Arrival)!.Value;
Assert.True(update.Delete);
Assert.False(update.SetAsDisplay);
}
[Fact]
public void ArrivalIsStampedBecauseItIsNotOnTheWire()
{
// FillProgressString counts down from _time_of_server_update, which the
// server never sends. Without the stamp there is no anchor and the
// repeat timer cannot tick.
ContractTrackerUpdate update = ContractTrackerMessages.ParseUpdate(
Concat(Tracker(1u, 7u, 3u, 0, 600.0), U32(0u), U32(0u)), Arrival)!.Value;
Assert.Equal(Arrival, update.Tracker.ReceivedAt);
}
[Fact]
public void ATruncatedUpdateIsRejectedRatherThanPartiallyDecoded()
{
// The struct alone, with the two flags missing.
Assert.Null(ContractTrackerMessages.ParseUpdate(Tracker(1u, 7u, 1u, 0, 0), Arrival));
Assert.Null(ContractTrackerMessages.ParseUpdate([], Arrival));
}
// ── the progress counter ────────────────────────────────────────────
[Theory]
[InlineData(1u, 0u, false)]
[InlineData(2u, 0u, false)]
[InlineData(3u, 0u, false)]
[InlineData(4u, 0u, true)] // counter present, zero done
[InlineData(9u, 5u, true)]
public void TheStageCarriesTheProgressCountAboveFour(
uint stage, uint expectedProgress, bool expectedHasCounter)
{
// Retail encodes N completed steps as ProgressCounter + N rather than
// as a separate field, so a naive enum switch would see stage 9 as an
// unknown value and show nothing.
ContractTracker tracker = ContractTrackerMessages.ParseUpdate(
Concat(Tracker(1u, 7u, stage, 0, 0), U32(0u), U32(0u)), Arrival)!.Value.Tracker;
Assert.Equal(expectedProgress, tracker.Progress);
Assert.Equal(expectedHasCounter, tracker.HasProgressCounter);
}
// ── 0x0314, the full table ──────────────────────────────────────────
[Fact]
public void TheTableDecodesEveryEntryKeyedByContractId()
{
byte[] payload = Concat(
HashHeader(count: 2, buckets: 8),
U32(0x1111u), Tracker(1u, 0x1111u, 1u, 0, 0),
U32(0x2222u), Tracker(1u, 0x2222u, 6u, 10.0, 20.0));
IReadOnlyDictionary<uint, ContractTracker> table =
ContractTrackerMessages.ParseTable(payload, Arrival)!;
Assert.Equal(2, table.Count);
Assert.Equal(ContractStage.Available, table[0x1111u].Stage);
Assert.Equal(2u, table[0x2222u].Progress);
Assert.Equal(Arrival, table[0x2222u].ReceivedAt);
}
[Fact]
public void AnEmptyTableIsAValidAnswerNotAFailure()
{
// "You have no contracts" is a real thing the server says, and it is
// how the panel gets cleared. Returning null here would leave stale
// contracts on screen forever.
IReadOnlyDictionary<uint, ContractTracker>? table =
ContractTrackerMessages.ParseTable(HashHeader(0, 0), Arrival);
Assert.NotNull(table);
Assert.Empty(table!);
}
[Fact]
public void ANonEmptyTableWithNoBucketsIsRejected()
{
// PackableHashTable::UnPack early-returns on a zero-bucket table, so a
// count without buckets is a corrupt frame, not an empty one.
Assert.Null(ContractTrackerMessages.ParseTable(
Concat(HashHeader(count: 1, buckets: 0), U32(1u), Tracker(1u, 1u, 1u, 0, 0)),
Arrival));
}
[Fact]
public void ATruncatedTableIsRejectedRatherThanReturningThePrefix()
{
// Claiming two entries and supplying one must not decode as one — a
// half-read table would silently drop the player's quests.
Assert.Null(ContractTrackerMessages.ParseTable(
Concat(HashHeader(count: 2, buckets: 8), U32(1u), Tracker(1u, 1u, 1u, 0, 0)),
Arrival));
}
[Fact]
public void AnImplausibleCountIsRejectedWithoutAllocatingForIt()
{
// Retail's largest observed table is ~3 KB. A count field of 60,000
// against a short payload is a decode error; it must fail fast rather
// than try to read 60,000 entries.
Assert.Null(ContractTrackerMessages.ParseTable(
HashHeader(count: 60000, buckets: 256), Arrival));
}
}
/// <summary>
/// Campaign QJ: the outbound abandon action.
/// </summary>
public sealed class AbandonContractRequestTests
{
[Fact]
public void TheAbandonPayloadIsTheContractIdAlone()
{
// ACE's GameActionAbandonContract reads exactly one uint32 and nothing
// else; a longer payload desyncs the whole game-action stream.
byte[] frame = ClientCommandRequests.BuildAbandonContract(
sequence: 7u, contractId: 0x1234u);
// 0xF7B1 envelope, sequence, opcode, then the payload.
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(0)));
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(4)));
Assert.Equal(0x0316u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(8)));
Assert.Equal(0x1234u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(12)));
Assert.Equal(16, frame.Length);
}
}

Some files were not shown because too many files have changed in this diff Show more