From bcc34ee30181c261d1dfe4550621e5c8cd77357a Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 10 Aug 2026 19:28:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(chat):=20retail=20text=20style=20=E2=80=94?= =?UTF-8?q?=20two-plane=20glyph=20outlines,=20authored=20SpewBox/chat=20st?= =?UTF-8?q?yles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CH round 4, user-gate items 1+2. Root cause: retail ships a second (background) glyph atlas per font, dilated 2px on every side, plus two border-pixel scalars (Font.NumHorizontalBorderPixels/ NumVerticalBorderPixels) that acdream's font reader never read — so even the pre-existing outline parameter drew almost nothing once enabled. Landed together (either half alone is a no-op or a regression): - UiDatFont carries BorderX/BorderY from the DAT font resource. - UiRenderContext.DrawStringDat inflates the background blit's source and destination rect by that margin and restructures into retail's exact two-pass whole-string outline-then-fill model (UIElement_Text::DrawSelf), plus the 8-neighbour +-1px fallback for fonts with no background atlas. Corrects the stale "property 0xd" comment to the real ids, 0x21 (Outline) / 0x22 (OutlineColor). - LayoutDesc property 0x21/0x22 import (ElementInfo.Outline/ OutlineColor, LayoutImporter.ReadState, ElementReader.Merge/ ApplyCanonicalLegacyProjection, DatWidgetFactory.BuildText) so every authored-outline element across the DAT set is correct at once. - SpewBox: RetailFontId corrected from a round-3 heuristic (0x40000025) to the actually-authored 0x40000001 (18px bold serif), Outline=true set on the controller's UiText. Fill colour stays the user-gate-round-1-pinned yellow — font atlases are alpha-only (PFID_A8), so there is no baked shading that could explain the screenshot's gold as anything other than the outline itself. - Chat transcript: default fill now seeds from its authored ARGB(255,204,204,204) instead of an unrelated color-table slot (ChatTranscriptRenderer.BuildLines takes the transcript's own DefaultColor as a parameter); the 34-entry LogTextType table is untouched, and every existing CH1 conformance test stays green unmodified. Regenerated the committed chat_2100006f.json fixture from the real installed DAT, confirming end to end (not by missing-field default) that the transcript carries no outline. Tests: font-reader border fields + inflation math pinned against the real DAT font, two-pass draw ordering/tint/inflation via a new TextRenderer.DebugSpriteSegmentVerts test seam, property 0x21/0x22 import at both the ElementReader.Merge and StateDesc-property layers, SpewBox font/outline, and the chat default-shade seed with the color table proven untouched. Full Release suite: 12,610 passed / 4 skipped / 0 failed (AcDream.slnx, complete solution). Co-Authored-By: Claude Opus 5 --- .../retail-divergence-register.md | 5 +- docs/plans/2026-08-09-chat-parity-campaign.md | 103 ++- .../2026-08-10-retail-ui-text-style.md | 616 ++++++++++++++++++ src/AcDream.App/Rendering/TextRenderer.cs | 25 + .../UI/Layout/ChatTranscriptRenderer.cs | 35 +- .../UI/Layout/ChatWindowController.cs | 3 +- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 10 + src/AcDream.App/UI/Layout/ElementReader.cs | 54 ++ .../UI/Layout/FloatingChatWindowController.cs | 3 +- src/AcDream.App/UI/Layout/LayoutImporter.cs | 21 + src/AcDream.App/UI/SpewBoxController.cs | 108 ++- src/AcDream.App/UI/UiDatFont.cs | 47 +- src/AcDream.App/UI/UiRenderContext.cs | 177 +++-- src/AcDream.App/UI/UiText.cs | 31 +- .../UI/Layout/ChatLayoutConformanceTests.cs | 47 ++ .../UI/Layout/ChatTranscriptRendererTests.cs | 77 +++ .../UI/Layout/ElementReaderTests.cs | 78 +++ .../UI/Layout/UiPropertyBagTests.cs | 68 ++ .../UI/Layout/fixtures/chat_2100006f.json | 78 +++ .../UI/SpewBoxControllerTests.cs | 49 ++ .../UI/UiDatFontBorderPixelTests.cs | 131 ++++ ...iRenderContextDrawStringDatOutlineTests.cs | 334 ++++++++++ 22 files changed, 1995 insertions(+), 105 deletions(-) create mode 100644 docs/research/2026-08-10-retail-ui-text-style.md create mode 100644 tests/AcDream.App.Tests/UI/UiDatFontBorderPixelTests.cs create mode 100644 tests/AcDream.App.Tests/UI/UiRenderContextDrawStringDatOutlineTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 96945845..ba755c8c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -170,7 +170,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 131 active rows (AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), see its retirement note below, closing ISSUES.md #364; AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 132 active rows (AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), see its retirement note below, closing ISSUES.md #364; AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -232,7 +232,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-39 | Chat lines carry one solid color per line (retail's exact 34-value `LogTextType` table as of Campaign CH slice CH1, 2026-08-09 — see `RetailChatColorTable`, no longer the earlier synthetic per-`ChatKind` approximation); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs`; consumers `src/AcDream.App/UI/Layout/ChatWindowController.cs`, `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line coloring is now the exact retail tonal palette (`ChatInterface::BuildChatColorLookupTable @0x004F31C0`), not an approximation of it | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp); `docs/research/2026-08-09-chat-retail-color-table.md` | | AP-175 | PopUpString (`GameEvent 0x0004`) renders as an ordinary chat-log line (`ChatKind.Popup`) instead of retail's MODAL DIALOG. Filed 2026-08-09, Campaign CH slice CH1 (color table) — the color-table work routes this entry through the new 34-value `LogTextType` table (fixed at `0x00` Default/green, unchanged from the entry's pre-existing color) but does not change WHERE it renders; a modal-dialog port is out of this slice's scope | `src/AcDream.Core/Chat/ChatLog.cs` (`OnPopup`); `src/AcDream.Core.Net/GameEventWiring.cs:126` | Informational popup text still reaches the player via the chat transcript; a full modal-dialog port is deferred work, not a color-table concern | Any retail-specific PopUpString behavior contingent on being a blocking modal (e.g. must-acknowledge) is not reproduced; acdream's chat-log line can be missed or scrolled past instead | `ClientCommunicationSystem::Handle_Communication__PopUpString @0x0057FE80`; `docs/research/2026-08-09-chat-retail-color-table.md` §5.1 | | AP-177 | SpewBox line lifetime is an INVENTED 5-second placeholder. Retail's `gmSpewBoxUI` never raises the expiry element message (`0x10000003`) anywhere in its own compiled Sept 2013 EoR code — the real per-line timeout/fade curve is owned by keystone.dll's authored behaviour for layout `0x10000012` element `0x1000004A`, which this slice did not measure (a live cdb capture on `gmSpewBoxUI::ListenToElementMessage @0x004D57C0` against a real retail client would resolve it). Filed 2026-08-09, Campaign CH slice CH2 | `src/AcDream.Core/Chat/SpewBoxState.cs` (`DefaultLifetime`) | A round, conservative placeholder was chosen over guessing a retail-matching curve; no fade is modeled at all (the line pops on and off) | SpewBox lines may linger noticeably longer or shorter than retail's actual timing, and pop instead of fading | `docs/research/2026-08-09-chat-retail-interface-text.md` §3.2.1 | -| AP-178 | **NARROWED 2026-08-09 at the CH2 REJECT-review rework (NIT 3, `docs/research/2026-08-09-ch2-review-findings.md`), WORDING CORRECTED at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6):** the original filing's `dats.Portal` pass used an id source (`DatCollection`'s top-level AGGREGATE `GetAllIdsOfType()`) that is NOT `dats.Portal`'s own id space (`dats.Portal.GetAllIdsOfType()` reports a count of ZERO for this type), so querying those ids against `dats.Portal.TryGet` established nothing about Portal either way — the "swept only dats.Portal and found ZERO... all-invented" framing overclaimed a search that never meaningfully happened. Extending the sweep to `dats.Local` (`client_local_English.dat`), this time correctly paired, FOUND it: LayoutDesc `0x21000011`, element `0x10000048`, whose sole child (ListBox `0x10000049`, matching `gmSpewBoxUI::PostInit`'s `GetChildRecursive(0x10000049)` verbatim) carries ListBox property `0x10000028` = the integer `4`. Whether `dats.Portal` ALSO carries a copy remains UNESTABLISHED, not ruled out. Two sub-claims RETIRE: extent is now AUTHORED (`450×72`, not a placeholder size) and `MaxConcurrentItems` is now AUTHORED (`4`, not retail's code-default `1`). **CH USER-GATE ROUND 1 (2026-08-09):** colour PINS — the user tested live, side-by-side against retail, and confirmed the on-screen SpewBox text is the same bright yellow as an incoming Tell (`0x81C4C8`, `RetailChatColorTable.Yellow` = `(1, 1, 0.247, 1)`); `SpewBoxController.SpewBoxColor` now uses that exact value. The user's SAME live pass also reported that SIZE, POSITION, and FONT still visibly differ from retail — so despite extent's earlier AUTHORED status above, size is user-gate round 1: differs, iterating (re-opened pending a follow-up measurement pass, not yet root-caused). Three sub-claims therefore REMAIN open: (1) absolute screen position — the recovered position is `(0,0)` RELATIVE TO A PARENT this sweep could not identify (the element is presumably still mounted via the C++ `gmClient` HUD registration block the research doc's §1.1 describes, just parented under something dat-authored rather than the root view directly), so `TopOffset=60px` + a centered `Left` recomputed every frame (corrected from a one-time computation at nit 1 — see `SpewBoxController.Tick`) remain acdream's own placeholder, not a resolved retail value, and the user confirms this is visibly wrong; (2) size/font — the AUTHORED `450×72` extent and whatever font this renders with still do not match what the user sees live; unmeasured which of extent, the unresolved parent scale, or font metrics is the actual cause; (3) vertical content flow — the block now renders TOP-aligned (newest line at the top, via `UiText.VerticalJustify`/`HonorVerticalJustification`, nit 2) because that is the only placement consistent with "newest on top," but retail's own authored vertical justification for this element is unmeasured, so this is also acdream's invention pending measurement, not a resolved retail value. Retail's edge codes (`leftEdge=3`/`rightEdge=3`, "centered" per `ElementReader.ToAnchors`'s own doc comment; `topEdge=1`, top-anchored) confirm the box is a fixed-width centered block, not a full-viewport stretch — `SpewBoxController`'s anchor shape was corrected to match (`AnchorEdges.None` + a centered `Left` recomputed every frame against the current root width, `OneLine=false` since 4 concurrent lines can now actually be visible instead of collapsing to 1). **CH USER-GATE ROUND 3 (2026-08-10):** the user's finding (a) confirmed POSITION and FONT still read wrong live — "not aligned all the way to the top" and "not the correct font and size (retail's is SMALLER)." Both sub-claims close as best-available APPROXIMATIONS, not resolved retail values (a re-run of `SpewBoxLayoutDumpDiagnostic` this round still finds no `FontDid`/colour property on element `0x10000048` or its ListBox child `0x10000049`, confirming the true retail values remain genuinely unmeasurable statically): (1) position — `TopOffset` moves from the round-1 60px placeholder to `0` (flush to the viewport top), per the user's explicit direction; the true retail PARENT remains unidentified. (2) font — `SpewBoxController` now resolves retail dat Font `0x40000025` (`MaxCharHeight=11px`, `Baseline=9px`; confirmed via `AcDream.Cli dump-font-atlas` sweeping every populated font id `0x40000000`-`0x40000032` in the installed DAT) instead of silently falling through to the unwired 15px debug `BitmapFont` every prior round shipped with (no `DatFont`/`Font` was ever set on this element before). `0x40000025` is the SMALLEST font id confirmed in use by any of acdream's currently-imported retail LayoutDesc fixtures (cross-referenced across all `tests/AcDream.App.Tests/UI/Layout/fixtures/*.json` dumps) — it is ALSO the chat window's own smallest font (the `0x2100006F` floating-window 1/2/3/4 indicator badges), so both selection criteria the round-3 brief offered agree on the same id, with no tie to break. Vertical content flow remains OPEN, unchanged from round 1. | `src/AcDream.App/UI/SpewBoxController.cs`; `src/AcDream.Core/Chat/SpewBoxState.cs` (`MaxConcurrentItems`); `src/AcDream.App/UI/UiText.cs` (`HonorVerticalJustification`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`Assets` accessor, round 3) | Colour is CONFIRMED, not a placeholder — CH user-gate round 1 (2026-08-09) pinned it against the user's own live side-by-side retail observation, not a recollection. A live cdb capture of `gmSpewBoxUI`'s runtime rect/state (or walking the `States` dictionary this pass skipped, or identifying the C++-assigned parent) remains the resolution path for the three still-open sub-claims (position, size/font, vertical flow) | SpewBox text may render in the wrong absolute screen location, size/font, or vertical flow versus retail — all three CONFIRMED wrong by the user's CH round-1 live pass, not merely suspected; colour is CLOSED and no longer a risk. The size/max-items risk this row originally recorded ("bursts of refusals collapse to one visible line where retail's authored ListBox may show more") is RETIRED — up to 4 now render, matching the authored value, though the box's overall size still visibly differs from retail per the user. Round 3 (2026-08-10) closes the position/font risks as best-available approximations (flush-top mount, smallest confirmed-used retail font) rather than resolved retail values — the box may still not sit at retail's true pixel position/size, and the exact retail font remains genuinely unmeasurable; only vertical content flow remains fully OPEN, unchanged from round 1 | `docs/research/2026-08-09-chat-retail-interface-text.md` §3.2.2-§3.2.4; `tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs`; `src/AcDream.App/UI/Layout/ElementReader.cs` (`ToAnchors`) | +| AP-178 | **NARROWED 2026-08-09 at the CH2 REJECT-review rework (NIT 3, `docs/research/2026-08-09-ch2-review-findings.md`), WORDING CORRECTED at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6):** the original filing's `dats.Portal` pass used an id source (`DatCollection`'s top-level AGGREGATE `GetAllIdsOfType()`) that is NOT `dats.Portal`'s own id space (`dats.Portal.GetAllIdsOfType()` reports a count of ZERO for this type), so querying those ids against `dats.Portal.TryGet` established nothing about Portal either way — the "swept only dats.Portal and found ZERO... all-invented" framing overclaimed a search that never meaningfully happened. Extending the sweep to `dats.Local` (`client_local_English.dat`), this time correctly paired, FOUND it: LayoutDesc `0x21000011`, element `0x10000048`, whose sole child (ListBox `0x10000049`, matching `gmSpewBoxUI::PostInit`'s `GetChildRecursive(0x10000049)` verbatim) carries ListBox property `0x10000028` = the integer `4`. Whether `dats.Portal` ALSO carries a copy remains UNESTABLISHED, not ruled out. Two sub-claims RETIRE: extent is now AUTHORED (`450×72`, not a placeholder size) and `MaxConcurrentItems` is now AUTHORED (`4`, not retail's code-default `1`). **CH USER-GATE ROUND 1 (2026-08-09):** colour PINS — the user tested live, side-by-side against retail, and confirmed the on-screen SpewBox text is the same bright yellow as an incoming Tell (`0x81C4C8`, `RetailChatColorTable.Yellow` = `(1, 1, 0.247, 1)`); `SpewBoxController.SpewBoxColor` now uses that exact value. The user's SAME live pass also reported that SIZE, POSITION, and FONT still visibly differ from retail — so despite extent's earlier AUTHORED status above, size is user-gate round 1: differs, iterating (re-opened pending a follow-up measurement pass, not yet root-caused). Three sub-claims therefore REMAIN open: (1) absolute screen position — the recovered position is `(0,0)` RELATIVE TO A PARENT this sweep could not identify (the element is presumably still mounted via the C++ `gmClient` HUD registration block the research doc's §1.1 describes, just parented under something dat-authored rather than the root view directly), so `TopOffset=60px` + a centered `Left` recomputed every frame (corrected from a one-time computation at nit 1 — see `SpewBoxController.Tick`) remain acdream's own placeholder, not a resolved retail value, and the user confirms this is visibly wrong; (2) size/font — the AUTHORED `450×72` extent and whatever font this renders with still do not match what the user sees live; unmeasured which of extent, the unresolved parent scale, or font metrics is the actual cause; (3) vertical content flow — the block now renders TOP-aligned (newest line at the top, via `UiText.VerticalJustify`/`HonorVerticalJustification`, nit 2) because that is the only placement consistent with "newest on top," but retail's own authored vertical justification for this element is unmeasured, so this is also acdream's invention pending measurement, not a resolved retail value. Retail's edge codes (`leftEdge=3`/`rightEdge=3`, "centered" per `ElementReader.ToAnchors`'s own doc comment; `topEdge=1`, top-anchored) confirm the box is a fixed-width centered block, not a full-viewport stretch — `SpewBoxController`'s anchor shape was corrected to match (`AnchorEdges.None` + a centered `Left` recomputed every frame against the current root width, `OneLine=false` since 4 concurrent lines can now actually be visible instead of collapsing to 1). **CH USER-GATE ROUND 3 (2026-08-10):** the user's finding (a) confirmed POSITION and FONT still read wrong live — "not aligned all the way to the top" and "not the correct font and size (retail's is SMALLER)." Both sub-claims close as best-available APPROXIMATIONS, not resolved retail values (a re-run of `SpewBoxLayoutDumpDiagnostic` this round still finds no `FontDid`/colour property on element `0x10000048` or its ListBox child `0x10000049`, confirming the true retail values remain genuinely unmeasurable statically): (1) position — `TopOffset` moves from the round-1 60px placeholder to `0` (flush to the viewport top), per the user's explicit direction; the true retail PARENT remains unidentified. (2) font — `SpewBoxController` now resolves retail dat Font `0x40000025` (`MaxCharHeight=11px`, `Baseline=9px`; confirmed via `AcDream.Cli dump-font-atlas` sweeping every populated font id `0x40000000`-`0x40000032` in the installed DAT) instead of silently falling through to the unwired 15px debug `BitmapFont` every prior round shipped with (no `DatFont`/`Font` was ever set on this element before). `0x40000025` is the SMALLEST font id confirmed in use by any of acdream's currently-imported retail LayoutDesc fixtures (cross-referenced across all `tests/AcDream.App.Tests/UI/Layout/fixtures/*.json` dumps) — it is ALSO the chat window's own smallest font (the `0x2100006F` floating-window 1/2/3/4 indicator badges), so both selection criteria the round-3 brief offered agree on the same id, with no tie to break. Vertical content flow remains OPEN, unchanged from round 1. **CH USER-GATE ROUND 4 (2026-08-10),** `docs/research/2026-08-10-retail-ui-text-style.md`: the earlier "absent from both dats" finding for the SpewBox's own line template (element `0x1000004A`, base style `0x10000377` in layout `0x2100003F`) was WRONG — it was missed because the element is a ROOT of its layout (a children-only walk skips it) and its font/colour live in a BaseElement in a DIFFERENT LayoutDesc plus a NAMED state, not its own DirectState. Font, size, outline, and position all resolve as AUTHORED, closing three of the four remaining sub-claims: (1) font is `0x40000001` (18px bold serif), not the round-3 smallest-font heuristic `0x40000025` — three independent cross-checks (base style FontDID, the 18px authored line height, and 4×18=72=the authored box height); (2) the line template's state `0x10000002` authors property `0x21` (Outline) = `true` with no authored `0x22` (OutlineColour) → ctor default black — the heavy black border the user's screenshot showed and rounds 1-3 never reproduced; (3) position/extent are CONFIRMED authored, not merely user-matched by luck — `0x10000048` is a ROOT element of its own layout, so `pos(0,0)` + edge codes `L3/R3` (centred) + `T1` (top-anchored) resolve to exactly `TopOffset=0` + the per-frame centred `Left` recompute already in place. Only TWO sub-claims remain open: fill COLOUR (the authored `ARGB(255,255,0,0)` for state `0x10000002` still does not match the user's gold/amber screenshot; the font atlas is confirmed `PFID_A8` alpha-only so it cannot carry baked shading — the user-pinned yellow `(1,1,0.247,1)` stands, an exact retail cdb capture of live `m_curFontColor` is the only remaining resolution path) and vertical content flow (still fully OPEN, unchanged from round 1). Separately, the same commit ported the outline MECHANISM generically (`UiDatFont.BorderX`/`BorderY`, `UiRenderContext.DrawStringDat`'s two-pass model, and LayoutDesc property 0x21/0x22 import onto every DAT-imported text element) so the SpewBox is no longer a special case. | `src/AcDream.App/UI/SpewBoxController.cs`; `src/AcDream.Core/Chat/SpewBoxState.cs` (`MaxConcurrentItems`); `src/AcDream.App/UI/UiText.cs` (`HonorVerticalJustification`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`Assets` accessor, round 3) | Colour is CONFIRMED, not a placeholder — CH user-gate round 1 (2026-08-09) pinned it against the user's own live side-by-side retail observation, not a recollection. A live cdb capture of `gmSpewBoxUI`'s runtime rect/state (or walking the `States` dictionary this pass skipped, or identifying the C++-assigned parent) remained the resolution path for position/size/font before round 4 resolved all three as AUTHORED (see the round-4 paragraph above); an exact cdb capture of live `m_curFontColor` is now the only remaining resolution path, for fill colour and vertical content flow | SpewBox text may render in the wrong absolute screen location, size/font, or vertical flow versus retail — all three CONFIRMED wrong by the user's CH round-1 live pass, not merely suspected; colour is CLOSED and no longer a risk. The size/max-items risk this row originally recorded ("bursts of refusals collapse to one visible line where retail's authored ListBox may show more") is RETIRED — up to 4 now render, matching the authored value, though the box's overall size still visibly differs from retail per the user. Round 3 (2026-08-10) closes the position/font risks as best-available approximations (flush-top mount, smallest confirmed-used retail font) rather than resolved retail values — the box may still not sit at retail's true pixel position/size, and the exact retail font remained genuinely unmeasurable — round 4 (2026-08-10) resolves both as AUTHORED (see the round-4 paragraph above), retiring this risk for position/font entirely. Only fill colour (the authored red does not match the user's gold screenshot; the user-pinned yellow stands) and vertical content flow remain OPEN, unchanged from round 1 | `docs/research/2026-08-09-chat-retail-interface-text.md` §3.2.2-§3.2.4; `tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs`; `src/AcDream.App/UI/Layout/ElementReader.cs` (`ToAnchors`) | | AP-179 | `ChatLog.OnCombatLine`'s generic `0x06` Combat fallback types combat-feedback lines with a single stand-in `LogTextType` for callers with no more specific hit/miss/evade classification in hand, instead of retail's per-message dispatch. Split out of AP-176 (RETIRED 2026-08-09, Campaign CH slice CH2 — the WeenieError half of that bundled row is now the full 344-row `HandleFailureEvent` port, `WeenieErrorMessages.Resolve`); this combat-line half was never in CH2's scope and keeps its own row so the divergence is not silently dropped | `src/AcDream.Core/Chat/ChatLog.cs` (`OnCombatLine`) | `0x06` matches the switch's majority combat-line behavior and is a safe baseline; a full per-combat-message dispatch port is out of Campaign CH's scope | Wrong chat color for the combat-line kinds retail types distinctly (hit/miss/evade variants) | `ClientCommunicationSystem::HandleFailureEvent @0x00571990`; originally filed at the CH1 Opus review 2026-08-09 as part of AP-176, split out at CH2 | | AP-180 | `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed — retail's `ClientSystem::AddTextToScroll(text, type, allowPluginFilter, windowId)` delivers a `type == 0x1A` message with a non-zero `windowId` to BOTH the SpewBox and that specific chat window (research doc §2.3), the shape ~40 slash-command-output sites depend on. acdream's chokepoint routes on `type` alone; every current production caller passes `windowId = 0`, so the gap is latent, not yet visibly wrong. Filed 2026-08-09 at the CH2 REJECT-review rework (NIT 2, `docs/research/2026-08-09-ch2-review-findings.md`) | `src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs` (`AddText`) | No production caller passes a non-zero `windowId` yet, so nothing observably diverges today; implementing the dual-destination echo is CH4/CH5 scope at the earliest | A future slash-command-output caller that passes a non-zero `windowId` expecting it to echo into its originating chat window (matching retail) will silently land in the SpewBox only | `ClientSystem::AddTextToScroll @0x00563C50`; `docs/research/2026-08-09-chat-retail-interface-text.md` §2.3 | | AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | @@ -341,6 +341,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-188 | **Filed 2026-08-10 (Campaign CH slice CH6b — floating chat windows).** A floating chat window's chat entry always sends on the `Say` channel (`FloatingChatWindowController.Bind`'s `OnSubmit` hardcodes `ChatChannelKind.Say`). The floaty LayoutDesc (`0x2100005B`) authors no talk-focus menu (window-shell research doc §2.2 — only the main window's layout has one, element `0x10000014`), so there is no visible channel picker on a floaty window either way, matching retail's authored UI exactly. What is UNVERIFIED is whether retail's actual SEND path for a floaty window's typed message reads a per-window channel or the single globally-current talk-focus channel/target the main window's menu (or the last-selected/last-speakable-target state `gmMainChatUI::UseTime @0x004CDB20` tracks) last set — if the latter, a real retail floaty window would send on whatever channel the player most recently picked from the MAIN window, not always `Say`. Confirming this requires tracing `gmCCommunicationSystem`'s send-command path from a floaty `ChatInterface` instance, not yet done. Filed as ISSUES.md #369. `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` (`Bind`, the `OnSubmit` wiring) | Building genuine cross-window shared-channel state (reading `ChatWindowController`'s private `_activeChannel` from four independent sibling controllers, or promoting it to a shared owner) is a real design decision outside this slice's explicit scope (task items 1-6 do not ask for cross-window channel sharing); `Say` is retail's own default channel and the safest fixed value absent confirmation | If retail's actual mechanism is "send on the currently-selected global channel," a user who selects e.g. Fellowship from the main window's talk-focus menu and then types into a floaty window would see it sent as Fellowship in retail but as Say in acdream — no data loss (the message still sends), only channel-selection mismatch | `gmMainChatUI::InitTalkFocusMenu @0x004CDC50`; `gmMainChatUI::UseTime @0x004CDB20`; `docs/research/2026-08-09-chat-retail-window-shell.md` §2.2 | | AP-189 | **Filed 2026-08-10 at the CH6a/b REJECT-review rework (SHOULD-FIX 5, `docs/research/2026-08-10-ch6ab-review-findings.md`).** Retail keeps a PER-`ChatInterface` `m_chatLog`, truncated at 10,000 lines (`ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4711` → `TruncateChatLog`) — each of the five windows (main + 4 floaty) owns its OWN 10,000-line backlog, and a closed window keeps accumulating into its own log because `gmFloatyMainChatUI::SetVisible @0x004CE9B0` never unregisters the handler. acdream instead shares ONE canonical `ChatLog` capped at 500 entries (`RuntimeCommunicationState`'s ctor, `maximumChatEntries: 500`) with a 200-line display tail every window filters from (`InteractionRetainedUiComposition.cs:564`'s `displayLimit: 200` feeding `ChatVM.RecentLinesDetailed`; `ChatWindowState.ShouldDisplay` does the per-window filtering). The accumulate-while-closed and independent-per-window-scroll BEHAVIORS both fall out correctly from this shared-log shape, but the EFFECTIVE per-window scrollback DEPTH differs from retail's: a window whose filter accepts only a rare message type (e.g. a Fellowship-only floaty) sees only the fellowship lines that happen to still be inside the shared log's last 200-of-500 lines, not up to 10,000 like retail's own per-window log. `src/AcDream.Core/Chat/ChatLog.cs` (`_maxEntries`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs:564` (`displayLimit: 200`); `src/AcDream.App/UI/Layout/ChatWindowController.cs`/`FloatingChatWindowController.cs` (`GetTranscriptLines`) | A single shared canonical log matches acdream's Slice-J "one canonical transcript, many filtered presentations" pattern and keeps memory bounded regardless of how many windows are open; 500 shared entries covers many minutes of typical mixed-channel play, and both retail-observable BEHAVIORS this row could have broken (closed-window accumulation, independent per-window scroll position) are reproduced correctly — only the numeric DEPTH ceiling differs | In a busy mixed-channel session (heavy General/Trade traffic), a rarely-used channel (Fellowship, a Turbine room) can scroll out of the shared 500-entry window long before a floaty window filtered to just that channel would have neared retail's 10,000-line depth — a user who opens that floaty window after a long session sees a much shorter backlog than retail would show for the same play session | `ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640`/`TruncateChatLog @0x004F4711`; `gmFloatyMainChatUI::SetVisible @0x004CE9B0`; `docs/research/2026-08-09-chat-retail-window-shell.md` §1.2 | | AP-190 | **Filed 2026-08-10 (Campaign CH slice CH6c — window opacity + transparency setting; retires AP-40). AMENDED 2026-08-10 at the CH6c review-fix round: reworded (2), added (3)/(4).** Four divergences from retail's focus-driven window opacity, all decomp-verified (`docs/research/2026-08-09-chat-retail-window-shell.md` §3). (1) SCOPE: retail's `ChatInterface::SetOpacity`/`SetDefaultOpacity`/`SetActiveOpacity` only ever run on `ChatInterface`-derived windows (the main chat window + the four floaties) — every other retail window (vitals, toolbar, inventory, ...) has no opacity fade at all. acdream's `RetailWindowOpacityController` subscribes to `RetailWindowManager.WindowRegistered` and applies the SAME focus-driven fade to every window the manager ever registers, so the one Settings → Chat tab transparency slider pair affects the whole retained UI. (2) DEFAULT VALUE — REWORDED at the review-fix round: retail's shipped defaults are PER WINDOW CLASS — the base `ChatInterface` ctor (`0x004F4550`) sets DefaultOpacity=0.5/ActiveOpacity=1.0, but `gmMainChatUI`'s own ctor (`0x004CD0F0`, called after the base ctor) overrides DefaultOpacity to 1.0 (the main window is ALWAYS fully opaque in both states); `gmFloatyChatUI::Create` (`0x004CE2C0`) calls the base ctor directly with no override, so only the four floating windows keep 0.5/1.0. acdream originally shipped the base ChatInterface value (0.5/1.0) as ONE shared global default applied to EVERY registered window — combined with (1)'s scope extension this faded the WHOLE registered UI (radar, vitals, toolbar, main chat, ...) to 50% opacity out of the box, including several windows that can never take keyboard focus at all and so were PERMANENTLY stuck at 0.5. Fixed at the review round to `gmMainChatUI`'s 1.0/1.0 override as the shared default instead: this reduces the remaining divergence to acdream's four floating chat windows shipping OPAQUE where retail's floaties ship 0.5-while-idle — user-settable via the same Settings → Chat opacity slider pair, so it is now a default-VALUE divergence only, not a missing mechanism. (3) EASING (new, filed at the review-fix round): retail's `ChatInterface::ListenToGlobalMessage @0x004F3840` — armed on the focus element-messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E` at `0x004F5275` via `UIListener::RegisterForGlobalMessage(this, 3)` — eases the live opacity toward its target by 5% of the target-delta per tick, unregistering from the global tick once within FP-epsilon of the target. acdream's `RetailWindowOpacityController.Apply` snaps to the target opacity immediately on every focus-change event; porting the per-tick lerp needs a UI frame-tick hook the controller does not have today, so it is deferred rather than implemented this round. (4) FOCUS PREDICATE (new, filed at the review-fix round): retail's `ChatInterface::IsTextEntryFocused @0x004F30A0` tests specifically whether `GetFocusDescendant(rootElement) == this->m_chatEntry` — the chat ENTRY FIELD, not the window generally. acdream's `RetailWindowHandle.DescendantFocusChanged` fires whenever ANY focusable descendant of the window gains focus, a strictly broader predicate for any window with more than one focusable child. The linked active>=default invariant itself (`SetDefaultOpacity`/`SetActiveOpacity`'s mutual-correction bodies) IS ported exactly — `ChatOpacityLink` in `AcDream.UI.Abstractions`. | `src/AcDream.App/UI/RetailWindowOpacityController.cs`; `src/AcDream.App/UI/RetailWindowManager.cs` (`WindowRegistered`); `src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs`; `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs` (`DefaultOpacity`/`ActiveOpacity`) | Extending the fade to every window is the shape the user's requested "transparency setting" actually wants (a general UI preference, not a chat-only one); shipping the shared default at 1.0 keeps the out-of-box render retail-identical for the 11 non-chat windows AND the main chat window (the windows retail keeps opaque, several of which can never take focus at all), while the Settings → Chat transparency slider remains fully user-settable for anyone who wants the four floaties' retail translucence back. (3) and (4) are both presentation-only refinements — the fade direction and the linked-invariant math stay retail-exact, only the transition curve (snap vs. 5%-per-tick ease) and the focus predicate's granularity (any descendant vs. the text-entry specifically) diverge — so recording them without implementing the frame-tick hook (3) or narrowing the focus event (4) is the correct scope for a review-fix round rather than opening new implementation work | A user who compares acdream's default install against retail side-by-side now sees the 11 non-chat windows AND the main chat window matching (opaque); only the four floating chat windows still diverge (opaque vs. retail's 50%-while-idle) until the slider is dragged. (3) is visible as the opacity change happening in a single frame instead of retail's ~20-tick fade — low severity, since the START and END states are both retail-exact, only the transition is instant instead of eased. (4) is visible on any window with more than one distinct focusable descendant (e.g. a settings panel with several controls): acdream stays at ActiveOpacity while ANY of them holds focus, where retail would already have faded back to DefaultOpacity once focus left the specific text-entry element — for single-focusable-child windows (most of the retained UI today) the two predicates coincide and there is no observable difference | `ChatInterface::ChatInterface @0x004F4550`; `gmMainChatUI::gmMainChatUI @0x004CD0F0`; `gmFloatyChatUI::Create @0x004CE2C0`; `ChatInterface::SetDefaultOpacity @0x004F3BC0`/`SetActiveOpacity @0x004F3C40`; `ChatInterface::ListenToGlobalMessage @0x004F3840`; `ChatInterface::IsTextEntryFocused @0x004F30A0`; global-message arming switch @0x004F5275 (`UIListener::RegisterForGlobalMessage(this, 3)` on element messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E`) | +| AP-191 | **Filed 2026-08-10 (Campaign CH round 4, user-gate items 1+2 — retail two-plane glyph outline + authored SpewBox/chat text style, `docs/research/2026-08-10-retail-ui-text-style.md`).** The chat transcript's authored BASE STYLE (`0x10000372` in layout `0x2100003F`) carries a `0x1C`/`0x1D` pair alongside its `0x1A`/`0x1B` — `0x1D` (`TagFontColor[]`) is confirmed authored `ARGB(255,0,178,0)` (green), and `0x1C` is UNVERIFIED but most likely `TagFontDID` by symmetry with `0x1D` (both are pull-based, no `OnSetAttribute` case, unlike `0x1A`/`0x1B`/`0x21`/`0x22` which this round's commit DOES import). Retail's `AppendTextWithFont` selects a font/colour PAIR per appended run via `SetFontDIDNum`/`SetFontColorNum`, so a message's `[General]`-style channel tag can render in a distinct colour/font from the rest of the line — a capability `UiText.Line` does not have (one `Color` per whole line, no sub-line run concept). Landing this needs a per-run tag boundary threaded from `ChatTranscriptRenderer.BuildLines` through `UiText`'s line model into `UiRenderContext.DrawStringDat`, deliberately out of this round's scope (Fix 5 only changed the DEFAULT/uncolored-run seed, not the run model). `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`BuildLines`); `src/AcDream.App/UI/UiText.cs` (`Line`) | The default-fill fix (this same commit) is the higher-value, lower-risk half of retail's text-style gap for the transcript; a per-run tag concept is a larger structural change (touches the line model every transcript consumer reads) better landed as its own reviewed slice than folded into a text-style bugfix commit | Retail's `[General]`/channel-name tag prefix on a chat line renders the SAME colour as the rest of the line in acdream instead of green, and any authored tag-specific font goes unused — cosmetic only, the message text itself is unaffected | `UIElement_Text::AppendTextWithFont @0x00469de0`; `UIElement_Text::SetFontColorHelper @0x00466ac0`; `docs/research/2026-08-10-retail-ui-text-style.md` §2.3/§2.6 | ## 4. Temporary stopgap (TS) — 39 active rows (TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index 22fb591e..07c58dad 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -52,15 +52,23 @@ shell is now CODE-COMPLETE. **User gate round 4 ran 2026-08-10 and found six findings; see "User gate — round 4" below.** Items 3 (user-visible `/help` meta-markers leaking into live output, closing #364) and 5 (the main window's 1/2/3/4 indicator buttons now toggle their floating window -on click, per the user's retail memory) are fixed in this round's commit; -item 4 passed with no action needed; items 1+2 (text-style findings) were -under parallel read-only research this session, concurrent with this -commit's item 3+5 work; item 6 is deferred to the settings track. Status -stays CODE-COMPLETE pending the next user gate round (still needed for -CH6a's own visual confirmation, CH6b's keybind/mirror/filter behavior, -CH6c's focus-driven fade and Settings slider, round 3's fixes, round 4's -item 3+5 fixes, and a final in-client visual pass on everything fixed so -far). +on click, per the user's retail memory) are fixed at `5b54387b`; item 4 +passed with no action needed; item 6 is deferred to the settings track. +**Items 1+2 (text-style findings) landed CODE-COMPLETE this commit**, off +the parallel read-only research agent's findings +(`docs/research/2026-08-10-retail-ui-text-style.md`): the retail two-plane +glyph-outline mechanism (background atlas inflated by the font's +border-pixel margin, a bug that made "enable the outline flag" alone a +visual no-op), property 0x21/0x22 import so every authored-outline element +gets it for free, the SpewBox's authored font/size/outline (retired two of +AP-178's four open sub-claims), and the chat transcript's authored +default-fill seed (`ARGB(255,204,204,204)`, wired without touching the +34-entry `LogTextType` color table). Status stays CODE-COMPLETE pending the +next user gate round (still needed for CH6a's own visual confirmation, +CH6b's keybind/mirror/filter behavior, CH6c's focus-driven fade and +Settings slider, round 3's fixes, round 4's item 3+5 fixes, round 4's item +1+2 text-style fixes, and a final in-client visual pass on everything fixed +so far). **Why now:** first track of the alpha-release program (chat is the most visible daily surface for the friend-alpha). User-directed 2026-08-09. @@ -298,7 +306,8 @@ implementer per slice against a pinned contract (per | CH6c opacity | `a819687c` | 12,459 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed this commit — BLOCKER (out-of-box `DefaultOpacity` 0.5→1.0), AP-190 reworded + two new decomp-verified clauses (retail's per-tick ease, retail's entry-field-specific focus predicate), NITs (`UiElement.cs` stale comment, `WindowUnregistered` detach, post-Dispose `Set*` guards, `DrawString`/outline-pass alpha tests) | pending — needs the next connected round for visual confirmation (window fade on focus change, Settings slider live-apply) | | Goal-window #363/#367 interface-text seam | `09453eca` | 12,542 passed / 4 skipped / 0 failed | implementer-only, no subagent review this session (hard constraint) | pending — needs the next connected round to confirm the SpewBox now flashes for the reclassified refusals (see "Goal-window follow-up" below) | | Consolidated review — retail `/help` Detail extraction, seam wiring test | `f7a6f46b` | 12,553 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed this commit — SHOULD-FIX 1 (42 of 47 catalog leaf verbs given retail Detail_HelpType text, 4 confirmed-null, 1 honest UNVERIFIED, retiring the class doc's overclaim), SHOULD-FIX 2 (`ComposedChatViewModelWiresOnInterfaceTextToSpewBox` — the a5a7eb4f defect class had no test), SHOULD-FIX 3 (AP-113 RETIRED — Lifestone's and Marketplace's own bespoke bad-args refusal text recovered byte-exact), SHOULD-FIX 4 (register header's stale 0.5/1.0 sentence corrected), NITs (a)-(d) (`HeadlessDiagnosticWriter` instead of `Console.WriteLine`, bounded non-quiescent-pump liveness diagnostic, hydration test doc-comment contradiction, 0x26 fallback dispatches on its own `Type`) | pending — needs the next connected round to confirm `/help ` now shows retail's exact wording (see "Consolidated review" below) | -| User gate round 4 — items 3+5 (no meta text, indicator click-toggle) | this commit | 12,579 passed / 4 skipped / 0 failed | implementer-only, no subagent review this session (hard constraint) | pending — needs the next connected round to confirm `/help channels`/`chatting`/`commands`/`messagetypes` show complete retail text and clicking each indicator button toggles its floating window (see "User gate — round 4" below) | +| User gate round 4 — items 3+5 (no meta text, indicator click-toggle) | `5b54387b` | 12,579 passed / 4 skipped / 0 failed | implementer-only, no subagent review this session (hard constraint) | pending — needs the next connected round to confirm `/help channels`/`chatting`/`commands`/`messagetypes` show complete retail text and clicking each indicator button toggles its floating window (see "User gate — round 4" below) | +| User gate round 4 — items 1+2 (retail two-plane glyph outline, authored SpewBox/chat styles) | this commit | 12,610 passed / 4 skipped / 0 failed | implementer-only, no subagent review this session (hard constraint) | pending — needs the next connected round to confirm the SpewBox's heavy black border and the chat transcript's softer default shade (see "User gate round 4, items 1+2" below) | ### CH4 closeout (2026-08-09) @@ -1085,13 +1094,17 @@ SpewBox. The user tested the goal-window build live and reported six findings. A parallel read-only research agent investigated items 1+2 (text-style -presentation) concurrently with this session's items 3+5 fix work; this -session owned all builds/commits (hard constraint: one commit). +presentation) concurrently with the session that fixed items 3+5 +(`5b54387b`); that session owned its own build/commit (hard constraint: +one commit). Items 1+2 landed CODE-COMPLETE in a separate follow-up +session/commit off the research agent's findings +(`docs/research/2026-08-10-retail-ui-text-style.md`) — see the dispositions +below and the new ledger row. | # | User finding (condensed) | Disposition | |---|---|---| -| 1 | Text-style/presentation finding under the on-screen interface-text and/or `/help` surfaces. | **Research in flight** — a parallel read-only research agent investigated this concurrently with items 3+5; not fixed in this commit. Follow up in the next session once its findings land. | -| 2 | A second text-style/presentation finding, same surface family as item 1. | **Research in flight** — same parallel agent as item 1. | +| 1 | Both the chat-window transcript text AND the on-screen SpewBox text differ from retail in face, size, and colour shade. | **FIXED in the round-4 items-1+2 follow-up commit** (see the new ledger row below — a separate commit from items 3+5's `5b54387b`, per this session's one-parallel-agent/one-commit-per-session-slice pattern). Face/size for BOTH surfaces resolve to the actual authored fonts (chat: `0x40000000`/16px, already correct; SpewBox: `0x40000001`/18px, corrected from a round-3 heuristic). The transcript's default fill now seeds from its authored `ARGB(255,204,204,204)` instead of an unrelated color-table slot, without touching the 34-entry `LogTextType` table. See `docs/research/2026-08-10-retail-ui-text-style.md` §5. | +| 2 | Retail's SpewBox text carries a heavy black border around every glyph that acdream's does not. | **FIXED in the same round-4 items-1+2 follow-up commit as item 1.** Root cause: retail ships a SECOND ("background") glyph atlas per font, dilated 2px on every side, plus two border-pixel scalars (`NumHorizontalBorderPixels`/`NumVerticalBorderPixels`) that acdream's font reader dropped entirely (zero repo hits for `BorderPixel` before this fix) — so even the pre-existing `outline` parameter drew almost nothing once enabled. Both the missing border-pixel read AND the un-inflated background blit rect are fixed together (either alone is a no-op), `UiRenderContext.DrawStringDat` now runs retail's exact two-pass whole-string outline-then-fill model, and property 0x21/0x22 (Outline/OutlineColor) import onto every DAT-authored text element — not just the SpewBox — so this class of bug cannot recur element-by-element. The SpewBox's own line template authors outline ON with no colour (ctor black default), matching the user's screenshot. | | 3 | User-visible meta-markers ("IMPLEMENTED", "acdream has not yet extracted…"-style notices) leaking into live `/help` output. | **FIXED this commit, closes ISSUES.md #364.** Every honesty marker is now gone from user-visible text: `AllegianceOverview`/`HouseOverview`'s `[IMPLEMENTED]` tags and trailing "Subcommands NOT marked…" sentences, and `Day`/`Log`/`Render`/`Motd`'s appended "NOT YET IMPLEMENTED in acdream" tails are all removed, with the underlying retail text corrected/completed against the pseudo-C's own pristine consolidated data dumps (`Log` and `Motd` had also been silently truncated; `Render` had been entirely acdream-authored and is replaced with the real retail usage string). The three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings — `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`'s three "vtable slot" operands, previously believed undecodable, are the same pooled/mislabeled-data artifact this campaign has hit before (AP-113's precedent); reading the function's own disassembly for the `push imm32` preceding each constructor call resolves all three directly. `messagetypes` is now a real ported construction (`LogTextTypeEnumMapper::IsLegalChannel`'s 14-id whitelist + `LogTextTypeToString`'s name table + the exact join/wrap format) instead of an acdream summary. Register row AP-184 RETIRED. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` (51 tests, all passing) for the full citation trail. | | 4 | (Not detailed to this session — reported as passed.) | **User-passed**, no action needed this round. | | 5 | The main window's 1/2/3/4 indicator buttons don't open/close the floating chat windows on click — the user's retail memory says clicking should toggle them. | **FIXED this commit.** CH6b's decomp grep (`gmMainChatUI::ListenToElementMessage @0x004CDA80` has no click case for these ids) was TRUE but incomplete — it never checked `UIElement_Button`'s own generic click handler. `UIElement_Button::HandleButtonClick @0x00471E50` reads an Enum property (`0x12`) off the button itself and, if present, routes through `ICIDM`'s action map to `UIElementManager::DoVisibilityToggleAction @0x0045B660` (the SAME function the `Alt+1..4` keybinds reach), which broadcasts element message `0x31` to every element registered as a listener for that action id (via property `0x24`, read once by `UIElement::Initialize`) — the receiving element's generic `UIElement::ListenToElementMessage` base-class handler then toggles its own visibility per its OWN property `0x58`. The committed fixture (`chat_2100006f.json`) confirms the button HALF is genuinely armed: all four indicators carry a real Enum-kind property `0x12` = `0x10000514`-`0x10000517`. But the floating-window fixture (`chat_floaty_2100005b.json`) authors NO matching property `0x24`/`0x58` anywhere, so nothing in the shipped DAT registers a floating chat window as that action's listener — the generic mechanism is real and armed on the button side but has no proven target in the data available to us. Per CLAUDE.md, the user's retail memory is the axiom regardless: `ChatWindowController.BindIndicatorClicks` (new) wires each indicator's click to the SAME `ToggleFloatingChatWindow(windowId)` chokepoint the keybinds use, explicitly as USER-DIRECTED retail behavior. `SetIndicatorOpen` stays the sole writer of the `Selected` mirror (`SuppressSelfToggle` stays `true`) so the visual stays consistent through the click round trip. Full reconciliation in `docs/research/2026-08-09-chat-retail-window-shell.md` §1.4 (the `@0x004CDA80` citation stays true as a statement about that one function; the CONCLUSION is corrected). New tests in `ChatLayoutConformanceTests.cs` exercise the click round trip and confirm keybind and click drive the same chokepoint. | @@ -1109,3 +1122,65 @@ constraint — no client launches) — the next connected round should confirm show complete retail text with no meta-notice, and that clicking each of the four chat-window indicator buttons now opens/closes its floating window while the button's lit/unlit state stays correct. + +### User gate round 4, items 1+2 — text style follow-up (2026-08-10) + +Off the parallel research agent's findings +(`docs/research/2026-08-10-retail-ui-text-style.md`): retail's outline is a +SECOND ("background") glyph atlas per font, dilated 2px on every side, plus +two border-pixel scalars acdream's font reader never read (zero repo hits +for `BorderPixel`) — so even flipping the pre-existing `outline` parameter +was a visual no-op. Landed together (either half alone is a no-op or a +regression): + +1. **`UiDatFont`** carries `BorderX`/`BorderY` from the DAT's + `NumHorizontalBorderPixels`/`NumVerticalBorderPixels` + (`Font::Serialize @0x00443650`). +2. **`UiRenderContext.DrawStringDat`** inflates the background blit's source + AND destination rect by that margin, and restructures into retail's + exact two-pass model — the WHOLE STRING's outline pass, then the WHOLE + STRING's fill pass (`UIElement_Text::DrawSelf @0x00467aa0`), plus the + 8-neighbour ±1px fallback for the rare 0-border font family. The stale + "LayoutDesc property 0xd" comment (actually the `OnSetAttribute` + switch-case index, not the property id) is corrected to 0x21/0x22. +3. **Property 0x21 (Outline) / 0x22 (OutlineColor) import**: + `ElementInfo.Outline`/`OutlineColor`, read in `LayoutImporter.ReadState` + and `ElementReader.ApplyCanonicalLegacyProjection`/`Merge` with the same + "derived wins" convention as `FontDid`, wired onto `UiText.Outline`/ + `OutlineColor` by `DatWidgetFactory.BuildText`. Every one of the ~100 + authored-outline elements across 15 layouts is correct at once, not + fixed controller-by-controller. +4. **SpewBox**: `RetailFontId` corrected from the round-3 heuristic + (`0x40000025`) to the actually-authored `0x40000001` (18px bold serif, + base style `0x10000377`), `Outline = true` set directly on the + controller's `UiText` (it is synthesized, not DAT-imported). Fill colour + stays the user-gate-round-1-pinned yellow `(1,1,0.247,1)` — font atlases + are `PFID_A8` (alpha-only), so there is no baked shading that could + explain the screenshot's gold as anything other than the outline itself + making a bright fill read warmer. Register row AP-178 updated: font, + size, outline, and position are now AUTHORED; only fill-colour + calibration and the AP-177 line-lifetime timeout remain open. +5. **Chat transcript**: default fill now seeds from the authored + `ARGB(255,204,204,204)` (style `0x10000372` property 0x1B) instead of + the color table's own unrelated index-0x00 slot — `ChatTranscriptRenderer.BuildLines` + takes the transcript's own `UiText.DefaultColor` as an explicit + parameter. The 34-entry `LogTextType` table (the per-message color + authority) is untouched — every existing CH1 conformance test + (`RetailChatColorTableTests.cs`, `ChatWindowControllerTests.cs`) stays + green unmodified. No outline on the transcript, matching retail (style + `0x10000372` authors no property 0x21 anywhere in its chain) — pinned + against the real installed DAT via a regenerated `chat_2100006f.json` + fixture, not a JSON-missing-field default. + +Suite: 12,610 passed / 4 skipped / 0 failed (Release, complete solution — +`AcDream.slnx`), up from the round-4 items-3+5 baseline 12,579/4/0 — net ++31 tests, zero regressions (one `LandblockPresentationPipelineTests` +failure reproduces identically on the pristine pre-this-session baseline — +pre-existing run-order flakiness in an unrelated streaming test, confirmed +via `git stash` before touching any code; it did not reproduce at all +during this round's own full-solution runs). No subagent review this session +(hard constraint); no connected user gate for this commit's own changes +(hard constraint — no client launches) — the next connected round should +confirm the SpewBox now shows a heavy black border in the authored 18px +face, and that the chat transcript's default text shade reads slightly +softer than pure white. diff --git a/docs/research/2026-08-10-retail-ui-text-style.md b/docs/research/2026-08-10-retail-ui-text-style.md new file mode 100644 index 00000000..506ee463 --- /dev/null +++ b/docs/research/2026-08-10-retail-ui-text-style.md @@ -0,0 +1,616 @@ +# Retail UI text style — font resource, style model, outline pass, SpewBox, acdream gap list + +**Date:** 2026-08-10 +**Status:** RESEARCH ONLY — no production code changed. +**Trigger:** Campaign CH user-gate round 4 (2026-08-10). The user reports that BOTH the +chat-window transcript text and the on-screen SpewBox text differ from retail in face, size, +colour shade, and "shading" — retail's text carries a heavy black border that ours does not. +User-supplied retail screenshot of the SpewBox showing "You can't jump while in the air": +golden/amber fill, **strong full black outline around every glyph** (a border, not an offset +drop-shadow), bold serif face, horizontally centred at the very top of the viewport. + +**Primary sources** +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named) +- `docs/research/named-retail/acclient.h` (verbatim retail struct definitions) +- The installed DATs at `%USERPROFILE%\Documents\Asheron's Call\` read through + `Chorizite.DatReaderWriter` 2.1.7 — the same reader `DatCollection` uses in production. + All measurements below were taken with a throwaway scratchpad probe (not committed). + +**Headline result.** Retail's outline is not a shadow and not a shader effect. Every retail +`Font` DAT resource ships **two** glyph atlases — a foreground (fill) plane and a background +(outline) plane whose glyphs are the fill glyphs **dilated by 2 px on every side** — plus the +per-font `NumHorizontalBorderPixels` / `NumVerticalBorderPixels` that say how far to inflate +the blit rectangle to capture that dilation. acdream loads both atlases but (a) never enables +the outline pass at any production call site and (b) blits the background plane with the +**un-inflated** rect, so even switching the flag on would draw almost nothing. Both must be +fixed together. + +--- + +## 1. THE FONT RESOURCE (`DB_TYPE_FONT`, `0x40xxxxxx`) + +### 1.1 Wire layout — `Font::Serialize @ 0x00443650` + +Fields in serialization order (all `Archive::CheckAlignment(4)` then `GetBytes`): + +| Offset in stream | Type | Field | Notes | +|---|---|---|---| +| — | — | `DBObj::Serialize` | the DID header | +| +0 | `uint32` | `maxCharHeight` | line height | +| +4 | `uint32` | `maxCharWidth` | used by the fixed-width advance mode | +| +8 | `uint32` | `numCharacters` | | +| +12 | `numCharacters × 11 bytes` | `charDescs` | `esi_1 = numCharacters * 0xb` @ `0x004436df` | +| | `uint32` | `m_NumHorizontalBorderPixels` | @ `0x00443750` | +| | `uint32` | `m_NumVerticalBorderPixels` | @ `0x00443778` | +| | `int32` | `m_BaselineOffset` | @ `0x004437a0` | +| | `uint32` | `m_ForegroundSurfaceDataID` | @ `0x004437c8` | +| | `uint32` | `m_BackgroundSurfaceDataID` | @ `0x004437f3` | + +`FontCharDesc` (`acclient.h:4129`, `__unaligned align(1)`, exactly 11 bytes): +`u16 m_Unicode; u16 m_OffsetX; u16 m_OffsetY; s8 m_Width; s8 m_Height; s8 m_HorizontalOffsetBefore; s8 m_HorizontalOffsetAfter; s8 m_VerticalOffsetBefore;` + +`Font : DBObj` (`acclient.h:4130`) also holds runtime-only members +(`m_pForegroundSurface`, `m_pBackgroundSurface`, `characterMap`) that are **not** serialized. + +**Verified struct offsets** (from `DrawCharacter`'s raw field reads, which cross-check the +header): `Font+0x40 = m_NumHorizontalBorderPixels`, `Font+0x44 = m_NumVerticalBorderPixels`, +`Font+0x60 = m_pBackgroundSurface`. That fixes `sizeof(DBObj) == 0x30` and confirms the +field order above. + +### 1.2 So: YES, the resource carries outline data — as a second glyph PLANE + +`m_BackgroundSurfaceDataID` is a full second `RenderSurface` of the *same dimensions* as the +foreground, containing the same glyphs at the same `(OffsetX, OffsetY)` **dilated outward**. + +**Measured** (scratchpad probe against the installed `client_portal.dat`; alpha bounding box +of each glyph inside its inflated window, i.e. `(OffsetX−hB, OffsetY−vB, W+2hB, H+2vB)`): + +| Font | border | glyph | FG bbox in window | BG bbox in window | +|---|---|---|---|---| +| `0x40000000` | 4,4 | `A` (9×8) | `(4,4)-(12,11)` 9×8 | `(2,2)-(14,13)` **13×12** | +| `0x40000000` | 4,4 | `o` (5×5) | `(4,4)-(8,8)` 5×5 | `(2,2)-(10,10)` **9×9** | +| `0x40000001` | 4,4 | `W` (13×9) | `(4,4)-(16,12)` 13×9 | `(2,2)-(18,14)` **17×13** | +| `0x40000025` | 3,3 | `A` (6×6) | `(3,3)-(8,8)` 6×6 | `(1,1)-(10,10)` **10×10** | + +The FG glyph always sits at exactly `(hB, vB)` inside the inflated window — i.e. the source +rect for the background plane is offset by exactly `−(hB, vB)` — and the BG glyph is the FG +glyph grown by **+2 px on every side**, uniformly, for every font measured. The border-pixel +value (3–6) is the *margin the atlas reserves*; the actual dilation is 2 px. Inflating the +blit by the full border is therefore correct and harmless (the ring beyond +2 is zero alpha). + +Correlating evidence from the full font sweep (`0x40000000`–`0x40000032`, 49 fonts): +**every font that has a background atlas has `border >= 3`, and every font without one has +`border == 0`.** The first glyph row also begins at `OffsetY == vBorder` in bordered fonts +(`0x40000000` VB=4 → glyphs at `OffsetY=4`; `0x40000025` VB=3 → `OffsetY=3`) and at +`OffsetY == 0` in unbordered fonts (`0x40000021`). The margin exists precisely so the +inflated source rect stays in bounds. + +All font atlases are `PFID_A8` (alpha-only, value 28 = `0x1C` — see §3.2). + +### 1.3 Font inventory relevant to this report + +| Font | MaxCharHeight | MaxCharWidth | Baseline | hB,vB | glyphs | fg / bg surface | atlas | +|---|---|---|---|---|---|---|---| +| `0x40000000` | 16 | 16 | 12 | 4,4 | 1050 | `0x06005EE5` / `0x06005EE6` | 1024×312 A8 | +| `0x40000001` | **18** | 19 | 14 | 4,4 | 1050 | `0x06005EE7` / `0x06005EE8` | 1024×361 A8 | +| `0x40000002` | 14 | 15 | 11 | 3,3 | 1050 | `0x06005ECF` / `0x06005ED0` | 1024×214 A8 | +| `0x40000025` | 11 | 13 | 9 | 3,3 | 1050 | `0x06005F08` / `0x06005F09` | 1024×184 A8 | +| `0x4000000E`…`0x40000014` | 24…48 | — | — | 4–6 | 210 | — | the decorative uncial "title" family | +| `0x40000017/18`, `0x40000021/22/23` | — | — | — | **0,0** | 20609 | fg only | CJK/unicode, **no outline plane** | + +`0x40000000`/`0x40000001`/`0x40000002` are the same **bold serif** face at 16/18/14 px — the +classic AC UI face. Rendered proof (probe, font `0x40000001`, gold fill, black outline, +inflated bg pass) reproduces the user's screenshot exactly. The `0x4000000E`+ family is the +decorative celtic/uncial display face and is **not** what the user photographed. + +### 1.4 How acdream reads it today — and what it drops + +`src/AcDream.App/UI/UiDatFont.cs:93` `UiDatFont.Load`: +- reads `MaxCharHeight` → `LineHeight` ✅ +- reads `BaselineOffset` → `BaselineOffset` ✅ (stored but never used — see §3.4) +- uploads `ForegroundSurfaceDataId` and `BackgroundSurfaceDataId` ✅ +- builds the `char → FontCharDesc` map ✅ +- **never reads `NumHorizontalBorderPixels` / `NumVerticalBorderPixels`** ❌ + +The reader exposes them: `DatReaderWriter.DBObjs.Font` has public fields +`NumHorizontalBorderPixels` and `NumVerticalBorderPixels` (verified by reflection over +`DatReaderWriter.dll` 2.1.7). A repo-wide grep for `BorderPixel` returns **zero** hits. + +**Verdict for Q1:** the resource carries a dedicated outline plane plus the two border-pixel +scalars that make it usable. acdream loads the plane but drops the scalars, and therefore +cannot address the plane correctly. + +--- + +## 2. THE TEXT STYLE MODEL + +### 2.1 The element property table — `UIElement_Text::OnSetAttribute @ 0x0046a640` + +The switch is **biased**: `switch (BaseProperty::GetPropertyName(arg2) - 0x14)` at +`0x0046a674`. Adding `0x14` back gives the real LayoutDesc property ids: + +| Property id | Meaning | Handler | +|---|---|---| +| `0x14` | HorizontalJustification (enum) | `SetHorizontalJustification` | +| `0x15` | VerticalJustification (enum) | `SetVerticalJustification` | +| `0x16` | Editable (bool) | `SetEditable` | +| `0x17` | StringInfo — the text | `SetStringInfo` | +| `0x1A` | **FontDID array** | `DoFontReset` | +| `0x1B` | **FontColor array** | `DoFontReset` | +| `0x1C` | *(second DID array — no `OnSetAttribute` case)* | pull-based; see §2.3 | +| `0x1D` | **TagFontColor array** | `DoFontReset` | +| `0x1E` | MaxCharacters (int) | `m_glyphList.m_nMaxCharacters` | +| `0x1F` | NoIme (bool) | `SetNoIme` | +| `0x20` | OneLine (bool) | `SetOneLine` | +| **`0x21`** | **Outline (bool)** | **`SetOutline` @ `0x0046a81c`** | +| **`0x22`** | **OutlineColor (color)** | **`m_curOutlineColor` @ `0x0046a6bf`**, then `DoFontReset` | +| `0x23`/`0x24`/`0x25`/`0x26` | margL / margR / margU / margD (int) | `m_margL` … `m_margD` | +| `0x27` | Selectable (bool) | `SetSelectable` | +| `0x28` | TrimFromTop (bool) | `m_glyphList.m_bTrimFromTop` | +| `0x29` | FitToText (bool) | `SetFitToText` | +| `0xC7` | TruncateTextToFit | — | +| `0xCB` / `0xCC` | LoseFocusOnEscape / LoseFocusOnAcceptInput | — | + +> **Correction to an existing acdream comment.** `src/AcDream.App/UI/UiRenderContext.cs:221` +> says the outline flag is "LayoutDesc property 0xd". That is the *switch case index*, not the +> property id. The authored property id is **`0x21`**; the outline colour is **`0x22`**. + +### 2.2 Defaults — `UIElement_Text::UIElement_Text @ 0x00468570` + +- `m_bitField = 0x300` (`0x00468 5eb`) → **bit `0x10` (outline) starts CLEAR**. Outline is + opt-in per element. +- `m_curFontColor = RGBAColor_White` +- `m_curTagFontColor = RGBAColor_White` +- `m_curOutlineColor = RGBAColor_Black` (`0x004686cb`, packed from `RGBAColor_Black`) +- `m_eHorizontalJustification = 2`, `m_eVerticalJustification = 4` +- default font from `DBObj::GetByEnum(0x2e /*Font DBObj type*/, 9, 0x18)` with fallback + `(0x2e, 9, 3)`. + +`m_curOutlineColor` has exactly two writers in the whole binary: the ctor (black) and +property `0x22` (`0x0046a6bf`). **UNVERIFIED-adjacent but well-supported:** because no +element in the shipped DATs authors `0x22` on any text that matters here (see §2.5), the +effective outline colour everywhere is **pure black** — matching the user's screenshot. + +### 2.3 Font/colour are ARRAYS with a runtime slot index + +`UIElement_Text::SetFontDIDHelper @ 0x00466960` does +`vtable->InqProperty(0x1A) → array → element[arg4] → InqDataID → FontLocal::GetGlobalOrLocalFont(did)`. +`SetFontColorHelper @ 0x00466ac0` is the same for a colour array. + +- `SetFontDIDNum(n)` @ `0x00468290` → `SetFontDIDHelper(0x1a, &m_curFontObj, n)` +- `SetFontColorNum(n)` @ `0x004682b0` → `SetFontColorHelper(0x1b, &m_curFontColor, n)` **and** + `SetFontColorHelper(0x1d, &m_curTagFontColor, n)` +- `DoFontReset` @ `0x0046a560` → all three with index **0** +- `AppendStringInfoWithFont(info, fontNum, colorNum)` @ `0x00469de0` sets the slots then appends + +So a single element can carry e.g. `0x1B = [white, green, red, yellow]` and code selects the +slot. The DAT sweep confirms multi-entry colour arrays exist +(`[white, green, red]` ×7, `[white, green, red, yellow]` ×2). + +`0x1C` is authored as a DID array alongside `0x1D` TagFontColor on every "tag-capable" style +(e.g. style `0x10000372` authors `0x1C = [0x40000000]`, the same DID as its `0x1A`). +**UNVERIFIED:** most likely `TagFontDID` (the font used for the `[General]`-style tag run), +by symmetry with `0x1D` and because it has no `OnSetAttribute` case (pull-based, like +`0x1A`/`0x1B` are for the helpers). Cheapest resolution: grep `InqProperty(0x1c` in a fresh +Ghidra decompile of `GlyphList::AddText`/`UIElement_Text::AddText_Internal`. + +### 2.4 State resolution — which colour is live + +`UIElement::Initialize @ 0x00462c90`: +1. `SetState(m_desc.m_defaultState)` +2. then iterate `m_desc.m_properties` calling `OnSetAttribute` on each. + +`UIElement::SetState @ 0x00464e70` re-derives the effective property collection +(`m_desc.m_properties` ⊕ `state.m_properties` ⊕ `m_instanceProperties`) for the OLD and NEW +state and re-fires `OnSetAttribute` for every property whose value **changed**. So a state +change re-drives font, colour, and the outline flag. If the requested state has no +`StateDesc`, `SetState` falls back to state 0 (the DirectState). + +### 2.5 What the DATs actually author + +All 101 `LayoutDesc`s live in `client_local_English.dat` only; all fonts live in +`client_portal.dat` (verified per-file — there is no duplicate-definition ambiguity). + +**Global sweep for property `0x21`/`0x22`:** outlining is used sparingly and deliberately — +roughly 100 authored `0x21 = true` rows across ~15 layouts, and only **9** authored `0x22` +outline colours in the entire DAT set (values `ARGB(255,17,15,7)` and `ARGB(255,0,0,102)`). +Everything else inherits the black ctor default. + +**Layout `0x2100003F` is retail's text style sheet** — 30+ `type 0x0C` style prototypes that +other layouts inherit through `BaseElement`/`BaseLayoutId`. The two that matter: + +``` +elem 0x10000372 (CHAT TRANSCRIPT STYLE) + 0x14 HJustify = Enum(2) -> Left + 0x15 VJustify = Enum(4) -> Bottom + 0x1A FontDID[] = [0x40000000] -> 16 px bold serif + 0x1B FontColor[] = [ARGB(255,204,204,204)] + 0x1C = [0x40000000] + 0x1D TagFontColor[]= [ARGB(255,0,178,0)] (green) + 0x23 margL = 0, 0x25 margU = 0 + (states) Normal_rollover/Highlight -> white; Normal_pressed/Ghosted -> (51,51,51) + *** NO 0x21 -> the chat transcript is NOT outlined in retail *** + +elem 0x10000377 (SPEWBOX LINE STYLE) + 0x14 HJustify = Enum(1) -> Center + 0x15 VJustify = Enum(1) -> Center + 0x1A FontDID[] = [0x40000001] -> 18 px bold serif + 0x1B FontColor[] = [white] + 0x23 margL = 0, 0x25 margU = 0 + (state Ghosted) 0x1B -> (76,76,76) +``` + +The gold + outline family (`0x10000382`–`0x10000388`) uses the decorative display fonts +`0x4000000E`–`0x40000014` with `0x1B = ARGB(255,255,242,127)` and `0x21 = true` — that is the +big centred announcement text, **not** the SpewBox. + +### 2.6 The chat window — `LayoutDesc 0x2100006F` + +Full tree walked. The transcript is element **`0x10000011`** (not `0x10000010`, which is its +`type 0x03` container panel): + +``` +/0x10000600/0x10000010/ elem 0x10000011 type 0x00000000 (UIElement_Text) + base = element 0x10000372 in layout 0x2100003F <-- the style above + pos(16,0) size(368x73) + own DirectState props: 0x23 margL = 2, 0x24 margR = 2, 0x27 Selectable = true, + 0x51 = true, 0x72 = Enum(0x10000012), 0xCD = Enum(3) + NO own 0x1A / 0x1B / 0x21 / 0x22 +``` + +**Chat font truth:** face `0x40000000`, 16 px line height, baseline 12, **no outline**, +default fill `ARGB(255,204,204,204)`, tag colour green `(0,178,0)`, left-justified, +bottom-justified, 2 px left/right margin. Per-line colours come from +`ChatInterface`'s colour table stamped onto the glyph runs (already ported — see +`docs/research/2026-08-09-chat-retail-color-table.md`), with `(204,204,204)` as the element +default when a run carries none. + +--- + +## 3. THE SHADOW/OUTLINE PASS + +### 3.1 Two whole-string passes — `UIElement_Text::DrawSelf @ 0x00467aa0` + +``` +var_b0 = 1; +if (m_bitField & 0x10) // SetOutline(true) / property 0x21 + var_b0 = 0; + +if (var_b0 < 2) do { + ... walk every glyph of the block ... + if (var_b0 != 0) // FILL pass + DrawCharacter(x, y, font, ch, glyph.color, 0x1000, m_curOutlineColor); + else if (font->m_pBackgroundSurface == 0) // OUTLINE pass, no bg plane + for (j = -1; j <= 1; j++) + for (k = -1; k <= 1; k++) + if (k || j) + DrawCharacter(x+k, y+j, font, ch, m_curOutlineColor, 0x9000, 0xff000000); + else // OUTLINE pass, bg plane + DrawCharacter(x, y, font, ch, glyph.color, 0x7000, m_curOutlineColor); + ... + cond = (var_b0 + 1) < 2; var_b0 += 1; +} while (cond); // 0x00467f6b / 0x00467f6e +``` + +Consequences: +- outline OFF → the loop runs **once** (pass 1, fill only); +- outline ON → the loop runs **twice**: pass 0 draws the outline for **the whole block**, + pass 1 draws every fill on top. Fills therefore always sit above every neighbour's outline — + interleaving per glyph (what acdream would do) lets glyph N+1's outline paint over glyph N's + fill on tight kerning. +- retail has **two** outline mechanisms and picks by data: bg plane when the font has one, + otherwise 8 neighbour blits of the *foreground* glyph at ±1 px. This is the same + data-driven-dispatch shape as `feedback_retail_dispatch_is_data_driven`. + +### 3.2 `SurfaceWindow::DrawCharacter @ 0x00442bd0` — flag bits + +`DrawCharacter(dst, x, y, font, unicode, fgColor /*arg6*/, flags /*arg7*/, outlineColor /*arg8*/)` + +| Bit | Effect | Site | +|---|---|---| +| `0x0001` | fixed-width advance = `maxCharWidth + 1` | `0x00442c2f` | +| `0x0002` | apply `m_BaselineOffset` | `0x00442c6f` | +| `0x0100` | `CopyAlpha` instead of a colour blit | `0x00442c80` | +| `0x1000` | proportional advance `hOffBefore + Width + hOffAfter`, glyph at `x + hOffBefore` | `0x00442c3a`–`0x00442c4a` | +| `0x2000` | **skip** the foreground plane | `0x00442dea` | +| `0x4000` | **draw** the background plane (also needs `m_pBackgroundSurface != 0`) | `0x00442d02` | +| `0x8000` | force the tint path (`SurfaceWindow::Color`) | `0x00442dc1` / `0x00442e65` | + +So `0x7000` = bg-plane-only, proportional; `0x1000` = fill-only, proportional; +`0x9000` = fill-glyph tinted with the outline colour, proportional. + +Both planes go through +`SurfaceWindow::Color(dst, src, Blit_4Alpha, colour)` whenever the surface format is +`0x1C` (`PFID_A8`) — which font atlases always are — so the A8 coverage is tinted by the +supplied colour. `arg8` tints the background plane; `arg6` tints the foreground plane. +**This is exactly acdream's shader model** (A8 → `(255,255,255,a)`, multiplied by the vertex +tint). The mechanism matches; only the gating and the rect do not. + +### 3.3 The rect inflation — `CreateCharRectPair @ 0x00441480` + +Foreground call (`0x00442cc0`) and background call (`0x00442d3a`) differ in exactly two +argument slots: the foreground passes `0` and `nullptr`; the background passes +`Font+0x40` (`m_NumHorizontalBorderPixels`) and `Font+0x44` (`m_NumVerticalBorderPixels`). + +The body inflates BOTH the source sub-rect and the destination rect symmetrically by those +two values before clamping to the surface bounds: + +``` +dst.left = X - hB; dst.top = Y - vB; dst.right = X + W + hB; dst.bottom = Y + H + vB; +src.left = Ox - hB; src.top = Oy - vB; src.right = Ox + W + hB; src.bottom = Oy + H + vB; +(then correlated clamping against the surface extents) +``` + +> BN's regparm reconstruction scrambles the parameter names in this function +> (`edx`/`ebx` come in as unassigned registers), so the *slot mapping* above is inferred from +> the call-site diff, not read literally. It is however **confirmed by measurement**: §1.2 +> shows the background glyph's alpha bbox begins at `(hB−2, vB−2)` inside the inflated window +> and is `W+4 × H+4`, i.e. only an inflated rect can capture it, and the foreground glyph sits +> at exactly `(hB, vB)`. + +### 3.4 Comparison with `UiRenderContext.DrawStringDat` + +`src/AcDream.App/UI/UiRenderContext.cs:226–293`. + +| Retail | acdream today | Verdict | +|---|---|---| +| Two whole-block passes (outline block, then fill block) | one loop, outline+fill interleaved per glyph | ⚠️ differs on tight kerning | +| Outline gated by `m_bitField & 0x10` ← property `0x21` | gated by a `bool outline = false` parameter | mechanism present | +| Outline enabled on the elements that author `0x21` | **no production call site ever passes `outline: true`** | ❌ **outline never drawn** | +| Outline colour = `m_curOutlineColor` (black default, property `0x22`) | hard-coded `new Vector4(0,0,0,color.W)` (line 249) | ✅ correct in practice | +| bg source rect = `(Ox−hB, Oy−vB, W+2hB, H+2vB)`, dest inflated to match | bg source rect = `(Ox, Oy, W, H)`, dest **not** inflated (lines 274–279) | ❌ **outline invisible even if enabled** | +| Fallback: 8 neighbour blits when the font has no bg plane | none | ❌ missing (matters for the 0-border fonts) | +| Advance `hOffBefore + W + hOffAfter` | `UiDatFont.GlyphAdvance` — identical | ✅ | +| Glyph at `x + hOffBefore`, `y + vOffBefore`, integer line baseline | identical (lines 247–260) | ✅ | +| A8 × tint | A8 → white+alpha × vertex tint | ✅ | + +**Rendered proof** (probe, font `0x40000001`, "You can't jump while in the air", gold on a +grass-toned backdrop): +- `outline: false` (acdream today) → flat glyphs, no border. +- `outline: true`, **not** inflated → visually indistinguishable from no outline; the only + artifact is a single dark pixel where a descender pokes out. The fill covers the whole + un-inflated bg rect. +- `outline: true`, **inflated by the border pixels** → the heavy black border the user + photographed. + +**Verdict for Q3:** same mechanism, two independent defects. Enabling the flag alone would +change nothing visible. Both the flag *and* the rect inflation are required. + +### 3.5 One more retail detail acdream does not implement + +`0x0002` (`use m_BaselineOffset`) — `UiDatFont.BaselineOffset` is loaded and stored but never +read by any draw path. Retail's `DrawSelf` glyph loop does not set `0x0002`, so the transcript +and SpewBox paths are unaffected; leave it. Noted so the field is not mistaken for a gap. + +--- + +## 4. THE SPEWBOX — resolved, not a placeholder any more + +**The earlier CH finding that element `0x1000004A` / layout enum `0x10000012` is "absent from +both dats" is WRONG.** It is present. It was missed for two reasons, both mechanical: the +element is a **root** of its layout (so a children-only walk skips it), and its font/colour +live in a **BaseElement in a different LayoutDesc** plus a **named state**, not in its own +DirectState. + +### 4.1 Runtime path — `gmSpewBoxUI` + +- `gmSpewBoxUI::Register @ 0x004d5dd0` → `RegisterElementClass(0x10000016, Create)`. +- `gmSpewBoxUI::PostInit @ 0x004d5ab0` → `SetShouldEraseBackground(1)`, `SetMouseVisible(0)`, + `GetChildRecursive(0x10000049)` → the ListBox, reads its property `0x10000028` into + `m_maxConcurrentItems` (default 1 if absent), `RegisterForGlobalMessage(3)`, + `RegisterNoticeHandler(0x186b6)`. +- `gmSpewBoxUI::RecvNotice_DisplayFinalStringInfo @ 0x004d60a0` — accepts only + **`arg2 == 0x1a`** (the client-local / interface-text `LogTextType`) and queues the + `StringInfo`. +- `gmSpewBoxUI::ListenToGlobalMessage @ 0x004d6090` → on global message 3, `Update`. +- `gmSpewBoxUI::Update @ 0x004d5df0`: + `UIElementManager::CreateChildElementByEnum(s_pInstance, nullptr, 0x10000012, 0x1000004a)` + → `DynamicCast(0xC)` → `SetText(trimmed)` → `ResizeTo(listBox.width, h)` → + `RecalculateGlyphList` → dedup against item 0 → `InsertItem(item, 0)` → delete item + `m_num-1` when over `m_maxConcurrentItems` → `ScrollToShow(0)`. + +acdream's `SpewBoxController` already mirrors the tick source, newest-on-top ordering, and +the 4-item cap correctly. + +### 4.2 The authored line template — `LayoutDesc 0x21000011` (client_local_English.dat) + +``` +/ elem 0x1000004A type 0x00000000 (UIElement_Text) + base = 0x10000377 in layout 0x2100003F + size 450 x 18 <-- exactly Font 0x40000001's MaxCharHeight + DefaultState = 0x10000002 (its only state) + [DirectState] 0x14 HJustify = Enum(1) -> Center + 0x15 VJustify = Enum(1) -> Center + [state 0x10000002] 0x1B FontColor[] = [ARGB(255,255,0,0)] <-- RED + 0x21 OUTLINE = true <-- OUTLINE ON + 0x23 margL = 5 + 0x24 margR = 5 + inherited from 0x10000377: + 0x1A FontDID[] = [0x40000001] <-- 18 px bold serif + outline colour: NOT authored anywhere -> ctor default = BLACK + +/ elem 0x10000048 type 0x10000016 (gmSpewBoxUI) + size 450 x 72, edges L3 T1 R3 B2, pos (0,0), ROOT of the layout + (L3/R3 = "centered" per ElementReader.ToAnchors; T1 = top-anchored) + [DirectState] 0x3B = Bool(false) + +/0x10000048/ elem 0x10000049 type 0x00000005 (ListBox) + size 450 x 72, DefaultState = Ghosted + 0x10000028 MaxConcurrentItems = 4 +``` + +Three independent cross-checks that the font is `0x40000001`: +1. the base style `0x10000377` authors `0x1A = [0x40000001]` directly; +2. the line template's authored height is **18 px** and `0x40000001`'s `MaxCharHeight` is + **18**; +3. 4 items × 18 px = **72 px** = the authored box height. + +**Position is now derived, not guessed.** `0x10000048` is a ROOT element of its layout, so its +parent is the viewport: `pos(0,0)`, `L3/R3` centred, `T1` top-anchored ⇒ a 450×72 block +horizontally centred, flush to the top of the screen. That is exactly what +`SpewBoxController` does today (`TopOffset = 0`, per-frame recentre) — the user-directed +placement turns out to be the authored one. Register row AP-178's position entry can be +retired. + +### 4.3 The colour — the ONE unresolved conflict + +- **DAT/decomp says:** `ARGB(255,255,0,0)` — pure red — because `DefaultState` is + `0x10000002` and `UIElement::Initialize` applies that state before the properties. This is + also *consistent* with retail's chat colour table, where `LogTextType 0x1A` (the only type + the SpewBox accepts) maps to `colorBrightRed (1,0,0,1)` + (`docs/research/2026-08-09-chat-retail-color-table.md`, row `0x1A`). +- **The user says:** golden/amber, and pinned yellow `#FFFF3F` live at CH round 1. + +**UNVERIFIED — do not "fix" the colour off this document.** Two things could reconcile it, +and I cannot separate them statically: +1. The ListBox (`DefaultState = Ghosted`) may propagate its state to inserted items via + `StateDesc::m_bPassToChildren` (`UIElement::SetState @ 0x00464ebf`/`0x00464ec6`). If the + item is moved out of `0x10000002`, `SetState` re-fires `OnSetAttribute` for the changed + keys and the colour reverts toward the base (white / Ghosted grey) — and the outline flag + would go with it, which contradicts the screenshot, so this is unlikely but not excluded. +2. A large part of "gold vs yellow" is **the outline itself**. Rendering `#FFFF3F` at 18 px + with the black border eats the glyph edges and reads distinctly warmer/goldener than the + same `#FFFF3F` drawn flat. Probe images of `#FF0000`, `#FFFF3F`, `#FFF27F`, `#D2D264` and + `#FFFFFF` at font `0x40000001` with the correct outline show `#FFFF3F`-with-outline + matching a "golden/amber" description well, and `#FF0000` matching it not at all. + +**Cheapest resolution, in order:** +- (a) Show the user the four probe renders side by side and let them point at one — zero cost, + and their eye is the acceptance test anyway. +- (b) If that is ambiguous, cdb one-liner against live retail: + `bp acclient!UIElement_Text::DrawSelf ".if (@@c++(((UIElement_Text*)@ecx)->m_bitField & 0x10)) { .printf \"col=%08x outline=%08x\\n\", @@c++(((UIElement_Text*)@ecx)->m_curFontColor), @@c++(((UIElement_Text*)@ecx)->m_curOutlineColor) }; gc"` + then trigger a jump-in-air refusal. That reads the live `m_curFontColor` directly. + +**Recommendation meanwhile:** keep the user-pinned yellow (it is already a passed user gate), +and land the *face + size + outline*, which are unambiguous. Re-open colour only if the user +still flags it after the outline lands. + +### 4.4 Answering "is there ANY on-screen text with authored style" + +Yes — the SpewBox line template above is exactly that, and it is the only one of its kind. +The other authored-outline on-screen families are the big centred announcement styles +(`0x10000382`–`0x10000388`, decorative fonts `0x4000000E`–`0x40000014`, gold +`ARGB(255,255,242,127)`), which are a different surface. There is no separate authored +"wait cue"/ECM display-string element — Campaign CH already routed the portal wait cue through +the SpewBox, which the decomp supports (notice type `0x1A` is the only inbound channel). + +--- + +## 5. ACDREAM GAP LIST + FIX PLAN + +### 5.1 Where each attribute comes from today + +**Chat transcript** + +| Attribute | acdream today | Retail | Match? | +|---|---|---|---| +| Face | `0x40000000` — `InteractionRetainedUiComposition.cs:572` (`UiDatFont.Load` default `UiDatFont.DefaultFontId` = `0x40000000`) → `RetailUiRuntime.cs:843` → `ChatWindowController.cs:293` `Transcript.DatFont = datFont` | `0x40000000` via base style `0x10000372` prop `0x1A` | ✅ | +| Size | 16 px (`UiDatFont.LineHeight` = `MaxCharHeight`) | 16 px | ✅ | +| Per-line colour | `UiText.Line.Color` from the ported chat colour table | glyph-run colour from `ChatInterface` | ✅ | +| Default colour | `UiText.DefaultColor = Vector4.One` (`UiText.cs:77`) | `ARGB(255,204,204,204)` | ❌ shade too bright | +| Tag colour | none | `0x1D = (0,178,0)` green + `0x1C` tag font | ❌ missing | +| Outline | never drawn (`UiText.cs:562`, `:605`, `:428`, `:450`, `:471` all call `DrawStringDat` with the default `outline: false`) | style `0x10000372` authors **no** `0x21` | ✅ **correct — chat must stay un-outlined** | +| Margins | `UiText.Padding`, not read from the DAT | element `0x10000011` authors `0x23`/`0x24` = 2 | ⚠️ minor | +| Justification | `HJustify`/`VJustify` from `0x14`/`0x15` (`ElementReader.cs:339+`) | Left / Bottom via base | ✅ (inherited through `Merge`) | + +**SpewBox** + +| Attribute | acdream today | Retail | Match? | +|---|---|---|---| +| Face | `0x40000025` — `SpewBoxController.cs:121` | **`0x40000001`** (base style `0x10000377` prop `0x1A`) | ❌ wrong font | +| Size | 11 px line | **18 px** line (and the authored line box is 18 px tall) | ❌ far too small | +| Fill colour | `(1,1,0.247,1)` — `SpewBoxController.cs:184` | authored `(255,0,0)`; user-observed gold/yellow | ⚠️ see §4.3 | +| Outline | none | **`0x21 = true`**, colour black | ❌ **the reported "shading"** | +| Position | centred, `TopOffset = 0` — `SpewBoxController.cs:137` | centred (edges L3/R3), top-anchored (T1), root-of-layout ⇒ viewport top | ✅ now *derived* | +| Extent | 450×72 — `SpewBoxController.cs:164-165` | 450×72 | ✅ | +| Max items | 4 | `0x10000028 = 4` | ✅ | +| Margins | none | `0x23`/`0x24` = 5 | ⚠️ minor | + +**Shared plumbing** + +| Gap | Site | +|---|---| +| `NumHorizontal/VerticalBorderPixels` never read | `UiDatFont.cs:93-127` (`Load`), no field on `UiDatFont` | +| Background sub-rect not inflated | `UiRenderContext.cs:274-279` | +| Outline never enabled | `UiRenderContext.cs:226` default `outline = false`; zero production callers pass `true` | +| Property `0x21` (outline) / `0x22` (outline colour) not imported | `ElementReader.cs:339-386` reads only `0x1A`, `0x14`, `0x15`, `0x1B`; `LayoutImporter.cs:499-560` likewise | +| No 8-neighbour fallback for 0-border fonts | `UiRenderContext.DrawStringDat` | +| Outline drawn per glyph, not per block | `UiRenderContext.DrawStringDat` loop | +| Stale comment: "LayoutDesc property 0xd" | `UiRenderContext.cs:221` | + +### 5.2 Fix plan + +**Fix 1 — `UiDatFont` carries the border pixels.** Add +`public int BorderX { get; }` / `BorderY { get; }` populated from +`font.NumHorizontalBorderPixels` / `NumVerticalBorderPixels` in `UiDatFont.Load` +(`UiDatFont.cs:93`). Cite `Font::Serialize @ 0x00443650`. Pure additive; no behaviour change +until Fix 2. + +**Fix 2 — `DrawStringDat` blits the background plane with the inflated rect.** +In `UiRenderContext.cs:272-280`, when the outline pass runs use +source `(g.OffsetX - BorderX, g.OffsetY - BorderY, g.Width + 2*BorderX, g.Height + 2*BorderY)` +and destination `(gx - BorderX, gy - BorderY, same size)`. Cite +`SurfaceWindow::DrawCharacter @ 0x00442d3a` + `CreateCharRectPair @ 0x00441480`. +**Fixes 1 and 2 must land together** — either alone is a no-op or a regression. + +**Fix 3 — restructure `DrawStringDat` into two whole-string passes.** When `outline` is set, +loop the string once emitting only outline quads, then again emitting only fill quads. Cite +`UIElement_Text::DrawSelf @ 0x00467f6b`. Low risk, and it removes the tight-kerning artifact. +Optional in the same commit: the 8-neighbour `±1` fallback when +`font.BackgroundTexture == 0` (cite `0x00467d7e`–`0x00467e14`), needed only if a 0-border font +(`0x40000017/18/21/22/23`, `0x4000001A`, `0x4000001D`–`0x40000020`, `0x40000030/31`) is ever +used for outlined text. Not currently reachable — file it rather than build it if scope is tight. + +**Fix 4 — SpewBox: face + size + outline.** In `SpewBoxController.cs`: +- `RetailFontId` `0x40000025` → **`0x40000001`** (three independent cross-checks in §4.2); +- pass `outline: true` for this element's text. Cleanest seam: add + `public bool Outline { get; set; }` to `UiText` and forward it into the `DrawStringDat` + calls at `UiText.cs:428/450/471/562/605`, then set `Outline = true` on the SpewBox `UiText`. +- rewrite the class remarks: position and extent are now **authored** (`0x21000011` + element `0x10000048`), and the font is **authored** (`0x2100003F` element `0x10000377`). + Retire the AP-178 rows for position and font; keep a colour row pending §4.3. +- optional: 5 px left/right margin from `0x23`/`0x24`. + +**Fix 5 — chat transcript default shade + tag colour.** Set the transcript's +`UiText.DefaultColor` to `(204/255, 204/255, 204/255, 1)` from style `0x10000372` prop `0x1B`, +and leave the outline OFF (retail authors none). File the `0x1D` tag colour `(0,178,0)` and +the `0x1C` tag font as a separate follow-up — it needs a per-run tag concept that +`UiText.Line` does not have yet. + +**Fix 6 — import `0x21`/`0x22` properly.** Add `Outline` (bool, prop `0x21`) and +`OutlineColor` (colour, prop `0x22`, default black) to `ElementInfo` + +`ElementReader.ApplyCanonicalLegacyProjection` + `LayoutImporter.ReadState`, with the same +"derived wins" merge rule as `FontDid`, and have `DatWidgetFactory` push them onto the built +`UiText`. That makes every authored-outline element in the DAT (~100 rows across 15 layouts) +correct at once instead of one controller at a time. Correct the stale +`UiRenderContext.cs:221` comment to property `0x21`/`0x22` in the same commit. + +**Suggested order:** 1+2 (mechanism) → 4 (the surface the user is looking at) → 5 → 6 → 3. +Fixes 1+2+4 alone should reproduce the screenshot. + +### 5.3 Divergence-register bookkeeping + +- **Retire:** AP-178's *position* and *extent* placeholders (both are authored and already + matched), and its *font* placeholder once Fix 4 lands. +- **Add:** a row for the chat transcript's missing tag-colour/tag-font (`0x1D`/`0x1C`) until + Fix 5's follow-up lands. +- **Add:** a row for the missing 8-neighbour outline fallback if Fix 3's optional half is + deferred. +- **Keep, rewritten:** the AP-178 *colour* row — now "authored `(255,0,0)` for state + `0x10000002`; acdream ships the user-pinned yellow pending the §4.3 resolution." + +--- + +## Appendix — probe method (reproducible) + +A throwaway .NET 8 console referencing `Chorizite.DatReaderWriter` 2.1.7 + `SixLabors.ImageSharp`, +run against `%USERPROFILE%\Documents\Asheron's Call`. It (a) sweeps `Font 0x40000000`–`0x40000040` +printing every serialized field plus the fg/bg `RenderSurface` dimensions and format, +(b) walks every `LayoutDesc` root and child recursively across all four DATs printing each +`ElementDesc`'s `BaseElement`/`BaseLayoutId`, `DefaultState`, and every `StateDesc` property +(DirectState **and** named states) by id, (c) measures per-glyph alpha bounding boxes in both +atlases inside the inflated window, and (d) composites sample strings with the retail two-pass +model to PNG. Nothing was written into the repo tree. The three walk features the earlier CH +sweep lacked — **root elements**, **`BaseElement`/`BaseLayoutId` inheritance**, and +**named-state properties** — are exactly what turned `0x1000004A` from "absent" into fully +specified. diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 7c714bb4..8d64280d 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -115,6 +115,31 @@ public sealed class TextRenderer : IDisposable } } + /// + /// Test-only: same submission-ordered segmentation as + /// , but exposing the FULL per-vertex float + /// buffer (8 floats/vertex: x,y,u,v,r,g,b,a — see , + /// 6 vertices/quad) instead of just texture/count/alpha. Needed by the + /// UiRenderContext.DrawStringDat two-pass outline/fill tests — proving + /// draw ORDER (outline segment before fill segment), TINT (RGB, not just + /// alpha), and the background-plane INFLATION (dest quad size + UV span) + /// all requires more than exposes. + /// AcDream.App.Tests-only via InternalsVisibleTo. + /// + internal IReadOnlyList<(uint Texture, IReadOnlyList Verts)> DebugSpriteSegmentVerts + { + get + { + var result = new List<(uint, IReadOnlyList)>(_segUsed); + for (int i = 0; i < _segUsed; i++) + { + SpriteSeg seg = _spriteSegs[i]; + result.Add((seg.Texture, seg.Verts.ToArray())); + } + return result; + } + } + /// /// Test-only snapshot of the current frame's queued NORMAL-layer BITMAP FONT /// text buffer (/, diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs index 37694a68..f712e189 100644 --- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs +++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs @@ -45,22 +45,43 @@ internal static class ChatTranscriptRenderer /// advancing for lines actually appended to THIS window's own scroll /// (AppendStringInfoWithFont only runs for displayed lines). /// + /// + /// The transcript element's own base fill color — retail's + /// m_curFontColor BEFORE any AppendTextWithFont call ever + /// tints it, i.e. the value DoFontReset seeds from the element's + /// authored LayoutDesc property 0x1B (style 0x10000372: + /// ARGB(255,204,204,204) for the main chat transcript). Campaign + /// CH round 4 (docs/research/2026-08-10-retail-ui-text-style.md + /// §5.2 Fix 5): this used to be hardcoded to + /// RetailChatColorTable.TryGetColor(0x00u, ...) (colorGreen) — + /// the color table's OWN default-slot color, not the ELEMENT's authored + /// default. The two are unrelated: the table governs the per-message + /// LogTextType tint (untouched by this parameter — every message + /// with an in-range type still resolves its own table color exactly as + /// before), while is only the carried- + /// forward seed for a line whose type falls OUTSIDE the table's 34 + /// entries (matching retail's out-of-range "leave m_curFontColor + /// unchanged" rule — see 's own doc). + /// Callers pass their transcript's . + /// public static List BuildLines( IReadOnlyList detailed, float maxW, Func measure, - Func? accept) + Func? accept, + Vector4 defaultColor) { var result = new List(detailed.Count); if (detailed.Count == 0) return result; - // Retail's font-color state (m_curFontColor) persists across every - // line actually appended to this window — an out-of-range LogTextType - // leaves it unchanged rather than reverting to a default (color-table - // doc §3.2). Seed the carry with retail's own unfilled-slot default - // (colorGreen, index 0x00). - RetailChatColorTable.TryGetColor(0x00u, out Vector4 currentColor); + // Retail's font-color state (m_curFontColor) persists across every line + // actually appended to this window — an out-of-range LogTextType leaves it + // unchanged rather than reverting to a color-table default (color-table doc + // §3.2). Seed the carry with the ELEMENT's own authored default fill + // (defaultColor), matching retail's DoFontReset — not the color table's + // unrelated index-0x00 slot. + Vector4 currentColor = defaultColor; foreach (FormattedLine d in detailed) { if (accept is not null && !accept(d.LogTextType)) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 6c66b7f2..aaa142c2 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -697,7 +697,8 @@ 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); - var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, Accept); + var result = ChatTranscriptRenderer.BuildLines( + detailed, maxW, measure, Accept, Transcript.DefaultColor); return StoreTranscriptLayout(result, revision, filter, maxW, datFont, debugFont); } diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 99f9bd73..a81669b2 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -612,6 +612,10 @@ public static class DatWidgetFactory FontColorPalette = ElementReader.ReadEffectiveColorPalette( info, 0x1Bu), + // Outline from dat property 0x21 (BoolBaseProperty). Default false — matches + // ElementInfo.Outline's own default, so this is a no-op for the ~99% of text + // elements that don't author it. + Outline = info.Outline, }; t.ConfigureDatState(info); @@ -622,6 +626,12 @@ public static class DatWidgetFactory if (info.FontColor.HasValue) t.DefaultColor = info.FontColor.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 + // (black, matching retail's m_curOutlineColor default) already applies. + if (info.OutlineColor.HasValue) + t.OutlineColor = info.OutlineColor.Value; + if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored) t.LinesProvider = () => [new UiText.Line(authored, t.DefaultColor)]; diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index b4976b42..73317ab8 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Numerics; +using AcDream.App.UI; namespace AcDream.App.UI.Layout; @@ -108,6 +109,26 @@ public sealed class ElementInfo /// public Vector4? FontColor; + /// + /// Outline flag from dat Properties[0x21] (BoolBaseProperty). Retail + /// UIElement_Text::SetOutline @0x0046a81c / m_bitField & 0x10. + /// Default false (ctor m_bitField=0x300 clears the outline bit) — outlining is + /// opt-in per element. Propagated in with the same + /// "derived wins when true" rule used for . + /// + public bool Outline; + + /// + /// Outline color from dat Properties[0x22] (ColorBaseProperty). Retail + /// m_curOutlineColor, ctor default RGBAColor_Black + /// (UIElement_Text::UIElement_Text @0x004686cb). Null means "not authored" — + /// the factory then leaves the widget at its own black default + /// (). Propagated in + /// with the same "non-null derived wins" rule used + /// for . + /// + public Vector4? OutlineColor; + /// /// Sprite per state: state name → (RenderSurface file id, DrawMode int). /// The "" key represents the unnamed DirectState (ElementDesc.StateDesc). @@ -307,6 +328,12 @@ 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, + // 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. + Outline = derived.Outline || base_.Outline, + // OutlineColor: same "non-null derived wins" rule as FontColor. + OutlineColor = derived.OutlineColor ?? base_.OutlineColor, // DefaultStateName: derived wins if set; otherwise inherit the base's default. DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, // This helper merges one element snapshot only. LayoutImporter separately @@ -384,6 +411,33 @@ public static class ElementReader info.FontColor = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha); } } + + // Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField & 0x10. + if (info.TryGetEffectiveProperty(0x21u, out var outline) + && outline.Kind == UiPropertyKind.Bool) + { + info.Outline = outline.BoolValue; + } + + // OutlineColor (0x22): ColorBaseProperty (matches FontColor's Array-tolerant read — + // no authored 0x22 is currently array-wrapped, but the fallback costs nothing). + // Retail m_curOutlineColor, ctor default RGBAColor_Black. + if (info.TryGetEffectiveProperty(0x22u, out var outlineColor)) + { + UiPropertyValue? outlineColorValue = outlineColor.Kind == UiPropertyKind.Color + ? outlineColor + : outlineColor.Kind == UiPropertyKind.Array + && outlineColor.ArrayValue.Count > 0 + && outlineColor.ArrayValue[0].Kind == UiPropertyKind.Color + ? outlineColor.ArrayValue[0] + : null; + if (outlineColorValue is not null) + { + var c = outlineColorValue.ColorValue; + float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f; + info.OutlineColor = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha); + } + } } /// diff --git a/src/AcDream.App/UI/Layout/FloatingChatWindowController.cs b/src/AcDream.App/UI/Layout/FloatingChatWindowController.cs index 3f3c7255..3df1766c 100644 --- a/src/AcDream.App/UI/Layout/FloatingChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/FloatingChatWindowController.cs @@ -277,7 +277,8 @@ public sealed class FloatingChatWindowController : IRetainedPanelController bool Accept(uint logTextType) => windowFilters.ShouldDisplay( WindowId, ChatWindowState.BroadcastTargetWindow, logTextType); - var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, Accept); + var result = ChatTranscriptRenderer.BuildLines( + detailed, maxW, measure, Accept, Transcript.DefaultColor); _cachedTranscriptRevision = revision; _cachedFilter = filter; diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 43418270..5bf6d484 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -559,6 +559,27 @@ public static class LayoutImporter float a = c.Alpha == 0 ? 1f : c.Alpha / 255f; info.FontColor = new System.Numerics.Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, a); } + + // Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField & + // 0x10. Only update while still at the default (false); derived-wins handled in + // ElementReader.Merge — same "only update if still at default" pattern as HJustify. + if (!info.Outline + && sd.Properties.TryGetValue(0x21u, out var outlineRaw) + && outlineRaw is BoolBaseProperty outlineBool) + { + info.Outline = outlineBool.Value; + } + + // OutlineColor (0x22): ColorBaseProperty. Retail m_curOutlineColor, ctor default + // RGBAColor_Black. Only read when not already set — same pattern as FontColor. + if (info.OutlineColor is null + && sd.Properties.TryGetValue(0x22u, out var outlineColorRaw) + && outlineColorRaw is ColorBaseProperty outlineColorProp) + { + var oc = outlineColorProp.Value; + float oa = oc.Alpha == 0 ? 1f : oc.Alpha / 255f; + info.OutlineColor = new System.Numerics.Vector4(oc.Red / 255f, oc.Green / 255f, oc.Blue / 255f, oa); + } } } diff --git a/src/AcDream.App/UI/SpewBoxController.cs b/src/AcDream.App/UI/SpewBoxController.cs index b25204e9..a29d1a43 100644 --- a/src/AcDream.App/UI/SpewBoxController.cs +++ b/src/AcDream.App/UI/SpewBoxController.cs @@ -107,29 +107,74 @@ namespace AcDream.App.UI; /// /// Register row AP-178 updated to record both dispositions. /// +/// +/// Campaign CH user-gate round 4 (2026-08-10), +/// docs/research/2026-08-10-retail-ui-text-style.md: the earlier +/// "absent from both dats" finding for the SpewBox's own line template +/// (element 0x1000004A, base style 0x10000377 in layout +/// 0x2100003F) was WRONG — it was missed because the element is a +/// ROOT of its layout (a children-only walk skips it) and its font/color +/// live in a BaseElement in a DIFFERENT LayoutDesc plus a NAMED state, not +/// its own DirectState. Extending the sweep to walk roots, BaseElement/ +/// BaseLayoutId inheritance, and named-state properties found it, resolving +/// three of AP-178's four remaining open sub-claims: +/// +/// Font: is now +/// 0x40000001 (18px bold serif), not the round-3 smallest-font +/// heuristic 0x40000025. Three independent cross-checks: (1) base +/// style 0x10000377 authors FontDID [0x40000001] directly; +/// (2) the line template's authored height is 18px, exactly +/// 0x40000001's MaxCharHeight; (3) 4 items × 18px = 72px, +/// the authored box height (). +/// Outline: the line template's state 0x10000002 +/// authors property 0x21 (Outline) = true, outline color +/// NOT authored → ctor default black +/// (). This is the +/// "heavy black border" the user's screenshot showed and the earlier +/// rounds never reproduced — is now set on +/// construction. +/// Position/extent: confirmed AUTHORED, not merely +/// user-matched by luck. 0x10000048 (the gmSpewBoxUI root) is a +/// ROOT element of its own layout, so its parent is the viewport: +/// pos(0,0), edge codes L3/R3 ("centered" per +/// ElementReader.ToAnchors's doc comment) and T1 +/// (top-anchored) resolve to a 450×72 block horizontally centered, flush +/// to the viewport top — exactly what =0 and the +/// per-frame centered Left recompute already do. AP-178's position +/// sub-claim retires. +/// +/// Only fill COLOR (see — the atlas is PFID_A8, +/// alpha-only, so it cannot carry baked shading; the user-pinned yellow +/// stands pending an exact retail measurement) and the AP-177 line-lifetime +/// timeout remain open. +/// internal sealed class SpewBoxController : IDisposable { /// - /// Retail dat Font id this controller resolves for its text - /// (Campaign CH user-gate round 3 — see the class remarks). Not - /// retail's own measured SpewBox font (unmeasurable — no FontDid - /// property was found on the authored element); the smallest DAT font - /// confirmed in use by any currently-imported retail LayoutDesc, - /// chosen so the rendered text is visibly smaller than the prior debug - /// fallback, per the user's report. + /// Retail dat Font id this controller resolves for its text. Campaign CH + /// user-gate round 4 (2026-08-10): AUTHORED, not a heuristic — base + /// style 0x10000377 (layout 0x2100003F), which the + /// SpewBox's own line template (0x1000004A in layout + /// 0x21000011) inherits, authors FontDID [0x40000001] + /// directly. 0x40000001 is the classic AC bold-serif UI face at + /// 18px (MaxCharHeight=18, matching the line template's own + /// authored 18px height and the 4×18=72px authored box height). See the + /// class remarks for the three-way cross-check and + /// docs/research/2026-08-10-retail-ui-text-style.md §4.2. /// - internal const uint RetailFontId = 0x40000025u; + internal const uint RetailFontId = 0x40000001u; /// - /// Register row AP-178 (screen position): retail's authored ABSOLUTE - /// screen position is still unknown — the LayoutDesc dump (see class - /// remarks) recovered the element's position as (0,0) relative - /// to a PARENT this sweep could not identify. Campaign CH user-gate - /// round 3 (2026-08-10): the user reported the box was not flush to - /// the very top of the screen; mounted at 0 now, per explicit - /// user direction — still acdream's own placement choice pending the - /// true retail parent/offset, but now matching the user's live report - /// instead of an arbitrary 60px placeholder. (The SIBLING row AP-177 — + /// Register row AP-178 (screen position): CONFIRMED authored, not a + /// placeholder that happens to match. Campaign CH user-gate round 4 + /// (2026-08-10): the SpewBox root (0x10000048) is a ROOT element + /// of its own layout (0x21000011), so its parent is the + /// viewport — pos(0,0) plus edge codes L3/R3 (centered) + /// and T1 (top-anchored) resolve to exactly this: a fixed-width + /// block horizontally centered and flush to the viewport top. The + /// round-3 "user-directed approximation pending the true retail + /// parent/offset" framing is retired — the parent (the viewport root) + /// and the offset (0) are now both resolved. (The SIBLING row AP-177 — /// the invented line-lifetime timeout — lives in /// 's own doc comment, not /// here; this controller does not own that concern.) @@ -173,13 +218,22 @@ internal sealed class SpewBoxController : IDisposable /// (colorBrightRed) is still explicitly NOT this — retail's own /// BuildChatColorLookupTable writes to ChatInterface::m_chatLog, /// a completely different element tree the SpewBox never touches - /// (research doc §3.2.3); the LayoutDesc dump (see class remarks) also - /// never surfaced a colour property for this element. The exact retail - /// value simply happens to coincide with the Tell colour, per the user's - /// live observation. POSITION and FONT were re-addressed at Campaign CH - /// user-gate round 3 (2026-08-10) — see the class remarks and the - /// / comments; both - /// remain acdream-directed approximations, not resolved retail values. + /// (research doc §3.2.3). POSITION and FONT were resolved as AUTHORED at + /// Campaign CH user-gate round 4 (2026-08-10) — see the class remarks and + /// the / comments. The + /// AUTHORED value for THIS element's state 0x10000002 is actually + /// pure red ARGB(255,255,0,0) — but the user's live retail + /// screenshot shows gold/amber, not red, so the user's own eye remains + /// the axiom here (per feedback_retail_oracle_no_whack_a_mole). + /// Round 4 checked whether font 0x40000001's FILL-plane atlas + /// could explain the gold as baked shading over the pinned yellow: it + /// cannot — every dat font atlas (this one included) is PFID_A8, + /// alpha-only coverage with no per-pixel colour channel, so there is no + /// baked tint to compose with. The gold reading is therefore the OUTLINE + /// itself (a black border around a bright yellow glyph reads warmer/ + /// richer than the same colour drawn flat) rather than atlas shading. + /// The user-pinned yellow stands; only an exact cdb capture of live + /// retail's m_curFontColor would resolve the remaining gap. /// private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.247f, 1f); @@ -245,6 +299,12 @@ internal sealed class SpewBoxController : IDisposable ClickThrough = true, ZOrder = int.MaxValue, DefaultColor = SpewBoxColor, + // Campaign CH user-gate round 4: the line template's authored state + // 0x10000002 sets property 0x21 (Outline) = true with no authored + // outline colour, i.e. the ctor black default + // (UiRenderContext.DefaultOutlineColor) — the heavy black border in + // the user's screenshot. See the class remarks. + Outline = true, Visible = false, }; _text.LinesProvider = () => _lines; diff --git a/src/AcDream.App/UI/UiDatFont.cs b/src/AcDream.App/UI/UiDatFont.cs index 176cb3b0..08976464 100644 --- a/src/AcDream.App/UI/UiDatFont.cs +++ b/src/AcDream.App/UI/UiDatFont.cs @@ -12,16 +12,22 @@ namespace AcDream.App.UI; /// /// A retail dat-font (DB_TYPE_FONT, id range 0x40000000-0x40000FFF) ready for /// 2D drawing. Holds the two GL atlas textures (foreground glyph pixels + -/// background outline/shadow), the per-glyph descriptor table, and the line +/// background outline/shadow), the per-glyph descriptor table, the border-pixel +/// inflation margin (/), and the line /// metrics, so can blit each glyph /// as two textured quads exactly the way the retail client does. /// /// -/// Retail render model — SurfaceWindow::DrawCharacter -/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits): for -/// each glyph it copies the BACKGROUND atlas sub-rect first, tinted with the -/// outline color (black), then the FOREGROUND atlas sub-rect, tinted with the -/// requested text color. The pen advances by +/// Retail render model — UIElement_Text::DrawSelf (acclient 0x00467aa0) +/// runs the WHOLE glyph run TWICE when the element's outline flag (LayoutDesc +/// property 0x21, m_bitField & 0x10) is set: pass 0 draws every glyph's +/// outline (the BACKGROUND atlas sub-rect, inflated by / +/// on every side, tinted with the element's outline color — +/// ctor default black, property 0x22), then pass 1 draws every glyph's fill (the +/// FOREGROUND atlas sub-rect, tinted with the requested text color) on top. With +/// the outline flag clear, only pass 1 runs. SurfaceWindow::DrawCharacter +/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits) is the +/// per-glyph blit each pass calls. The pen advances by /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter (the function's /// return value, accumulated by the string loop at 0x00467ed4 /// edi_3 += var_98), and each glyph is drawn starting at @@ -34,8 +40,8 @@ namespace AcDream.App.UI; /// (255,255,255, alpha). The UI sprite shader path (ui_text.frag, /// uUseTexture==2) MULTIPLIES the sampled texel by the per-vertex tint /// (texture(uTex,vUv) * vColor), so tinting a white+alpha glyph by a -/// color gives that color with the glyph's alpha — black for the outline pass, -/// text color for the fill pass. No shader change was needed. +/// color gives that color with the glyph's alpha — the outline color for the +/// outline pass, text color for the fill pass. No shader change was needed. /// /// public sealed class UiDatFont @@ -60,19 +66,38 @@ public sealed class UiDatFont /// Distance from a line's top to its baseline (retail BaselineOffset). public float BaselineOffset { get; } + /// + /// Retail Font::m_NumHorizontalBorderPixels / m_NumVerticalBorderPixels + /// (Font::Serialize @0x00443650; verified struct offsets Font+0x40 / + /// Font+0x44). The background (outline) atlas glyph is the foreground glyph + /// dilated 2px on every side, sitting inside a margin this wide — measured: the FG + /// glyph always sits at exactly (BorderX, BorderY) inside its inflated window, + /// and the BG glyph's alpha bbox begins at (BorderX-2, BorderY-2). Zero for + /// fonts with no background atlas (e.g. the CJK/unicode family, + /// is false). inflates the background blit's + /// source AND destination rect by exactly this much on every side + /// (SurfaceWindow::DrawCharacter @0x00442d3a + CreateCharRectPair @0x00441480) + /// so the dilation is actually captured instead of cropped away. + /// + public int BorderX { get; } + public int BorderY { get; } + private readonly Dictionary _glyphs; internal UiDatFont( uint fgTex, int fgW, int fgH, uint bgTex, int bgW, int bgH, float lineHeight, float baselineOffset, - Dictionary glyphs) + Dictionary glyphs, + int borderX = 0, int borderY = 0) { ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH; BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH; LineHeight = lineHeight; BaselineOffset = baselineOffset; _glyphs = glyphs; + BorderX = borderX; + BorderY = borderY; } /// True if this font carries a separate outline/shadow atlas @@ -123,7 +148,9 @@ public sealed class UiDatFont bgTex, bgW, bgH, lineHeight: font.MaxCharHeight, baselineOffset: font.BaselineOffset, - glyphs); + glyphs, + borderX: (int)font.NumHorizontalBorderPixels, + borderY: (int)font.NumVerticalBorderPixels); } /// diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 8a8ffef1..14e394c6 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -202,28 +202,64 @@ public sealed class UiRenderContext TextRenderer.DrawString(f, text, screenX, screenY, alphaColor); } + /// Retail UIElement_Text's constructor outline color default + /// (RGBAColor_Black, UIElement_Text::UIElement_Text @0x004686cb). + /// Used by when the caller doesn't supply an + /// explicit outlineColor (LayoutDesc property 0x22 is authored on only + /// 9 elements in the whole DAT set — every other outlined element uses this). + public static readonly Vector4 DefaultOutlineColor = new(0f, 0f, 0f, 1f); + /// /// Draw a single line of text with a retail dat font (), /// at , = the top-left of the - /// typographic block (in this element's local space). Mirrors retail's - /// SurfaceWindow::DrawCharacter (acclient 0x00442bd0): for each glyph - /// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline), - /// then the FOREGROUND atlas sub-rect tinted (the - /// fill). The pen advances by + /// typographic block (in this element's local space). The pen advances by /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter and each /// glyph is positioned at pen + HorizontalOffsetBefore on the X axis - /// and at baseline + VerticalOffsetBefore - (BaselineOffset) via the - /// glyph's OffsetY into the atlas. + /// and at baseline + VerticalOffsetBefore via the glyph's OffsetY into + /// the atlas. /// - /// gates the black outline pass. Retail decides - /// this PER text element: UIElement_Text::DrawSelf (acclient 0x00467aa0) - /// runs the outline pass only when m_bitField & 0x10 is set — i.e. the - /// element called SetOutline(true) (LayoutDesc property 0xd). The DEFAULT - /// is OFF (one fill-only pass): the talk-focus menu items set no outline, so an - /// always-on outline shows as a grey halo over the solid menu panel. Pass - /// outline:true only for elements retail outlines. + /// + /// Two-pass outline modelUIElement_Text::DrawSelf + /// (acclient 0x00467aa0): var_b0 = (m_bitField & 0x10) ? 0 : 1; — when + /// the outline bit is CLEAR the loop runs once (fill only, var_b0==1); + /// when SET it runs twice, pass 0 (outline) for the WHOLE STRING first, then + /// pass 1 (fill) for the whole string. Fills therefore always paint over every + /// neighbour's outline — this method reproduces that by iterating the string + /// twice rather than interleaving outline+fill per glyph, so on tight kerning + /// glyph N+1's outline never covers glyph N's fill (nor vice versa). + /// + /// + /// + /// gates the outline passes. Retail decides this PER + /// text element: the bit is m_bitField & 0x10, set by LayoutDesc + /// property 0x21 (SetOutline @0x0046a81c) — NOT property 0xd (an + /// earlier comment here named the wrong id: 0xd is the *switch-case index* inside + /// UIElement_Text::OnSetAttribute, not the authored property). The DEFAULT + /// is OFF (one fill-only pass, ctor m_bitField=0x300 clears bit 0x10): + /// outlining is opt-in per element (~100 authored rows across 15 layouts). + /// is the element's m_curOutlineColor + /// (LayoutDesc property 0x22, ctor default — + /// black; only 9 elements in the whole DAT set author a non-black value). + /// + /// + /// + /// Outline mechanism — retail picks by data, per font + /// (feedback_retail_dispatch_is_data_driven shape): when + /// is true, the outline pass blits the + /// BACKGROUND (dilated) atlas sub-rect, inflated by + /// / on every + /// side of both the source AND destination rect + /// (SurfaceWindow::DrawCharacter @0x00442d3a + + /// CreateCharRectPair @0x00441480) — the un-inflated rect crops the + /// dilation away, making the outline invisible even with the flag on. When the + /// font has NO background atlas (the CJK/unicode family), retail falls back to + /// 8 neighbour blits of the FOREGROUND glyph at ±1px + /// (UIElement_Text::DrawSelf 0x00467d7e-0x00467e14). + /// /// - public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, bool outline = false) + public void DrawStringDat( + UiDatFont font, string text, float x, float y, Vector4 color, + bool outline = false, Vector4? outlineColor = null) { if (font is null || string.IsNullOrEmpty(text)) return; @@ -232,7 +268,6 @@ public sealed class UiRenderContext // anchor each glyph's quad at the line top (y) plus its VerticalOffsetBefore. float originX = _current.X + x; float originY = _current.Y + y; - float pen = originX; // Snap the LINE baseline to a whole pixel ONCE. Retail's // SurfaceWindow::DrawCharacter (acclient 0x00442bd0) takes an int32 pen Y @@ -246,8 +281,27 @@ public sealed class UiRenderContext // line on one row and pixel-aligned. float baseY = System.MathF.Round(originY); - var outlineTint = new Vector4(0f, 0f, 0f, color.W); + if (outline) + { + Vector4 oc = outlineColor ?? DefaultOutlineColor; + var outlineTint = new Vector4(oc.X, oc.Y, oc.Z, color.W); + // PASS 0 — outline, whole string (acclient 0x00467aa0, var_b0 starts at 0). + DrawStringDatPass(font, text, originX, baseY, outlineTint, isOutlinePass: true); + } + // PASS 1 (or the only pass, when outline is off) — fill, whole string. + DrawStringDatPass(font, text, originX, baseY, color, isOutlinePass: false); + } + + /// One whole-string pass of 's two-pass + /// model: either every glyph's outline ( true) + /// or every glyph's fill. Recomputes the pen from scratch — a pure function of + /// /, so both passes advance + /// identically and stay in lock-step without sharing mutable state. + private void DrawStringDatPass( + UiDatFont font, string text, float originX, float baseY, Vector4 tint, bool isOutlinePass) + { + float pen = originX; for (int i = 0; i < text.Length; i++) { if (!font.TryGetGlyph(text[i], out var g)) @@ -255,7 +309,7 @@ public sealed class UiRenderContext // Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its // true fractional advance). Vertical: integer baseline + integer per-glyph - // offset — never an independent per-glyph round (see baseY note above). + // offset — never an independent per-glyph round (see DrawStringDat's baseY note). float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore); float gy = baseY + g.VerticalOffsetBefore; float gw = g.Width; @@ -263,35 +317,76 @@ public sealed class UiRenderContext if (gw > 0f && gh > 0f) { - // Background (outline) atlas pass, tinted black — drawn behind. Gated by - // `outline` (retail's per-element m_bitField & 0x10); off by default so UI - // text is crisp fill-only and free of the grey halo over solid panels. - // Both passes route through ApplyAlpha (applyAlpha: true) so a window's - // opacity fades glyphs exactly like its chrome/background sprites — retail's - // ChatInterface::SetOpacity (0x004F3120) fades the whole composited surface. - if (outline && font.BackgroundTexture != 0) - { - var (bu0, bv0, bu1, bv1) = AtlasUv( - g.OffsetX, g.OffsetY, g.Width, g.Height, - font.BackgroundWidth, font.BackgroundHeight); - DrawSpriteAbsolute( - font.BackgroundTexture, gx, gy, gw, gh, - bu0, bv0, bu1, bv1, outlineTint, applyAlpha: true); - } - - // Foreground (fill) atlas pass, tinted with the requested color. - var (fu0, fv0, fu1, fv1) = AtlasUv( - g.OffsetX, g.OffsetY, g.Width, g.Height, - font.ForegroundWidth, font.ForegroundHeight); - DrawSpriteAbsolute( - font.ForegroundTexture, gx, gy, gw, gh, - fu0, fv0, fu1, fv1, color, applyAlpha: true); + if (isOutlinePass) + DrawOutlineGlyph(font, g, gx, gy, gw, gh, tint); + else + DrawFillGlyph(font, g, gx, gy, gw, gh, tint); } pen += UiDatFont.GlyphAdvance(g); } } + /// Foreground (fill) atlas blit, tinted with the requested text color. + /// Both passes route through DrawSpriteAbsolute(..., applyAlpha: true) so a + /// window's opacity fades glyphs exactly like its chrome/background sprites — + /// retail's ChatInterface::SetOpacity (0x004F3120) fades the whole + /// composited surface. + private void DrawFillGlyph( + UiDatFont font, DatReaderWriter.Types.FontCharDesc g, + float gx, float gy, float gw, float gh, Vector4 tint) + { + var (fu0, fv0, fu1, fv1) = AtlasUv( + g.OffsetX, g.OffsetY, g.Width, g.Height, + font.ForegroundWidth, font.ForegroundHeight); + DrawSpriteAbsolute(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, tint, applyAlpha: true); + } + + /// Outline-pass blit for one glyph: the background (dilated) atlas + /// sub-rect inflated by the font's border-pixel margin when the font carries + /// one, else retail's 8-neighbour ±1px foreground-glyph fallback. + private void DrawOutlineGlyph( + UiDatFont font, DatReaderWriter.Types.FontCharDesc g, + float gx, float gy, float gw, float gh, Vector4 tint) + { + if (font.BackgroundTexture != 0) + { + // Background (dilated) plane, inflated by (BorderX, BorderY) on every side of + // BOTH the source sub-rect and the destination rect — CreateCharRectPair's + // background call (0x00442d3a) inflates both symmetrically before the atlas + // blit. The un-inflated rect (acdream's prior behavior) crops the dilation + // away entirely, leaving only a single stray pixel where a descender pokes out. + int bx = font.BorderX, by = font.BorderY; + float ix = gx - bx; + float iy = gy - by; + float iw = gw + 2f * bx; + float ih = gh + 2f * by; + var (bu0, bv0, bu1, bv1) = AtlasUv( + g.OffsetX - bx, g.OffsetY - by, (int)iw, (int)ih, + font.BackgroundWidth, font.BackgroundHeight); + DrawSpriteAbsolute(font.BackgroundTexture, ix, iy, iw, ih, bu0, bv0, bu1, bv1, tint, applyAlpha: true); + } + else + { + // No background plane (retail's 0-border fonts — the CJK/unicode family): + // fall back to retail's 8-neighbour ±1px foreground-glyph blit + // (UIElement_Text::DrawSelf 0x00467d7e-0x00467e14). + var (fu0, fv0, fu1, fv1) = AtlasUv( + g.OffsetX, g.OffsetY, g.Width, g.Height, + font.ForegroundWidth, font.ForegroundHeight); + for (int dy = -1; dy <= 1; dy++) + { + for (int dx = -1; dx <= 1; dx++) + { + if (dx == 0 && dy == 0) continue; + DrawSpriteAbsolute( + font.ForegroundTexture, gx + dx, gy + dy, gw, gh, + fu0, fv0, fu1, fv1, tint, applyAlpha: true); + } + } + } + } + /// Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to /// normalized UVs for an atlas of x /// . Guards against a zero-sized atlas. diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 06714e47..ba093185 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -90,6 +90,27 @@ public sealed class UiText : UiElement, IUiDatStateful /// the controller (e.g. ChatWindowController). public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); + /// + /// Retail LayoutDesc property 0x21 (UIElement_Text::SetOutline + /// @0x0046a81c, m_bitField & 0x10). When true, every dat-font draw + /// on this element runs retail's two-pass outline+fill model + /// (). Default false, matching the + /// ctor bitfield (0x300) which clears the outline bit — outlining is + /// opt-in per element. Set by + /// from for DAT-imported + /// text, or directly by a synthesized controller (e.g. the SpewBox). + /// + public bool Outline { get; set; } + + /// + /// Retail LayoutDesc property 0x22 (m_curOutlineColor). Only + /// meaningful when is true. Default black, matching the + /// ctor default (RGBAColor_Black, + /// UIElement_Text::UIElement_Text @0x004686cb) — only 9 elements in the + /// whole DAT set author a non-black outline color. + /// + public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor; + /// Optional dat state-sprite background (the element's own media), drawn /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. public uint BackgroundSprite { get; set; } @@ -425,7 +446,7 @@ public sealed class UiText : UiElement, IUiDatStateful { float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f; float cy = VOffset(Height, cdf.LineHeight, Padding, VerticalJustify); - ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color); + ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color, Outline, OutlineColor); } else if ((Font ?? ctx.DefaultFont) is { } cbf) { @@ -447,7 +468,7 @@ public sealed class UiText : UiElement, IUiDatStateful { float rx = Width - rdf.MeasureWidth(line0.Text) - Padding; float ry = VOffset(Height, rdf.LineHeight, Padding, VerticalJustify); - ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color); + ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color, Outline, OutlineColor); } else if ((Font ?? ctx.DefaultFont) is { } rbf) { @@ -468,7 +489,7 @@ public sealed class UiText : UiElement, IUiDatStateful if (DatFont is { } datSingle) { float y = VOffset(Height, datSingle.LineHeight, Padding, VerticalJustify); - ctx.DrawStringDat(datSingle, line0.Text, Padding, y, line0.Color); + ctx.DrawStringDat(datSingle, line0.Text, Padding, y, line0.Color, Outline, OutlineColor); } else if ((Font ?? ctx.DefaultFont) is { } bitmapSingle) { @@ -559,7 +580,7 @@ public sealed class UiText : UiElement, IUiDatStateful } if (datFont is not null) - ctx.DrawStringDat(datFont, text, lineX, y, lines[i].Color); + ctx.DrawStringDat(datFont, text, lineX, y, lines[i].Color, Outline, OutlineColor); else ctx.DrawString(text, lineX, y, lines[i].Color, bitmapFont); } @@ -602,7 +623,7 @@ public sealed class UiText : UiElement, IUiDatStateful if (run.Text.Length == 0) continue; if (datFont is not null) { - ctx.DrawStringDat(datFont, run.Text, x, y, run.Color); + ctx.DrawStringDat(datFont, run.Text, x, y, run.Color, Outline, OutlineColor); x += datFont.MeasureWidth(run.Text); } else diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index fae9e025..867d416f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -230,6 +230,53 @@ public class ChatLayoutConformanceTests Assert.Equal(0x10000016u, input.DatElementId); } + /// + /// Campaign CH round 4 (docs/research/2026-08-10-retail-ui-text-style.md + /// §2.5/§2.6): the chat transcript's base style (0x10000372 in layout + /// 0x2100003F) authors NO property 0x21 (Outline) anywhere in its + /// inheritance chain — regenerated straight from the real installed DAT + /// (ChatLayoutFixtureGenerator), proving the property-0x21/0x22 + /// importer () + /// resolves this correctly end to end, not merely by a missing-field JSON + /// default. + /// + [Fact] + public void ChatFixture_TranscriptElementInfo_CarriesNoOutline() + { + var root = FixtureLoader.LoadChatInfos(); + var transcript = Find(root, 0x10000011u)!; + + Assert.False(transcript.Outline); + Assert.Null(transcript.OutlineColor); + } + + /// Same fact at the built-widget level — DatWidgetFactory.BuildText + /// must not turn a false ElementInfo.Outline into a true UiText.Outline. + [Fact] + public void ChatFixture_TranscriptWidget_OutlineIsOff() + { + var layout = FixtureLoader.LoadChat(); + var transcript = Assert.IsType(layout.FindElement(0x10000011u)); + + Assert.False(transcript.Outline); + } + + /// + /// The transcript's default fill resolves to the authored + /// ARGB(255,204,204,204) (style 0x10000372 property 0x1B) — + /// Fix 5's target. Pins the value at the built-widget level, on the SAME + /// regenerated fixture the outline test above uses. + /// + [Fact] + public void ChatFixture_TranscriptWidget_DefaultColorIsAuthoredOffWhite() + { + var layout = FixtureLoader.LoadChat(); + var transcript = Assert.IsType(layout.FindElement(0x10000011u)); + + var expected = new System.Numerics.Vector4(204f / 255f, 204f / 255f, 204f / 255f, 1f); + Assert.Equal(expected, transcript.DefaultColor); + } + [Fact] public void ChatFixture_ScrollbarImportsInheritedMediaRoles() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRendererTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRendererTests.cs index 4a131c1c..62d1e12e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRendererTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRendererTests.cs @@ -1,6 +1,9 @@ using System.Collections.Generic; using System.Linq; +using System.Numerics; using AcDream.App.UI.Layout; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; namespace AcDream.App.Tests.UI.Layout; @@ -86,4 +89,78 @@ public class ChatTranscriptRendererTests Assert.Equal(new[] { "first", "", "third" }, lines); } + + // ── BuildLines: default-color seed (Campaign CH round 4, Fix 5) ───────── + // docs/research/2026-08-10-retail-ui-text-style.md §5.2 Fix 5: the seed for + // a line whose LogTextType falls OUTSIDE RetailChatColorTable's 34 entries + // must be the ELEMENT's own authored default fill (the transcript's + // UiText.DefaultColor), not the color table's own index-0x00 slot. Every + // line with an IN-RANGE type must still resolve its OWN exact table color — + // this parameter must never perturb that half. + + private static float MeasureByWidth(string s) => s.Length; + + private static readonly Vector4 OffWhite = new(0.8f, 0.8f, 0.8f, 1f); // ARGB(255,204,204,204) + + [Fact] + public void BuildLines_InRangeLogTextType_AlwaysUsesItsOwnTableColor_RegardlessOfDefaultColor() + { + // The 34-entry table remains the per-message color authority — this is + // the regression guard the task explicitly calls for: an in-range type + // (0x02 Speech -> pure white) must resolve to the SAME color whether the + // caller passes the new authored off-white default or an unrelated color. + var lines = new[] { new FormattedLine("hello", ChatKind.LocalSpeech, null, LogTextType: 0x02u) }; + + var withOffWhiteDefault = ChatTranscriptRenderer.BuildLines(lines, 1000f, MeasureByWidth, null, OffWhite); + var withUnrelatedDefault = ChatTranscriptRenderer.BuildLines( + lines, 1000f, MeasureByWidth, null, new Vector4(0f, 1f, 0f, 1f)); + + Assert.True(RetailChatColorTable.TryGetColor(0x02u, out Vector4 expected)); + Assert.Equal(expected, Assert.Single(withOffWhiteDefault).Color); + Assert.Equal(expected, Assert.Single(withUnrelatedDefault).Color); + } + + [Fact] + public void BuildLines_OutOfRangeLogTextType_AsFirstLine_UsesTheSuppliedDefaultColor() + { + // A LogTextType >= 34 (RetailChatColorTable.Colors.Count) is the + // out-of-range carry-forward case (see RetailChatColorTable's own doc): + // as the very FIRST line, there is no prior color to carry, so the + // element's own authored default fill applies — not colorGreen (the + // color table's unrelated 0x00 "Default" slot, the pre-fix seed). + var lines = new[] { new FormattedLine("mystery", ChatKind.System, null, LogTextType: 0xFFu) }; + + var result = ChatTranscriptRenderer.BuildLines(lines, 1000f, MeasureByWidth, null, OffWhite); + + Assert.Equal(OffWhite, Assert.Single(result).Color); + } + + [Fact] + public void BuildLines_OutOfRangeLogTextType_AfterAnInRangeLine_CarriesForwardThePriorTableColor() + { + // Retail's carry-forward rule (color-table doc §3.2): an out-of-range + // type leaves m_curFontColor UNCHANGED — it inherits whatever the + // PREVIOUS line resolved to, not the seed again. This must hold + // regardless of what defaultColor is passed. + var lines = new[] + { + new FormattedLine("says hi", ChatKind.LocalSpeech, null, LogTextType: 0x03u), // Tell -> yellow + new FormattedLine("mystery", ChatKind.System, null, LogTextType: 0xFFu), // out of range + }; + + var result = ChatTranscriptRenderer.BuildLines(lines, 1000f, MeasureByWidth, null, OffWhite); + + Assert.True(RetailChatColorTable.TryGetColor(0x03u, out Vector4 tellColor)); + Assert.Equal(2, result.Count); + Assert.Equal(tellColor, result[0].Color); + Assert.Equal(tellColor, result[1].Color); // carried forward, NOT OffWhite + } + + [Fact] + public void BuildLines_EmptyDetailed_ReturnsEmpty_RegardlessOfDefaultColor() + { + var result = ChatTranscriptRenderer.BuildLines( + System.Array.Empty(), 1000f, MeasureByWidth, null, OffWhite); + Assert.Empty(result); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs index 76127445..c4c1323f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs @@ -272,4 +272,82 @@ public class ElementReaderTests var merged = ElementReader.Merge(base_, derived); Assert.Null(merged.FontColor); } + + // ── Outline / OutlineColor — property 0x21/0x22 (Campaign CH round 4) ────── + + /// + /// A derived element that authors 0x21=true wins over an un-outlined base — + /// same "derived wins" convention as FontDid. This is the SpewBox line + /// template's own shape: its base style (0x10000377) authors no outline; the + /// line template's own state does. + /// + [Fact] + public void Merge_DerivedOutlineTrue_OverridesBaseFalse() + { + var base_ = new ElementInfo { Outline = false }; + var derived = new ElementInfo { Outline = true }; + var merged = ElementReader.Merge(base_, derived); + Assert.True(merged.Outline); + } + + /// + /// A derived element that does NOT author 0x21 (stays at the false default) + /// inherits an outlined base rather than clobbering it — the same "false/zero + /// derived never overrides a set base" rule HJustify/FontDid already use. + /// + [Fact] + public void Merge_DerivedOutlineFalse_InheritsOutlinedBase() + { + var base_ = new ElementInfo { Outline = true }; + var derived = new ElementInfo { Outline = false }; + var merged = ElementReader.Merge(base_, derived); + Assert.True(merged.Outline); + } + + /// + /// Neither base nor derived authors 0x21 — the chat transcript's own shape + /// (style 0x10000372 authors no outline anywhere in its chain). + /// + [Fact] + public void Merge_NeitherOutlines_MergedStaysFalse() + { + var base_ = new ElementInfo { Outline = false }; + var derived = new ElementInfo { Outline = false }; + var merged = ElementReader.Merge(base_, derived); + Assert.False(merged.Outline); + } + + /// + /// OutlineColor follows the exact same "non-null derived wins" rule as + /// FontColor — property 0x22 is authored on only 9 elements in the whole DAT + /// set, so most outlined elements inherit null here and fall back to the + /// widget's own black default. + /// + [Fact] + public void Merge_DerivedOutlineColor_OverridesBaseNull() + { + var base_ = new ElementInfo { OutlineColor = null }; + var derived = new ElementInfo { OutlineColor = new Vector4(0f, 0f, 0.4f, 1f) }; + var merged = ElementReader.Merge(base_, derived); + Assert.Equal(new Vector4(0f, 0f, 0.4f, 1f), merged.OutlineColor); + } + + [Fact] + public void Merge_DerivedOutlineColorNull_InheritsBaseColor() + { + var authored = new Vector4(17f / 255f, 15f / 255f, 7f / 255f, 1f); + var base_ = new ElementInfo { OutlineColor = authored }; + var derived = new ElementInfo { OutlineColor = null }; + var merged = ElementReader.Merge(base_, derived); + Assert.Equal(authored, merged.OutlineColor); + } + + [Fact] + public void Merge_BothOutlineColorNull_MergedIsNull() + { + var base_ = new ElementInfo { OutlineColor = null }; + var derived = new ElementInfo { OutlineColor = null }; + var merged = ElementReader.Merge(base_, derived); + Assert.Null(merged.OutlineColor); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/UiPropertyBagTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiPropertyBagTests.cs index bdf48702..09460fd7 100644 --- a/tests/AcDream.App.Tests/UI/Layout/UiPropertyBagTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/UiPropertyBagTests.cs @@ -74,6 +74,71 @@ public sealed class UiPropertyBagTests Assert.Equal(HJustify.Left, merged.HJustify); } + // ── Outline (0x21) / OutlineColor (0x22) — Campaign CH round 4 ───────────── + // Exercises ApplyCanonicalLegacyProjection (invoked by ElementReader.Merge) + // reading raw StateDesc-shaped properties, the same layer HJustify/FontColor + // are already pinned at above — one level below the flattened ElementInfo + // fields ElementReaderTests.cs covers. + + /// + /// The SpewBox line template's own shape: state 0x10000002 authors property + /// 0x21 (Outline) = true with no 0x22 (OutlineColor) — retail leaves + /// m_curOutlineColor at its ctor black default in that case. + /// + [Fact] + public void ElementMerge_Property0x21True_SetsOutline_WithNoAuthoredColor() + { + var info = new ElementInfo { DefaultStateId = 1u }; + info.States[1u] = State(1u, "0x10000002", (0x21u, Bool(true))); + + var merged = ElementReader.Merge(new ElementInfo(), info); + + Assert.True(merged.Outline); + Assert.Null(merged.OutlineColor); + } + + /// + /// The chat transcript base style's own shape: DirectState authors neither + /// 0x21 nor 0x22 anywhere in its chain — Outline must stay false, matching + /// "the chat transcript is NOT outlined in retail" (research doc §2.5). + /// + [Fact] + public void ElementMerge_NoOutlineProperty_StaysFalse() + { + var info = new ElementInfo(); + info.States[UiStateInfo.DirectStateId] = State( + UiStateInfo.DirectStateId, + "", + (0x1Bu, Color(204, 204, 204, 255))); + + var merged = ElementReader.Merge(new ElementInfo(), info); + + Assert.False(merged.Outline); + Assert.Null(merged.OutlineColor); + } + + /// + /// Property 0x22 (OutlineColor) reads a ColorBaseProperty into a normalized + /// Vector4 exactly like 0x1B (FontColor) does — one of the 9 elements in the + /// whole DAT set that authors a non-default outline colour (values observed: + /// ARGB(255,17,15,7) and ARGB(255,0,0,102)). + /// + [Fact] + public void ElementMerge_Property0x22_SetsOutlineColorFromAuthoredArgb() + { + var info = new ElementInfo(); + info.States[UiStateInfo.DirectStateId] = State( + UiStateInfo.DirectStateId, + "", + (0x21u, Bool(true)), + (0x22u, Color(17, 15, 7, 255))); + + var merged = ElementReader.Merge(new ElementInfo(), info); + + Assert.True(merged.Outline); + Assert.Equal(new Vector4(17f / 255f, 15f / 255f, 7f / 255f, 1f), merged.OutlineColor); + } + [Fact] public void ConvertProperty_PreservesNestedRetailValues() { @@ -139,6 +204,9 @@ public sealed class UiPropertyBagTests private static UiPropertyValue Enum(uint value) => new() { Kind = UiPropertyKind.Enum, UnsignedValue = value }; + private static UiPropertyValue Color(byte red, byte green, byte blue, byte alpha) + => new() { Kind = UiPropertyKind.Color, ColorValue = new UiColorValue(blue, green, red, alpha) }; + private static UiStateInfo State( uint id, string name, diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_2100006f.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_2100006f.json index d0b4076b..951698c3 100644 --- a/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_2100006f.json +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_2100006f.json @@ -200,6 +200,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": {}, "StateCursors": {}, "DefaultStateName": "", @@ -604,6 +606,8 @@ "Z": 1, "W": 1 }, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100688408, @@ -685,6 +689,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100693183, @@ -1095,6 +1101,8 @@ "Z": 1, "W": 1 }, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100688408, @@ -1176,6 +1184,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100693188, @@ -1253,6 +1263,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100693184, @@ -1336,6 +1348,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Maximized": { "Item1": 100687460, @@ -1417,6 +1431,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100693189, @@ -1494,6 +1510,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100693185, @@ -1571,6 +1589,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100693190, @@ -1648,6 +1668,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100693186, @@ -1759,6 +1781,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100688169, @@ -1818,6 +1842,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100688170, @@ -1936,6 +1962,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100688169, @@ -2025,6 +2053,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100688171, @@ -2079,6 +2109,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100682946, @@ -3051,6 +3083,8 @@ "Z": 0.8, "W": 1 }, + "Outline": false, + "OutlineColor": null, "StateMedia": {}, "StateCursors": {}, "DefaultStateName": "", @@ -3218,6 +3252,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100687630, @@ -3446,6 +3482,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100682847, @@ -3552,6 +3590,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": {}, "StateCursors": {}, "DefaultStateName": "Normal", @@ -3632,6 +3672,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100682848, @@ -3726,6 +3768,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100682851, @@ -3820,6 +3864,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100682854, @@ -4033,6 +4079,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100682857, @@ -4244,6 +4292,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100682860, @@ -4367,6 +4417,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100688169, @@ -4456,6 +4508,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100688172, @@ -4574,6 +4628,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100688169, @@ -4658,6 +4714,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100667706, @@ -4933,6 +4991,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100683109, @@ -5320,6 +5380,8 @@ "Z": 1, "W": 1 }, + "Outline": false, + "OutlineColor": null, "StateMedia": {}, "StateCursors": {}, "DefaultStateName": "", @@ -6304,6 +6366,8 @@ "Z": 1, "W": 1 }, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal_focussed": { "Item1": 100667819, @@ -6361,6 +6425,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal_focussed": { "Item1": 100683111, @@ -6419,6 +6485,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal_focussed": { "Item1": 100683111, @@ -6776,6 +6844,8 @@ "Z": 1, "W": 1 }, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100669717, @@ -6868,6 +6938,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100688173, @@ -7285,6 +7357,8 @@ "Z": 1, "W": 1 }, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100688408, @@ -7699,6 +7773,8 @@ "Z": 1, "W": 1 }, + "Outline": false, + "OutlineColor": null, "StateMedia": { "Normal": { "Item1": 100688408, @@ -7780,6 +7856,8 @@ "HJustify": 1, "VJustify": 1, "FontColor": null, + "Outline": false, + "OutlineColor": null, "StateMedia": { "": { "Item1": 100693187, diff --git a/tests/AcDream.App.Tests/UI/SpewBoxControllerTests.cs b/tests/AcDream.App.Tests/UI/SpewBoxControllerTests.cs index 182d6b62..c1a56623 100644 --- a/tests/AcDream.App.Tests/UI/SpewBoxControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/SpewBoxControllerTests.cs @@ -252,4 +252,53 @@ public sealed class SpewBoxControllerTests Assert.Null(text.DatFont); Assert.Null(text.Font); } + + // ── Campaign CH user-gate round 4 (2026-08-10) — font/outline AUTHORED ── + + [Fact] + public void RetailFontId_IsTheAuthoredEighteenPixelFace_NotTheRound3Heuristic() + { + // docs/research/2026-08-10-retail-ui-text-style.md §4.2: base style + // 0x10000377 (the line template's own BaseElement) authors FontDID + // [0x40000001] directly — three independent cross-checks (FontDID, + // the 18px authored line height, and 4x18=72 = the authored box + // height) all agree. Font 0x40000025 (the round-3 "smallest + // confirmed-used font" heuristic) is retired. + Assert.Equal(0x40000001u, SpewBoxController.RetailFontId); + } + + [Fact] + public void Construction_SetsOutlineTrue_MatchingTheAuthoredLineTemplate() + { + // The line template's state 0x10000002 authors property 0x21 + // (Outline) = true with no authored 0x22 (OutlineColor), so the + // ctor black default applies — the heavy black border in the + // user's retail screenshot that earlier rounds never reproduced. + var root = new UiRoot { Width = 1280f, Height = 720f }; + using var controller = new SpewBoxController(root, new SpewBoxVM(new SpewBoxState())); + + UiText text = Assert.IsType(root.Children.OfType().Single()); + Assert.True(text.Outline); + Assert.Equal(UiRenderContext.DefaultOutlineColor, text.OutlineColor); + } + + [Fact] + public void Construction_WithTheAuthoredFont_LineHeightMatchesTheAuthoredEighteenPixelBox() + { + // Three-way cross-check from the class remarks: the resolved font's + // MaxCharHeight (18) matches the authored per-line box height (18), + // and 4 concurrent items x 18px = the authored 72px box height. + var root = new UiRoot { Width = 1280f, Height = 720f }; + var font = new UiDatFont( + fgTex: 1, fgW: 64, fgH: 64, + bgTex: 2, bgW: 64, bgH: 64, // background atlas present, like the real 0x40000001 + lineHeight: 18f, baselineOffset: 14f, + glyphs: new Dictionary()); + + using var controller = new SpewBoxController( + root, new SpewBoxVM(new SpewBoxState()), font, debugFont: null); + + UiText text = Assert.IsType(root.Children.OfType().Single()); + Assert.Equal(18f, text.DatFont!.LineHeight); + } } diff --git a/tests/AcDream.App.Tests/UI/UiDatFontBorderPixelTests.cs b/tests/AcDream.App.Tests/UI/UiDatFontBorderPixelTests.cs new file mode 100644 index 00000000..a89293e2 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiDatFontBorderPixelTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AcDream.App.UI; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using SysEnv = System.Environment; + +namespace AcDream.App.Tests.UI; + +/// +/// Pins Font.NumHorizontalBorderPixels/NumVerticalBorderPixels +/// (Font::Serialize @0x00443650) against the real installed DAT, and the +/// plumbing that carries them from +/// onto / +/// — the field this project's font reader +/// dropped entirely before Campaign CH round 4 +/// (docs/research/2026-08-10-retail-ui-text-style.md §1.4). A repo-wide +/// grep for BorderPixel returned zero hits before this fix; these tests +/// are the regression guard against that gap reappearing. +/// +/// +/// The live-dat tests follow 's +/// pattern: skip (not fail) when the real dats aren't present, so the suite stays +/// green in environments without the installed game. +/// +/// +public sealed class UiDatFontBorderPixelTests +{ + // Measured values — docs/research/2026-08-10-retail-ui-text-style.md §1.3. + [Theory] + [InlineData(0x40000000u, 4u, 4u)] // 16px bold serif — chat transcript face + [InlineData(0x40000001u, 4u, 4u)] // 18px bold serif — SpewBox face (round 4) + [InlineData(0x40000002u, 3u, 3u)] // 14px bold serif + [InlineData(0x40000025u, 3u, 3u)] // 11px — the pre-round-4 SpewBox placeholder face + public void RealDatFont_HasExpectedBorderPixels(uint fontId, uint expectedHorizontal, uint expectedVertical) + { + string? datDir = ResolveDatDir(); + if (datDir is null) + return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + Assert.True(dats.TryGet(fontId, out Font? font), $"Font 0x{fontId:X8} not found"); + Assert.NotNull(font); + Assert.Equal(expectedHorizontal, font!.NumHorizontalBorderPixels); + Assert.Equal(expectedVertical, font.NumVerticalBorderPixels); + } + + [Fact] + public void RealDatFont_EveryFontWithABackgroundAtlas_HasANonZeroBorder() + { + // docs/research/2026-08-10-retail-ui-text-style.md §1.2: "every font that + // has a background atlas has border >= 3, and every font without one has + // border == 0" — the data-driven dispatch DrawStringDat's outline pass + // relies on (background plane vs 8-neighbour fallback) is exactly this + // correlation. Sweep the documented populated range (0x40000000-0x40000032). + string? datDir = ResolveDatDir(); + if (datDir is null) + return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + int checkedCount = 0; + for (uint id = 0x40000000u; id <= 0x40000032u; id++) + { + if (!dats.TryGet(id, out Font? font) || font is null) + continue; + checkedCount++; + + bool hasBackground = font.BackgroundSurfaceDataId != 0; + bool hasBorder = font.NumHorizontalBorderPixels > 0 || font.NumVerticalBorderPixels > 0; + Assert.True( + hasBackground == hasBorder, + $"Font 0x{id:X8}: hasBackground={hasBackground} but hasBorder={hasBorder}"); + } + + Assert.True(checkedCount > 10, "expected the documented font sweep to find multiple populated fonts"); + } + + /// + /// Pure plumbing check — no dat, no GL: the ctor + /// stores / verbatim onto + /// /, and existing + /// callers that omit them (pre-round-4 test fixtures) still default to zero. + /// + [Theory] + [InlineData(4, 4)] + [InlineData(3, 3)] + [InlineData(0, 0)] + public void Ctor_StoresBorderPixelsVerbatim(int borderX, int borderY) + { + var font = new UiDatFont( + fgTex: 1, fgW: 64, fgH: 64, + bgTex: 2, bgW: 64, bgH: 64, + lineHeight: 16f, baselineOffset: 12f, + glyphs: new Dictionary(), + borderX: borderX, borderY: borderY); + + Assert.Equal(borderX, font.BorderX); + Assert.Equal(borderY, font.BorderY); + } + + [Fact] + public void Ctor_OmittedBorderPixels_DefaultToZero() + { + // Pins backward compatibility for the existing UiDatFontTests/SpewBoxControllerTests + // call sites that construct UiDatFont without borderX/borderY. + var font = new UiDatFont( + fgTex: 0, fgW: 0, fgH: 0, + bgTex: 0, bgW: 0, bgH: 0, + lineHeight: 16f, baselineOffset: 12f, + glyphs: new Dictionary()); + + Assert.Equal(0, font.BorderX); + Assert.Equal(0, font.BorderY); + } + + private static string? ResolveDatDir() + { + string? fromEnv = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + + string defaultDir = Path.Combine( + SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + return Directory.Exists(defaultDir) ? defaultDir : null; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiRenderContextDrawStringDatOutlineTests.cs b/tests/AcDream.App.Tests/UI/UiRenderContextDrawStringDatOutlineTests.cs new file mode 100644 index 00000000..03304771 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiRenderContextDrawStringDatOutlineTests.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.App.UI; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.UI; + +/// +/// Campaign CH round 4 (docs/research/2026-08-10-retail-ui-text-style.md): +/// pins 's retail two-pass outline +/// model — whole-string outline pass THEN whole-string fill pass +/// (UIElement_Text::DrawSelf @0x00467aa0), the outline-color tint +/// (property 0x22, default black), the background-plane INFLATION by the +/// font's border-pixel margin (CreateCharRectPair @0x00441480), and the +/// 8-neighbour ±1px fallback for fonts with no background atlas. +/// +/// +/// Builds a real over the in-memory +/// test double (no live GPU) and reads back +/// — the full per-vertex +/// float buffer, not just texture/count/alpha — so position, UV span, and RGB +/// tint are all directly assertable. See +/// for the sibling +/// alpha-chokepoint coverage this file does not duplicate. +/// +/// +public sealed class UiRenderContextDrawStringDatOutlineTests +{ + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private const uint ForegroundTex = 1u; + private const uint BackgroundTex = 2u; + + private static (TextRenderer renderer, UiRenderContext ctx) Build() + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(800f, 600f)); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + return (renderer, ctx); + } + + private static FontCharDesc Glyph( + char c, ushort offsetX, ushort offsetY, byte width, byte height, + sbyte before = 0, sbyte after = 0, sbyte vBefore = 0) + => new() + { + Unicode = c, + Width = width, + Height = height, + OffsetX = offsetX, + OffsetY = offsetY, + HorizontalOffsetBefore = before, + HorizontalOffsetAfter = after, + VerticalOffsetBefore = vBefore, + }; + + /// One quad's decoded first-two-vertex geometry: (x,y,w,h) in dest + /// pixel space, (u0,v0,u1,v1) atlas UVs, (r,g,b,a) tint — reconstructed from + /// 's known 6-vertex/8-float layout + /// (V0 = top-left, V1 = bottom-right of the first triangle). + private readonly record struct Quad( + float X, float Y, float W, float H, + float U0, float V0, float U1, float V1, + float R, float G, float B, float A); + + private static List DecodeQuads(IReadOnlyList verts) + { + var quads = new List(); + const int floatsPerVertex = 8; + const int floatsPerQuad = floatsPerVertex * 6; + for (int i = 0; i + floatsPerQuad <= verts.Count; i += floatsPerQuad) + { + float x0 = verts[i + 0], y0 = verts[i + 1], u0 = verts[i + 2], v0 = verts[i + 3]; + float r = verts[i + 4], g = verts[i + 5], b = verts[i + 6], a = verts[i + 7]; + // Second vertex (index 1) is (x+w, y+h, u1, v1) per AppendQuad's V0..V5 order. + float x1 = verts[i + 8 + 0], y1 = verts[i + 8 + 1], u1 = verts[i + 8 + 2], v1 = verts[i + 8 + 3]; + quads.Add(new Quad(x0, y0, x1 - x0, y1 - y0, u0, v0, u1, v1, r, g, b, a)); + } + return quads; + } + + // ── Two-pass ordering ──────────────────────────────────────────────────── + + [Fact] + public void Outline_TwoGlyphString_EmitsOneWholeStringOutlineSegmentThenOneWholeStringFillSegment() + { + // Retail runs the ENTIRE glyph run's outline pass first, then the + // ENTIRE fill pass (UIElement_Text::DrawSelf 0x00467aa0) — not + // interleaved per glyph. Two glyphs on the SAME (background) texture + // batch into ONE segment; the fill pass on the SAME (foreground) + // texture batches into a SECOND segment. An interleaved + // outline-A/fill-A/outline-B/fill-B implementation would instead + // alternate textures and produce FOUR segments. + (TextRenderer renderer, UiRenderContext ctx) = Build(); + var font = new UiDatFont( + fgTex: ForegroundTex, fgW: 64, fgH: 64, + bgTex: BackgroundTex, bgW: 64, bgH: 64, + lineHeight: 16f, baselineOffset: 12f, + glyphs: new Dictionary + { + ['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8), + ['B'] = Glyph('B', offsetX: 4, offsetY: 4, width: 8, height: 8, before: 1), + }, + borderX: 4, borderY: 4); + + ctx.DrawStringDat(font, "AB", 0, 0, new Vector4(1f, 1f, 1f, 1f), outline: true); + + var segs = renderer.DebugSpriteSegments; + Assert.Equal(2, segs.Count); + Assert.Equal(BackgroundTex, segs[0].Texture); + Assert.Equal(12, segs[0].VertexCount); // 2 glyphs × 6 verts, ONE segment + Assert.Equal(ForegroundTex, segs[1].Texture); + Assert.Equal(12, segs[1].VertexCount); + } + + [Fact] + public void NoOutline_OnlyOneFillPassRuns() + { + (TextRenderer renderer, UiRenderContext ctx) = Build(); + var font = new UiDatFont( + fgTex: ForegroundTex, fgW: 64, fgH: 64, + bgTex: BackgroundTex, bgW: 64, bgH: 64, + lineHeight: 16f, baselineOffset: 12f, + glyphs: new Dictionary + { + ['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8), + }, + borderX: 4, borderY: 4); + + ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 1f, 1f), outline: false); + + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(ForegroundTex, seg.Texture); + } + + // ── Tint ───────────────────────────────────────────────────────────────── + + [Fact] + public void Outline_UsesOutlineColor_NotFillColor() + { + (TextRenderer renderer, UiRenderContext ctx) = Build(); + var font = new UiDatFont( + fgTex: ForegroundTex, fgW: 64, fgH: 64, + bgTex: BackgroundTex, bgW: 64, bgH: 64, + lineHeight: 16f, baselineOffset: 12f, + glyphs: new Dictionary + { + ['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8), + }, + borderX: 4, borderY: 4); + + var fill = new Vector4(1f, 1f, 0.247f, 1f); // SpewBox-style gold/yellow fill + var outlineColor = new Vector4(0.2f, 0.4f, 0.6f, 1f); // an arbitrary non-black outline + + ctx.DrawStringDat(font, "A", 0, 0, fill, outline: true, outlineColor: outlineColor); + + var segVerts = renderer.DebugSpriteSegmentVerts; + Assert.Equal(2, segVerts.Count); + + Quad outlineQuad = Assert.Single(DecodeQuads(segVerts[0].Verts)); + Assert.Equal(outlineColor.X, outlineQuad.R, 5); + Assert.Equal(outlineColor.Y, outlineQuad.G, 5); + Assert.Equal(outlineColor.Z, outlineQuad.B, 5); + + Quad fillQuad = Assert.Single(DecodeQuads(segVerts[1].Verts)); + Assert.Equal(fill.X, fillQuad.R, 5); + Assert.Equal(fill.Y, fillQuad.G, 5); + Assert.Equal(fill.Z, fillQuad.B, 5); + } + + [Fact] + public void Outline_DefaultsToBlack_WhenNoOutlineColorSupplied() + { + // Retail ctor default (RGBAColor_Black, UIElement_Text::UIElement_Text + // @0x004686cb) — only 9 elements in the whole DAT set author property + // 0x22, so the overwhelming majority of outlined text uses this default. + (TextRenderer renderer, UiRenderContext ctx) = Build(); + var font = new UiDatFont( + fgTex: ForegroundTex, fgW: 64, fgH: 64, + bgTex: BackgroundTex, bgW: 64, bgH: 64, + lineHeight: 16f, baselineOffset: 12f, + glyphs: new Dictionary + { + ['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8), + }, + borderX: 4, borderY: 4); + + ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 0.247f, 1f), outline: true); + + var segVerts = renderer.DebugSpriteSegmentVerts; + Quad outlineQuad = Assert.Single(DecodeQuads(segVerts[0].Verts)); + Assert.Equal(0f, outlineQuad.R); + Assert.Equal(0f, outlineQuad.G); + Assert.Equal(0f, outlineQuad.B); + } + + // ── Background-plane inflation (Fix 1+2) ──────────────────────────────── + + [Fact] + public void Outline_BackgroundPass_InflatesDestAndSourceRectByBorderPixels() + { + // §3.3 of the research doc: CreateCharRectPair inflates BOTH the source + // sub-rect and the destination rect by (BorderX, BorderY) on every side. + // Un-inflated (acdream's prior behavior) crops the dilated background + // glyph away almost entirely — this is Fix 1+2's whole point. + (TextRenderer renderer, UiRenderContext ctx) = Build(); + const int borderX = 4, borderY = 3; + const int atlasW = 100, atlasH = 100; + var font = new UiDatFont( + fgTex: ForegroundTex, fgW: atlasW, fgH: atlasH, + bgTex: BackgroundTex, bgW: atlasW, bgH: atlasH, + lineHeight: 16f, baselineOffset: 12f, + glyphs: new Dictionary + { + // OffsetX/Y >= border, matching every measured real font (§1.2: + // "the FG glyph always sits at exactly (hB, vB) inside the + // inflated window"). + ['A'] = Glyph('A', offsetX: 10, offsetY: 8, width: 9, height: 8), + }, + borderX: borderX, borderY: borderY); + + ctx.DrawStringDat(font, "A", 20f, 5f, new Vector4(1f, 1f, 1f, 1f), outline: true); + + var segVerts = renderer.DebugSpriteSegmentVerts; + Quad bg = Assert.Single(DecodeQuads(segVerts[0].Verts)); + Quad fg = Assert.Single(DecodeQuads(segVerts[1].Verts)); + + // Foreground (fill) quad is UN-inflated — the glyph's own box. + Assert.Equal(9f, fg.W, 3); + Assert.Equal(8f, fg.H, 3); + Assert.Equal(10f / atlasW, fg.U0, 5); + Assert.Equal(8f / atlasH, fg.V0, 5); + Assert.Equal(19f / atlasW, fg.U1, 5); // (offsetX + width) / atlasW + Assert.Equal(16f / atlasH, fg.V1, 5); // (offsetY + height) / atlasH + + // Background (outline) quad is inflated by (borderX, borderY) on every side: + // dest position moves up/left by the border, size grows by 2×border, and the + // SOURCE UV rect shifts by the same amount in the SAME direction (not cropped + // to the foreground glyph's own box). + Assert.Equal(fg.X - borderX, bg.X, 3); + Assert.Equal(fg.Y - borderY, bg.Y, 3); + Assert.Equal(fg.W + 2 * borderX, bg.W, 3); + Assert.Equal(fg.H + 2 * borderY, bg.H, 3); + Assert.Equal((10 - borderX) / (float)atlasW, bg.U0, 5); + Assert.Equal((8 - borderY) / (float)atlasH, bg.V0, 5); + Assert.Equal((10 - borderX + 9 + 2 * borderX) / (float)atlasW, bg.U1, 5); + Assert.Equal((8 - borderY + 8 + 2 * borderY) / (float)atlasH, bg.V1, 5); + } + + [Fact] + public void Outline_ZeroBorderFont_BackgroundPassMatchesForegroundRectExactly() + { + // A font with a background atlas but border == 0 (not currently observed + // in the shipped DATs — every bordered font measures border >= 3 — but + // the inflation math must degrade to "no-op" rather than misbehave). + (TextRenderer renderer, UiRenderContext ctx) = Build(); + var font = new UiDatFont( + fgTex: ForegroundTex, fgW: 64, fgH: 64, + bgTex: BackgroundTex, bgW: 64, bgH: 64, + lineHeight: 16f, baselineOffset: 12f, + glyphs: new Dictionary + { + ['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8), + }, + borderX: 0, borderY: 0); + + ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 1f, 1f), outline: true); + + var segVerts = renderer.DebugSpriteSegmentVerts; + Quad bg = Assert.Single(DecodeQuads(segVerts[0].Verts)); + Quad fg = Assert.Single(DecodeQuads(segVerts[1].Verts)); + + Assert.Equal(fg.X, bg.X, 3); + Assert.Equal(fg.Y, bg.Y, 3); + Assert.Equal(fg.W, bg.W, 3); + Assert.Equal(fg.H, bg.H, 3); + Assert.Equal(fg.U0, bg.U0, 5); + Assert.Equal(fg.U1, bg.U1, 5); + } + + // ── 8-neighbour fallback (no background atlas) ────────────────────────── + + [Fact] + public void Outline_FontWithNoBackgroundAtlas_FallsBackToEightNeighbourForegroundBlits() + { + // Retail's fallback for 0-border fonts (the CJK/unicode family): + // UIElement_Text::DrawSelf 0x00467d7e-0x00467e14 blits the FOREGROUND + // glyph 8 times at ±1px around the fill position, tinted with the + // outline color. Total: 8 outline quads + 1 fill quad, all on the same + // (foreground) texture, so they batch into ONE segment. + (TextRenderer renderer, UiRenderContext ctx) = Build(); + var font = new UiDatFont( + fgTex: ForegroundTex, fgW: 64, fgH: 64, + bgTex: 0, bgW: 0, bgH: 0, + lineHeight: 16f, baselineOffset: 12f, + glyphs: new Dictionary + { + ['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8), + }, + borderX: 0, borderY: 0); + + ctx.DrawStringDat(font, "A", 20f, 10f, new Vector4(1f, 1f, 1f, 1f), outline: true); + + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(ForegroundTex, seg.Texture); + Assert.Equal(9, seg.VertexCount / 6); // 8 neighbour outline blits + 1 fill blit + + var quads = DecodeQuads(renderer.DebugSpriteSegmentVerts[0].Verts); + Assert.Equal(9, quads.Count); + + // The fill position (gx, gy) is present exactly once — the fill pass — + // and every one of the 8 immediate neighbours is present exactly once — + // the outline pass — covering the full 3x3 block around it with no gaps + // and no duplicates. + var seen = new HashSet<(int dx, int dy)>(); + foreach (Quad q in quads) + { + int dx = (int)MathF.Round(q.X - 20f); + int dy = (int)MathF.Round(q.Y - 10f); + Assert.InRange(dx, -1, 1); + Assert.InRange(dy, -1, 1); + Assert.True(seen.Add((dx, dy)), $"duplicate blit at offset ({dx},{dy})"); + } + Assert.Equal(9, seen.Count); // all 9 positions in the 3x3 block, each exactly once + } +}