Merge remote-tracking branch 'origin/main' into codex/atmospheric-rendering-campaign
This commit is contained in:
commit
99cf26e00c
122 changed files with 14815 additions and 947 deletions
|
|
@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
199
docs/plans/2026-08-21-chat-text-tag-campaign.md
Normal file
199
docs/plans/2026-08-21-chat-text-tag-campaign.md
Normal 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.
|
||||
200
docs/plans/2026-08-21-contract-tracker-campaign.md
Normal file
200
docs/plans/2026-08-21-contract-tracker-campaign.md
Normal 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.
|
||||
121
docs/plans/2026-08-21-journal-campaign.md
Normal file
121
docs/plans/2026-08-21-journal-campaign.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
188
docs/research/2026-08-21-acdream-chat-ui-audit.md
Normal file
188
docs/research/2026-08-21-acdream-chat-ui-audit.md
Normal 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`
|
||||
376
docs/research/2026-08-21-acdream-text-stack-audit.md
Normal file
376
docs/research/2026-08-21-acdream-text-stack-audit.md
Normal 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).
|
||||
543
docs/research/2026-08-21-chat-tag-click-dispatch.md
Normal file
543
docs/research/2026-08-21-chat-tag-click-dispatch.md
Normal 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
|
||||
1–2 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`).
|
||||
469
docs/research/2026-08-21-chat-tagged-name-composition.md
Normal file
469
docs/research/2026-08-21-chat-tagged-name-composition.md
Normal 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`, 1–4), 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.
|
||||
849
docs/research/2026-08-21-chat-texttag-model.md
Normal file
849
docs/research/2026-08-21-chat-texttag-model.md
Normal 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.
|
||||
650
docs/research/2026-08-21-retail-chat-window-ui.md
Normal file
650
docs/research/2026-08-21-retail-chat-window-ui.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
73
src/AcDream.App/UI/ChatTranscriptLogWriter.cs
Normal file
73
src/AcDream.App/UI/ChatTranscriptLogWriter.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
121
src/AcDream.App/UI/JournalPersistence.cs
Normal file
121
src/AcDream.App/UI/JournalPersistence.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & 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)
|
||||
|
|
|
|||
288
src/AcDream.App/UI/Layout/JournalContractsPageController.cs
Normal file
288
src/AcDream.App/UI/Layout/JournalContractsPageController.cs
Normal 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)];
|
||||
}
|
||||
}
|
||||
317
src/AcDream.App/UI/Layout/JournalNotesPageController.cs
Normal file
317
src/AcDream.App/UI/Layout/JournalNotesPageController.cs
Normal 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)];
|
||||
}
|
||||
}
|
||||
252
src/AcDream.App/UI/Layout/JournalPageListController.cs
Normal file
252
src/AcDream.App/UI/Layout/JournalPageListController.cs
Normal 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)];
|
||||
}
|
||||
}
|
||||
208
src/AcDream.App/UI/Layout/JournalPanelController.cs
Normal file
208
src/AcDream.App/UI/Layout/JournalPanelController.cs
Normal 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") -> page 0x100005D4 DEFAULT (0x32 = True)
|
||||
/// button 0x10000560 ("Journal") -> page 0x10000563
|
||||
/// button 0x10000561 ("Page List") -> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
158
src/AcDream.App/UI/Layout/UiMediaSequence.cs
Normal file
158
src/AcDream.App/UI/Layout/UiMediaSequence.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
72
src/AcDream.Content/ContractTableReader.cs
Normal file
72
src/AcDream.Content/ContractTableReader.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
196
src/AcDream.Core.Net/Messages/ContractTrackerMessages.cs
Normal file
196
src/AcDream.Core.Net/Messages/ContractTrackerMessages.cs
Normal 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<unsigned long, CContractTracker></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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
184
src/AcDream.Core/Chat/ChatSessionLog.cs
Normal file
184
src/AcDream.Core/Chat/ChatSessionLog.cs
Normal 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);
|
||||
165
src/AcDream.Core/Chat/ChatTagMarkup.cs
Normal file
165
src/AcDream.Core/Chat/ChatTagMarkup.cs
Normal 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><TYPE:FORMAT:DATA></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>
|
||||
/// <Tell:IIDString:1342177290:Dww>Dww<\Tell> 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><</c> (no closing <c>></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)..]);
|
||||
}
|
||||
}
|
||||
219
src/AcDream.Core/Journal/JournalFile.cs
Normal file
219
src/AcDream.Core/Journal/JournalFile.cs
Normal 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><NEWP></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;
|
||||
}
|
||||
77
src/AcDream.Core/Journal/JournalPage.cs
Normal file
77
src/AcDream.Core/Journal/JournalPage.cs
Normal 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><TIME></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];
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
83
src/AcDream.Core/Quests/ContractEntry.cs
Normal file
83
src/AcDream.Core/Quests/ContractEntry.cs
Normal 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;
|
||||
}
|
||||
104
src/AcDream.Core/Quests/ContractProgressText.cs
Normal file
104
src/AcDream.Core/Quests/ContractProgressText.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
74
src/AcDream.Core/Ui/RetailDurationText.cs
Normal file
74
src/AcDream.Core/Ui/RetailDurationText.cs
Normal 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(' ');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
41
src/AcDream.Plugin.Abstractions/ContractSnapshot.cs
Normal file
41
src/AcDream.Plugin.Abstractions/ContractSnapshot.cs
Normal 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 = "");
|
||||
|
|
@ -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 => [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
76
src/AcDream.Runtime/Chat/ChatTextReplacements.cs
Normal file
76
src/AcDream.Runtime/Chat/ChatTextReplacements.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
59
src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs
Normal file
59
src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
201
src/AcDream.Runtime/Gameplay/RuntimeContractState.cs
Normal file
201
src/AcDream.Runtime/Gameplay/RuntimeContractState.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
386
src/AcDream.Runtime/Gameplay/RuntimeJournalState.cs
Normal file
386
src/AcDream.Runtime/Gameplay/RuntimeJournalState.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <msg></c> or no prefix → Say (default)</item>
|
||||
/// <item><c>/t</c> / <c>/tell <name> <msg></c> → whisper</item>
|
||||
/// <item><c>/r</c> / <c>/reply <msg></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 <msg></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),
|
||||
};
|
||||
|
||||
}
|
||||
|
|
@ -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<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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: () => { },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
121
tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs
Normal file
121
tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
180
tests/AcDream.App.Tests/UI/JournalPersistenceTests.cs
Normal file
180
tests/AcDream.App.Tests/UI/JournalPersistenceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
85
tests/AcDream.App.Tests/UI/Layout/ChatTagClickTests.cs
Normal file
85
tests/AcDream.App.Tests/UI/Layout/ChatTagClickTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
295
tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
Normal file
295
tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
182
tests/AcDream.App.Tests/UI/Layout/JournalPanelLiveBindTests.cs
Normal file
182
tests/AcDream.App.Tests/UI/Layout/JournalPanelLiveBindTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
186
tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs
Normal file
186
tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
139
tests/AcDream.App.Tests/UI/Layout/UiMediaSequenceTests.cs
Normal file
139
tests/AcDream.App.Tests/UI/Layout/UiMediaSequenceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(&m_position.frame, &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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue