diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 32c4eeb7..85413580 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -74,30 +74,40 @@ after each deliberate `Top` write for the imported-layout element. Precedent: `MapPageController.cs:235-249` (the same fix already landed for other runtime-repositioned imported/programmatic elements). -## #488 — MossTank `.utl` expression block: length prefix measured before newline normalization +## #489 — Headless: SpewBox pending queue grows unbounded when no console ticks it; console polish -**Status:** OPEN — found 2026-09-07 by the final Opus re-check of Campaign VT -slice 1 Part A (`f58e997b1`), not reachable from the UI. -**Severity:** LOW (latent) -**Component:** `src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs` (`AttachMossTankExpressions` ~504-521, `ApplyMossTankExpressions` ~539-554) vs `VtankLootProfileSerializer.cs` (`WriteBlock` ~357-366, `NormalizePayload`) +**Status:** OPEN — found 2026-09-07 by the Opus re-check of the headless console (`738111239`). +**Severity:** LOW/MEDIUM (leak in long-lived bots) +**Component:** `src/AcDream.Runtime/.../SpewBoxState.cs` (`Enqueue` ~:110, `_pending`), `src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs` -**Description.** The MossTank-owned unknown block that carries each loot rule's -`Expression` text writes `expression.Length` as a length prefix and then the raw -text; `WriteBlock` normalizes the whole payload afterwards, rewriting a lone -` +**Description.** `RuntimeCommunicationState.AddText` routes every `ClientLocal` (0x1A) line into `SpewBoxState.Enqueue`; the only `Tick` caller in the headless host is the console pump, so with the console disabled (every scripted/CI bot) `_pending` grows for the life of the session. Pre-existing before the console; the console merely made it visible. Fix shape: tick the SpewBox from the session tick regardless of the console (or drop `ClientLocal` text when nothing observes it), with a pin that a 10,000-line burst without a console does not grow the queue. + +**Polish carried from the same re-check:** in `--console` mode the JSON diagnostics/resources stream still interleaves with the chat lines on stdout — quiet it or send it to stderr when the console is on; `--console` missing from `--help`; `HeadlessConsoleOptions.cs:51` re-types the env-var literal (the LaunchOptions regex needs it — a const rename would split the two reads); the `/quit`/`/status`/"not handled" writes and `Pump()` sit outside the S4 try/catch (a broken stdout pipe would fault the session); the SpewBox's 4-entry visible cap can drop interface-text lines produced between two pumps. + +## #488 — MossTank `.utl` expression block: length prefix measured before newline normalization + +**Status:** OPEN — found 2026-09-07 by the final Opus re-check of Campaign VT +slice 1 Part A (`f58e997b1`), not reachable from the UI. +**Severity:** LOW (latent) +**Component:** `src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs` (`AttachMossTankExpressions` ~504-521, `ApplyMossTankExpressions` ~539-554) vs `VtankLootProfileSerializer.cs` (`WriteBlock` ~357-366, `NormalizePayload`) + +**Description.** The MossTank-owned unknown block that carries each loot rule's +`Expression` text writes `expression.Length` as a length prefix and then the raw +text; `WriteBlock` normalizes the whole payload afterwards, rewriting a lone +` `/` ` to ` -`. An expression containing a bare newline therefore grows -after its prefix was measured, the reader truncates it, lands mid-text on the -next length line, fails `int.TryParse` and silently abandons every remaining -rule's expression. The loot expression control is a single-line field so the UI -cannot author one; the legacy-JSON sweep can (free-form JSON). - -**Fix shape.** Normalize the expression before measuring it (or escape/refuse -newlines in the block), with a pin that writes a two-line expression and reads -it back through `VtankLootProfileSerializer.TryRead`. Companion cosmetics from -the same re-check: the unreachable `remaining` roster branch in the route and -loot sweeps, and the meta Delete notice printing the raw file name. - +`. An expression containing a bare newline therefore grows +after its prefix was measured, the reader truncates it, lands mid-text on the +next length line, fails `int.TryParse` and silently abandons every remaining +rule's expression. The loot expression control is a single-line field so the UI +cannot author one; the legacy-JSON sweep can (free-form JSON). + +**Fix shape.** Normalize the expression before measuring it (or escape/refuse +newlines in the block), with a pin that writes a two-line expression and reads +it back through `VtankLootProfileSerializer.TryRead`. Companion cosmetics from +the same re-check: the unreachable `remaining` roster branch in the route and +loot sweeps, and the meta Delete notice printing the raw file name. + ## #487 — Radar compass tokens may be pinned by the anchor pass (candidate) **Status:** OPEN — CANDIDATE, found 2026-09-06 by the Opus review of @@ -5922,6 +5932,13 @@ slice CH4). ## #363 — Chat refusal/usage call sites are typed ClientLocal 0x00 where retail types several 0x1A +**2026-09-07 owner-directed re-route:** the "Unknown command" refusals this +issue's closure routed to `ShowInterfaceText`/SpewBox now route to +`ShowSystemMessage`/the chat scroll instead, per explicit owner direction +that unknown commands must be visible in chat, not the SpewBox overlay. +Every OTHER site this issue named (bad-args refusals of real commands, +AP-183) is unaffected. See register row AD-124. + **Status:** CLOSED 2026-08-10. `ChatVM` gained a typed interface-text seam (`OnInterfaceText` init hook + `ShowInterfaceText(text)`) that the App-layer composition (`InteractionRetainedUiComposition.CreateRetainedUi`) wires to @@ -6261,6 +6278,14 @@ still missing); `src/AcDream.App/UI/Layout/LayoutImporter.cs` ## #367 — ChatCommandRouter's local-presentation fallbacks type-0x1A text still lands in the chat scroll, never the SpewBox +**2026-09-07 owner-directed re-route:** the two fallbacks this issue named +(`RetailCommandHelpTable.UnknownCommand` in `EmitVerbHelp`, and the +degenerate-prefix "Unknown command: {verb}." refusal) now call +`ShowSystemMessage(...)` again — back to the chat scroll, by explicit owner +direction that unknown commands must be visible there rather than in the +SpewBox this issue's 2026-08-10 closure moved them to. See register row +AD-124; this is a deliberate re-reversal, not a regression of this issue. + **Status:** CLOSED 2026-08-10, closed as a side effect of #363's interface-text seam (fix shape (a) from this issue's own filing). `ChatVM.OnInterfaceText` is exactly the hook this issue asked for; both diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 509afc36..655bfb6c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -76,7 +76,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 94 active rows (AD-123 filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 9 — MossTank keeps a ByCharacter auto loot .utl file for internal consistency across all four stores, where retail's own loot picker seeds only [None] and has no per-character auto file at all; AD-122 filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 7 — MossTank's .cdf writes real VTank-native Nav/Meta filenames as its own .af format instead of .nav/.met, unreadable by a real VTank instance sharing the same profile directory; AD-120 filed 2026-09-04 at the S4-c2 fix round 1 (M3) — a translucent building-shell instance under building detail draws immediately at its own walk-stream alpha-submission mark rather than "in place" mid-mesh-call, since acdream's opaque instances are stream-batched and retail's mesh call has no equivalent; AD-119 filed 2026-09-03 at Campaign OVERHAUL v2 S4 chunk 1 (S4-c1 C2) — the portal-depth color path substitutes a `ColorWrite=false` write mask for retail's zero-source-alpha `SRCALPHA`/`INVSRCALPHA` blend, a provably pixel-identical no-op either way; AD-117 filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round — three residual Contract A/B approximations the S2 retail-lens review named (visual-AABB circumsphere cheap reject, part rows published into unloaded neighbour cells, the unported `state & 0x1000` particle branch) — its original item 1, the render-only destination-cell move rule, was VERIFIED the same night as retail's own zero-sphere `CObjCell::find_cell_list` 0x0052b4e0 mechanism and is not a deviation; AD-116 filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 — `WalkProductionWorldData`'s borrowed per-cell view contributes NO cell for an entity the registry has flooded but the presentation scene cannot resolve yet (the deleted parent-cell/root-position fallbacks are gone), counted once per distinct entity per frame in `UnregisteredRenderMembershipCount`; AD-115 filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16) — `BuildCharacterTitleDisplay` clears the Profession element (`0x10000151`) when neither Int 261 CharacterTitleId nor String 5 Template resolves, where retail never clears `0x10000150`/`51`/`52` anywhere and would instead show the PREVIOUS target's stale title; AD-114 filed 2026-08-25 at Campaign AS slice AS2, owner-ruled ("we animate it, and I like it") — the examination window's preview clone tracks the assessed creature's live current animated pose every frame, where retail's clone plays its own private `CreatureMode` cycle decoupled from the live target's actual motion; AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 RETIRED 2026-08-28 — #386's named-retail message trace confirmed the vendor popup is content-sized and installed-DAT property 0x79 hides its disabled scrollbar; both behaviors are now ported; AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- +## 2. Adaptation (AD) — 95 active rows (AD-124 filed 2026-09-07, owner-directed — ChatCommandRouter's "Unknown command" refusals and plugin-originated system text (IPluginChat.PostSystemMessage) now route to the chat scroll instead of retail's own ClientLocal/0x1A SpewBox-only typing; AD-123 filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 9 — MossTank keeps a ByCharacter auto loot .utl file for internal consistency across all four stores, where retail's own loot picker seeds only [None] and has no per-character auto file at all; AD-122 filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 7 — MossTank's .cdf writes real VTank-native Nav/Meta filenames as its own .af format instead of .nav/.met, unreadable by a real VTank instance sharing the same profile directory; AD-120 filed 2026-09-04 at the S4-c2 fix round 1 (M3) — a translucent building-shell instance under building detail draws immediately at its own walk-stream alpha-submission mark rather than "in place" mid-mesh-call, since acdream's opaque instances are stream-batched and retail's mesh call has no equivalent; AD-119 filed 2026-09-03 at Campaign OVERHAUL v2 S4 chunk 1 (S4-c1 C2) — the portal-depth color path substitutes a `ColorWrite=false` write mask for retail's zero-source-alpha `SRCALPHA`/`INVSRCALPHA` blend, a provably pixel-identical no-op either way; AD-117 filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round — three residual Contract A/B approximations the S2 retail-lens review named (visual-AABB circumsphere cheap reject, part rows published into unloaded neighbour cells, the unported `state & 0x1000` particle branch) — its original item 1, the render-only destination-cell move rule, was VERIFIED the same night as retail's own zero-sphere `CObjCell::find_cell_list` 0x0052b4e0 mechanism and is not a deviation; AD-116 filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 — `WalkProductionWorldData`'s borrowed per-cell view contributes NO cell for an entity the registry has flooded but the presentation scene cannot resolve yet (the deleted parent-cell/root-position fallbacks are gone), counted once per distinct entity per frame in `UnregisteredRenderMembershipCount`; AD-115 filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16) — `BuildCharacterTitleDisplay` clears the Profession element (`0x10000151`) when neither Int 261 CharacterTitleId nor String 5 Template resolves, where retail never clears `0x10000150`/`51`/`52` anywhere and would instead show the PREVIOUS target's stale title; AD-114 filed 2026-08-25 at Campaign AS slice AS2, owner-ruled ("we animate it, and I like it") — the examination window's preview clone tracks the assessed creature's live current animated pose every frame, where retail's clone plays its own private `CreatureMode` cycle decoupled from the live target's actual motion; AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 RETIRED 2026-08-28 — #386's named-retail message trace confirmed the vendor popup is content-sized and installed-DAT property 0x79 hides its disabled scrollbar; both behaviors are now ported; AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- -to-line-break shaping, which retail's `ItemExamineUI::AddItemInfo @0x004AC050` does not do (wire text appends verbatim; the escape decode retail runs at `StringInfo` resolution now lives at our string source, `DatStringResolver` → `RetailStringEscapes`); AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate @@ -120,6 +120,7 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AD-124 | **Filed 2026-09-07, owner-directed.** Retail types two families of chat feedback as `ClientLocal` (0x1A) — the bit every `ChatInterface` window's default filter excludes, so they only ever reach the transient SpewBox overlay: (1) `ChatCommandRouter`'s "Unknown command" refusals (the degenerate-prefix guard's "Unknown command: {verb}." text, and both `EmitVerbHelp` fallbacks that print `RetailCommandHelpTable.UnknownCommand`); (2) plugin-originated system text (`IPluginChat.PostSystemMessage`, e.g. MossTank/VTank output). The owner explicitly overrode both for acdream: unknown-command refusals and plugin text now call `IChatCommandFeedback.ShowSystemMessage` / `RuntimeCommunicationState.AddText(text, RetailLogTextType.Default)` respectively, landing in the chat scroll instead — matching how Decal's own `AddChatText` surfaced plugin output. Every OTHER `ClientLocal` refusal (real retail commands with bad arguments, AP-183; movement/interaction refusals) is UNCHANGED and still SpewBox-only. | `src/AcDream.Runtime/Chat/ChatCommandRouter.cs` (three call sites: the no-letter-verb guard, `EmitVerbHelp`'s confirmed-null-help branch, `EmitVerbHelp`'s unresolved-verb fallback); `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs` (`UnknownCommand`'s own remarks); `src/AcDream.App/Plugins/AppAutomationSurface.cs` (`PostSystemMessage`); `src/AcDream.Plugin.Abstractions/Automation.cs` (`IPluginChat.PostSystemMessage`) | Direct owner instruction 2026-09-07: "Unknown commands like /vt or stuff from plugins shall now go to the SpewBox. They should go to the chatbox." Resolves a real discoverability gap — an unknown command or plugin notice silently flashed in the barely-visible SpewBox overlay with no transcript record, easy to miss and impossible to scroll back to. | A future re-read of `DoHelp`'s decomp could "fix" this back to `ShowInterfaceText`/`ClientLocal` per retail-faithful defaults, silently re-hiding the feedback the owner asked to keep visible; a NEW producer of "Unknown command" text or plugin system text that bypasses these exact call sites (a future command-dispatch path, a second plugin chat sink) would still hide in the SpewBox unless routed through the same seam. | `ClientCommunicationSystem::DoHelp @0x0057F9E0` (unresolved-verb branch, retail's own `ClientLocal`/0x1A typing — the behavior being overridden); `docs/ISSUES.md` #363/#367 | | AD-123 | **Filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 9.** Retail's real loot-profile picker (`aa()`, `uTank2/PluginCore.cs:7127-7154`) seeds ONLY `[None]` — there is no per-character auto loot file and no "mine only" filter for loot at all (`docs/research/vtank-kb/01-settings-and-profiles.md` section 3: "the loot default has no equivalent auto-name; loot profiles default to none"). MossTank keeps its own `ByCharacter`/"By char" auto-profile convention for loot anyway, for internal consistency with the Settings/Nav/Meta stores (all three of which DO have a real retail auto-file). | `src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs` (`ByCharacter`, `CurrentFileName`); `src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs` (`ListLootProfiles`) | The auto file is a normal `.utl`, named the same `--Name_Server.utl` shape the other three auto-files use; a real VTank install never creates or reads this convention itself, so it is additive, not a collision with anything retail writes. | A user comparing acdream's loot picker to real VTank's own `cmbLootSet` sees an extra "By char" entry retail never shows, and (harmlessly) an extra `--Name_Server.utl` file in a shared real-VTank profile directory. | `aa()` (`uTank2/PluginCore.cs:7127-7154`). | | AD-122 | **Filed 2026-09-07 at Campaign VT slice 1 Part A round 3 item 7; naming updated 2026-09-07 at Campaign VT slice 1c.** `VtankProfileDirectory.WriteCharacterBinding` writes a real `.cdf`'s Nav/Meta lines (4-5) as MossTank's own `.af` names (metaf's human-readable grammar), not VTank's native binary `.nav`/`.met` — and, since slice 1c's two-folder layout (owner decision 2026-09-07: Meta and Nav profiles both use `.af` and are told apart by folder, not a file-name marker), those two lines are now the folder-relative real storage keys `metas/Name.af`/`navs/Name.af`, not a bare file name. When `ACDREAM_VTANK_PROFILE_DIR` points at a REAL installed VirindiTank profile folder for direct interop, the `.cdf` this store writes there names files a real VTank instance cannot load (wrong format, and — now — a subfolder path a real VTank's own flat-directory `.cdf` reader was never built to resolve). | `src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs` (`WriteCharacterBinding`, `MetaFolder`/`NavFolder`); `src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs`/`MossTankRouteProfileStore.cs` (`.af` naming, folder-qualified `CurrentFileName`) | `.usd` settings and `.utl` loot stay real/binary-compatible; only Nav/Meta went `.af`-only for slice 1 (see `docs/research/vtank-kb/06-navigation-and-nav.md`/`07-meta-and-expressions.md`). acdream itself only ever reads its own `.cdf` writes back, so this is self-consistent as long as the two clients never share one profile directory. | A user pointing `ACDREAM_VTANK_PROFILE_DIR` at their real VTank install and then opening that character in real VTank gets a Nav/Meta load failure (wrong format AND wrong path for the recorded filename) even though Settings/Loot still work. Additionally, MossTank's first load MOVES every flat `.af` in that directory into `metas/`/`navs/` (slice 1c migration; one-time, logged, no overwrite on collision). | `da.q()`/`da.e()` (`refs/vtank/decompiled/da.cs:105-164`) — real VTank's own `.cdf` read/write. | | AD-119 | **Filed 2026-09-03 at Campaign OVERHAUL v2 S4 chunk 1 (S4-c1 C2; `docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md` §6 R3).** Retail's portal-depth draws (`D3DPolyRender::DrawPortalPolyInternal` @0x0059bc90, the `BLEND_SRCALPHA`/`BLEND_INVSRCALPHA` `SetBlendFunction` call) keep color writes ENABLED with a zero-source-alpha `SRCALPHA`/`INVSRCALPHA` blend — every OTHER piece of R3's state (`DEPTHTEST_ALWAYS`, depth write on, `CULLMODE_NONE`, no stencil) is ported exactly. acdream instead disables the color-write mask outright on the SAME pipeline (`ColorWrite = false` alongside `Blend = GpuBlendMode.None`) and `portal_depth.frag`'s `main()` writes no color output at all — a write-mask substituting for a zero-alpha blend. | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs:92,100` (`CreatePortalPipeline`'s `Blend`/`ColorWrite` fields); `src/AcDream.App/Rendering/Shaders/portal_depth.frag` (empty `main()`, no color output) | Retail's blend equation is `dst' = src*srcAlpha + dst*(1-srcAlpha)`; with `srcAlpha` fixed at 0 this collapses to `dst' = dst` for every fragment regardless of its RGB — the destination color buffer is left byte-identical either way. A write mask reaches the SAME outcome (the destination is never touched) through a structurally simpler path — no blend-unit work per fragment, no fragment color output to author or keep in sync with a "must stay zero" alpha invariant — so the two are pixel-identical, not merely usually-equivalent. | None expected: the equivalence is provable from the blend algebra above, not measured, so no capture, transcript, or visual gate can distinguish the two. The write mask is in fact the SAFER of the two going forward — a future edit that gives `portal_depth.frag` a real color output (e.g. an authored debug tint) still writes nothing under today's mask, where a ported zero-alpha blend would depend on that same edit remembering to keep alpha at exactly 0. | `D3DPolyRender::DrawPortalPolyInternal` @0x0059bc90 (`SetBlendFunction(BLEND_SRCALPHA, BLEND_INVSRCALPHA, BLENDOP_ADD)`, `SetDepthBufferMode(DEPTHTEST_ALWAYS, ...)`, `SetCullMode(CULLMODE_NONE)`); `PortalDepthMaskRenderer.Rhi.cs` | diff --git a/docs/launch-options.md b/docs/launch-options.md index 2baa58b4..6faae7e4 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -40,12 +40,16 @@ Assume a flag has a side effect until its row says otherwise. - **Everything diagnostic is OFF by default.** Every probe, dump, capture, and measurement flag in this document is inert until its variable is explicitly set — an unset environment runs zero diagnostics. Exactly - five flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`, + six flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and `ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B off-switch (`=0` disables the behavior for a comparison run), while `ACDREAM_RETAIL_UI` is the product's only gameplay presentation and uses - the same explicit diagnostic opt-out. That five-flag set is frozen by + the same explicit diagnostic opt-out. `ACDREAM_HEADLESS_CONSOLE` is the + sixth: its unset default is terminal-shaped (on when stdin is a real + console, off when redirected — not unconditionally on like the other + five), but once the variable is SET AT ALL it uses the identical `=0` + override (any other value enables). That six-flag set is frozen by `LaunchOptionsDocumentationTests` — a new default-on flag fails the build. - `=1` means the code tests for exactly the string `1`. Setting `true`, @@ -93,6 +97,7 @@ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release | `ACDREAM_DAT_DIR` | `=` | Fallback dat-directory when no positional argument is given. App: single read at `Program.cs:58`. Cli: read independently per-subcommand (each subcommand does `args.ElementAtOrDefault(N) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")`) plus once more for the default (no-subcommand) asset-inventory mode at line 152. | Two of the four `Program.cs` line numbers in the raw grep (91, 135) are **not reads** — they're the literal string `ACDREAM_DAT_DIR` inside `Log.Error` usage-text messages, not `GetEnvironmentVariable` calls. Only line 58 is a real read in `AcDream.App`. | none — hard usage error (exit 2) if unset and no positional arg | `Program.cs:58` (App); `Cli/Program.cs:24,35,47,59,71,84,113,125,137,152` (every Cli subcommand) | | `ACDREAM_DISPLAY_PROTOCOL` | `="auto"` / `"x11"` / `"wayland"` (case-insensitive, trimmed); any other value throws `InvalidOperationException` at startup | Linux-only: forces the GLFW 3.4 platform-init hint (X11 vs Wayland vs auto) before any window is created; ignored entirely on Windows (always `Windows` protocol) | An invalid value is fatal at startup (throws before any window exists), not a silent fallback | unset → auto-detected from `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY`/`DISPLAY`, falling back to GLFW `Automatic` | `GraphicalWindowBackendSelection.Resolve` (`GraphicalWindowBackendSelection.cs:26-58`) | | `ACDREAM_FAR_RADIUS` | `=` | Overrides preset's `FarRadius` (outer streaming/reveal window, landblocks) | Enlarging changes streaming memory budget and what's resident/rendered — CLAUDE.md: leave unset for measurement/gate runs (same family as legacy `ACDREAM_STREAM_RADIUS`) | preset's `FarRadius` (Low=5, Medium=8, High=12, Ultra=15) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:47`) | +| `ACDREAM_HEADLESS_CONSOLE` | `=0` disables (once set at all); any other value enables; unset falls through to the terminal-shaped default | Turns on the headless host's interactive console (docs/plans/2026-09-07-headless-console.md): a background thread reads stdin lines, each drained on the session tick through the SAME plugin-verb/client-slash-command pipeline the graphical chat box uses, with chat/lifecycle/portal output rendered to stdout. Only takes effect for `run` with a single configured session — a multi-session process reports `console: single-session only` via the diagnostics stream and does not attach one. | Starts a background stdin-reader thread and writes plain-text lines to the same stdout stream `HeadlessDiagnosticWriter` already uses for its JSON lines — the two interleave. Only applies to `run`; `--console` (bare flag, no value) always wins over this variable. S1 fix (2026-09-07): the variable itself now wins outright once SET AT ALL — `=0` disables even when stdin is a real terminal, matching every other `=0`-disables flag in this table; only an UNSET variable falls through to the terminal-shaped default. | unset → on when stdin is a real console, off when redirected (`!Console.IsInputRedirected`, checked once in `Program.cs`); set → `!= "0"` | `HeadlessConsoleOptions.Resolve` (`Configuration/HeadlessConsoleOptions.cs`) → `HeadlessEntryPoint.Run` → `HeadlessProcessHost`'s `consoleEnabled` | | `ACDREAM_LIVE` | `=1` (exactly the literal string `"1"`) | Core switch: connect to a live ACE server instead of running offline/no-connect. | The 4 non-`RuntimeOptions.cs` line numbers in the raw grep are **all comments or log-message text**, not reads — `SessionStartComposition.cs:39` is inside the string `"live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"`; `Program.cs:126` is inside a `--session-config` override log line; `GameWindow.cs:614,627` are doc comments. The only actual parse is `RuntimeOptions.cs:141`. Requires `ACDREAM_TEST_USER`/`ACDREAM_TEST_PASS` too (`HasLiveCredentials`) or the session silently reports `MissingCredentials` and skips. Forced to effectively-on (LiveMode=true) unconditionally by `--session-config` launches regardless of this var. | `false` | `RuntimeOptions.LiveMode` → `SessionStartComposition.cs` (log text only), `Program.cs:126` (log text only), `GameWindow.cs:614,627` (comments only), consumed for real via `RuntimeOptions.HasLiveCredentials` and `WorldSession`/`GameRuntime` session-start gating | | `ACDREAM_MAX_COMPLETIONS_PER_FRAME` | `=` | Overrides preset's per-frame streaming-completion throughput cap | Directly changes the streaming admission budget measured by perf/completion gates — do not vary during a measurement run | preset's value (Low=2, Medium=3, High=4, Ultra=6) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:59`) | | `ACDREAM_MSAA_SAMPLES` | `=` (0/2/4/8) | Overrides preset's MSAA sample count | Changes GPU multisample anti-aliasing (visual + GPU-cost change) | preset's `MsaaSamples` (Low=0, Medium=2, High/Ultra=4) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:48`) | @@ -143,6 +148,7 @@ config without connecting; `run` connects. | `--config ` | The versioned headless session-configuration document. Required. | — | | `--config-dir` / `--data-dir` / `--cache-dir` `` | Override each portable path root. | Merged over the config document's own `process.paths`; the command line wins. | | `-user` / `--user`, `-password` / `--password` | Direct single-session credentials, bypassing the config's credential source. | Plaintext in the process command line — prefer the config's credential reference. | +| `--console` | Forces the interactive console on for `run` (bare flag, no value) — see `ACDREAM_HEADLESS_CONSOLE`. | Same side effects as the environment variable; this flag always wins over it. | | `--help` / `-h` (or no args) | Prints usage, exits 0. | — | ### `AcDream.Launcher` diff --git a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md index 938c1866..3ee7d16a 100644 --- a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md +++ b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md @@ -131,3 +131,6 @@ re-review, merge to the campaign branch, then the owner's visual gate. - 2026-09-07 09:10 S7.3 Monsters landed on the panel worktree (`57ced0aff`, `c3b4f7862`; MossTank suite 645 → 651) — same commits as the entry above; recorded again here because the plain-menu-style branch (`cfa703065`) merged into the panel worktree afterward to pick up fix (b) before fix round A started. Fix round A (grid scaling, Profiles leftovers, 260-tall window, Advanced Options / Loot Editor as their own panels, blank trailing slots, fresh screenshots) dispatched on the same worktree after merging the plain-menu style in. S7.4–S7.6 follow. - 2026-09-07 fix round A landed on the panel worktree, four commits: `045cd0a19` (merge `claude/latest-main-sync-497549`, bringing the plain-``-style fix (b) in — resolved the ledger/markup/test conflicts by keeping both sides' content), `565a33d78` (grid scaling + Profiles cleanup + popups split into their own panels), `e414b2f56` (AcDream.App.csproj's CopyMossTankPlugin* targets hardcoded mosstank.xml as the only file to copy into `plugins/AcDream.Plugins.MossTank/` — the two new popup markup files silently landed in the App's own bin root instead and would have thrown `FileNotFoundException` on load; caught before any screenshot by inspecting the build output layout, not by a test), `78b42a519` (StartVisible=true fix for both popups — `StartVisible=false` left `PluginWindowVisibilityController`'s "requested visible" axis permanently false with no shelf entry to ever call `OnShown()`, so neither popup ever rendered despite a checked/green checkbox; plus dropped each popup's now-redundant in-content title label, and repositioned both away from the overlapping (440,60) placeholder). Real DAT-font measurements (`AcDream.Cli dump-font-atlas` against the installed DAT: font 0x40000000 MaxCharHeight=16, matching VVS's own assumed row height exactly) replaced the "sy row-pitch" theory in the 07:55 lead's read — the actual fix is a translation of the columns after each overflowing caption (Options +62px, Profiles +16px), not a font-driven vertical scale. Fresh screenshots recaptured end-to-end against a live local ACE with an isolated `ACDREAM_CONFIG_DIR` (stale persisted popup window positions from earlier probe runs would otherwise have overridden the new authored defaults forever — `RetailWindowLayoutPersistence` has no revision bump wired for plugin windows). All six requested screenshots (Options/Profiles/Vitals/Monsters/both popups) confirm: no overlap, no gold buttons, no stacked New/Loot-engine/path-string leftovers, both popups open as genuinely separate windows with clean titles, and the Route/Meta/Loot-editor move-up/move-down slots render real DAT icons instead of blank buttons. MossTank suite 651 → 654 (three new pins: the two-file `SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves` theory cases + `NoButtonAnywhereUsesTheUnrenderableArrowGlyphs`); App markup/plugin/menu filter holds 237/237. Deviation carried forward: Macro/Nav CopyTo lost their only in-UI target-name entry (the deleted 3-row block was their sole source; Meta already has one on its own tab) — matches VTank's own Profiles table having no name-draft control at all, but is a real, accepted capability regression pending a future naming-UX slice. Owner's connected visual gate is the next step. - 2026-09-07 S7.4–S7.6 implemented on the panel worktree (base `66b070def`), three commits: `f5409530f` (S7.4 — Items' 2-column name/hands grid, Consumables' "Excluded Scarab Types" icon+text grid and "Add Selected" button, Buffs' Extra Buff Spells / Blacklisted Buff Families lists plus a shared `mosstank-buffpicker.xml` SelfBuffChoiceView-style picker popup registered the same way fix round A's two popups are), `6118062a7` (S7.5 — Route's clWP/clWPc 2-column waypoint grid, the "Follow" nav-mode display remap, `scroll="true"` on the recall menu, and a third nav image button for "Select Nearest Point"), `cc323f6a5` (S7.6 — Meta's 6-column lstMetaRules grid: delete/move-up/move-down cells plus State/Condition/Action text cells opening the existing rule editor). Deviations documented at their own binding site: Items' Hands column is session-local only (no backing wieldable-handedness data anywhere in the plugin surface); Consumables' "Add Selected" accepts any selected owned item rather than requiring VTank's own SpellComponent object-class check (no classifier surface exists for plugins); ExtraBuffSpellNames/BlacklistedBuffFamilyNames (BuffPlan.cs) add storage + UI only, not wired into `BuffPlan.Build`'s cast selection (real casting-algorithm behavior, owned by a future Campaign VT behavior slice); Route's recall menu keeps its real 4 kinds rather than VTank's 27 named recalls (needs real per-recall spell-id data); Route's "Select Nearest Point" moves the tab's own edit selection rather than VTank's live navigation cursor (no mutable cursor exposed to a plugin); Meta's delete cell is a text "X" rather than an icon (no retail DAT delete-glyph id confirmed anywhere in this codebase, unlike the established move-up/move-down `0x060028FC`/`0x060028FD` pair). Every new/changed pin (contract control count 167→177→180→186, the new `mosstank-buffpicker.xml` popup pin, six new `MossTankPanelTests` interaction tests) was shown to fail against a targeted mutation before being confirmed green. MossTank suite 654 → 660; App markup/plugin filter holds 192/192; full solution builds clean in Release. Fresh live screenshots recaptured against the same local ACE recipe fix round A established (isolated `ACDREAM_CONFIG_DIR`/`ACDREAM_DATA_DIR`, an `ACDREAM_UI_PROBE_SCRIPT` route through the five changed tabs plus the new buff picker popup) → `docs/research/2026-09-07-slice7-screenshots/` (`tab-items.png`, `tab-consumables.png`, `tab-buffs.png`, `tab-route.png`, `tab-meta.png` recaptured at 900×300; `popup-buffpicker.png` added at 940×715). All six confirm: plain (non-gold) controls throughout, no overlapping captions, the two new Consumables/Buffs grids and the buff picker popup render correctly, and the Route/Meta move icons render real DAT art. No crashes or ungraceful exits across the probe runs. S7.7 (the gate script) and the owner's connected visual gate remain. +- 2026-09-07 09:10 S7.3 Monsters landed on the panel worktree (`57ced0aff`, `c3b4f7862`; MossTank suite 645 → 651): the 23-column grid with VTank's exact cycle lists (P −1…4; Dmg type 14 values; Ex. Vuln 9; PetDmg 10; name click deletes; arrows reorder with DEFAULT pinned). Implementer deviations for the review: Weapon/Offhand cycle MossTank's registered item roster instead of VTank's opaque weapon-type ids (MossTank models concrete owned items); the move-up/down DEFAULT guard is symmetric. Fix round A (grid scaling, Profiles leftovers, 260-tall window, Advanced Options / Loot Editor as their own panels, blank trailing slots, fresh screenshots) dispatched on the same worktree after merging the plain-menu style in. S7.4–S7.6 follow. +- 2026-09-07 10:10 fix round A landed on the panel worktree (`045cd0a19` merge of the plain menu, `565a33d78` column shifts + Profiles cleanup + 236-tall window + popup panel files, `e414b2f56` csproj plugin-copy fix, `78b42a519` popups actually render (`StartVisible` gotcha) + fresh screenshots, `66b070def` ledger; MossTank suite 651 → 654). Owner's two complaints verified fixed on the new screenshots. Deviation for the review: Macro/Nav CopyTo lost their in-UI target-name field with the deleted block (VTank has none either). S7.4–S7.6 dispatched on the same worktree. +- 2026-09-07 10:20 owner, live: "Drop down menus look horrible, there is also a checkmark on the text there." — the OPEN popup still draws retail art (tan gradient panel, ornate gold scrollbar, checkmark on the selected row). Plain open state (dark list rows, selected fill, plain scrollbar, no checkmark) dispatched on the plain-menu worktree; merges to the campaign branch, then into the panel worktree at fix round B. diff --git a/docs/plans/2026-09-07-headless-console.md b/docs/plans/2026-09-07-headless-console.md index 04aece66..40f919e8 100644 --- a/docs/plans/2026-09-07-headless-console.md +++ b/docs/plans/2026-09-07-headless-console.md @@ -1,7 +1,7 @@ # Headless console — an interactive CLI for the bot host Date: 2026-09-07 -Status: ACTIVE (owner direction 2026-09-07: "the headless client should have +Status: CLOSED 2026-09-07 — merged `8cb284d6f`, connected proof passed (owner direction 2026-09-07: "the headless client should have a CLI as well. Like we have the chat loaded in headless so we can see what it does and we can talk via it if we want and control plugins like /moss bla or /say hello") @@ -66,3 +66,159 @@ it or the lead may, it is not a visual gate. ## Ledger - 2026-09-07 planned; implementer dispatched. +- 2026-09-07 IMPLEMENTED. The dispatch seam already existed: + `AcDream.Runtime.Chat.ChatCommandRouter.Submit` is the SAME presentation- + free pipeline `LoginCommandSequence` (headless) and every graphical chat + window (`ChatWindowController`, `FloatingChatWindowController`, + `RetailUiRuntime`) already call — no lift was needed. Added + `HeadlessSessionHost.SubmitConsoleLine` (`Hosting/HeadlessSessionHost.cs`) + as the one new call site, reusing the host's own retained + `LiveChatCommandSurface`/plugin registry (now promoted from ctor locals to + fields) instead of a second parser. + New files: `Configuration/HeadlessConsoleOptions.cs` (typed `--console` / + `ACDREAM_HEADLESS_CONSOLE=1` / terminal-default resolution), + `Hosting/HeadlessConsoleInputReader.cs` (background stdin thread → FIFO + queue, never executes handler code), `Hosting/HeadlessConsoleController.cs` + (drains the queue on the session tick via a new `HeadlessSessionHost. + ConsolePump` hook; owns `/quit`/`/status`), `Hosting/ + HeadlessConsoleChatFormatter.cs` + `Hosting/HeadlessConsoleRenderer.cs` + (renders the K2 bot event stream — `IRuntimeEventObserver`, the same + interface a bot policy subscribes — as bracket-labelled lines: + `[Tell] Bob: hi`, `[Fellowship] …`, `[Local] …`), `Hosting/ + HeadlessConsoleChatFeedback.cs` (decorates `RuntimeChatCommandFeedback` so + retail's transient SpewBox/`ClientLocal` interface text — which never + touches `ChatLog`, so it never reaches the K2 event stream — also reaches + the console). `/quit` cancels a `CancellationTokenSource` linked into the + scheduler's run token in `HeadlessProcessHost` (the SAME graceful-exit + path an external Ctrl+C/SIGTERM already takes); `/status` reports + generation, position (or "unknown" without a live movement controller), + and loaded-plugin count (no plugin today reports a richer macro-state + string). Console only attaches for a single-session `run` (per the plan's + "out of scope for the first cut" multi-session note); constructed AFTER + every session's own credential resolution so the reader thread never + races a `StandardInput`-provider password prompt on the same stream. + Chosen console default: on when `!Console.IsInputRedirected` (a real + operator at a terminal), off when redirected (scripts/CI/piped fixtures, + where a blocked `ReadLine` on a background thread would just sit idle) — + resolved once in `Program.cs`, the only place that can see the real + `Console`. + Deviation from the plan's illustrative example: retail's own transcript + never prefixes Tell/Local lines with a bracket (`ChatVM.FormatEntry` + renders "Bob tells you, ..."/"Bob says, ..." with no label) — Headless + cannot reference `AcDream.UI.Abstractions` (the dependency-boundary + test), so `HeadlessConsoleChatFormatter` is a deliberately DIFFERENT, + terminal-shaped "[Label] Sender: text" rendering using the SAME channel- + name strings (matching the plan's literal `[Tell] Bob: hi` example), not + a byte-for-byte port of the graphical prose. + Tests: `tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs` (20 new + tests — options resolution, command-line flag parsing, reader-thread + ordering/never-on-reader-thread, controller drain/quit/status, chat + formatting, and full `/say`/plain-text/plugin-verb/unknown-verb dispatch + against a real `HeadlessSessionHost` + `FixtureSessionOperations`, no live + server) plus the existing `LaunchOptionsDocumentationTests` (4/4 green) + and `HeadlessDependencyBoundaryTests` (3/3 green, unchanged — Headless + still references only `AcDream.Runtime`). Every test in this batch was + mutation-checked to fail before the corresponding production line existed + (see the implementer's final report for the specific mutations run: + skipping the interface-text callback, skipping `_quitRequested.Cancel()`, + forcing `TryHandlePluginCommand` to always return false, dropping the + reader thread's `Enqueue`, and swapping `ChatChannelKind.Say` for `.Tell` + in `SubmitConsoleLine`). + Suites: `dotnet test tests/AcDream.Headless.Tests -c Release` → 193 + passed / 1 pre-existing failure (`LinuxRejectsGroupOrOtherCredentialPermissions`, + a Linux-only lane test that cannot run on this Windows host — unrelated + to this change) / 194 total. `dotnet test tests/AcDream.Runtime.Tests -c + Release` → 1891/1891 passed. `dotnet test tests/AcDream.App.Tests -c + Release --filter "FullyQualifiedName~Chat|FullyQualifiedName~Command| + FullyQualifiedName~LaunchOptions"` → 414 passed / 2 pre-existing failures + (`ChatIndicatorButtonLiveMountProbeTests`/`OptionsPanelLiveMountProbeTests` + — both gated on `ACDREAM_PROBE_LIVE_MOUNT=1`, a manual live-DAT probe lane, + unrelated to this change) / 3 skipped / 419 total. `dotnet build + AcDream.slnx -c Release` green throughout. + +- 2026-09-07 FIX ROUND (Opus review, APPROVE-WITH-FIXES). S1: `ACDREAM_ + HEADLESS_CONSOLE=0` now disables the console even when stdin is a real + terminal — the prior `== "1"` test let `"0"` silently fall through to the + terminal-shaped default; the flag is now the sixth entry in + `LaunchOptionsDocumentationTests.DefaultOnBehaviorFlags` (a default-on + behavior with an A/B off-switch, once set at all, like + `ACDREAM_RETAIL_CHASE`). S2: the reader-thread pin is now falsifiable — a + fixture `TextReader` records the actual thread id `ReadLine` ran on, and a + new test asserts the controller's submit callback runs on neither that + thread nor any other unexpected one, only the `DrainDue` caller's. S3: one + `HeadlessProcessHost` end-to-end test proves a console line reaches the + session's real `SubmitConsoleLine` pipeline and `/quit` returns + `HeadlessExitCode.Success`. S4: `HeadlessConsoleController.Handle` now + wraps `_submit` in try/catch (mirroring `LoginCommandSequence.DrainDue`) + and prints a line for `UnknownCommand`/`Dropped`, so a console typo can + never escape into the scheduler's per-session quarantine catch. S5: + deleted the per-call `HeadlessConsoleChatFeedback` decorator — it only + ever saw text produced by the console's OWN `SubmitConsoleLine` calls. + The new `HeadlessConsoleSpewBoxPump` polls the shared `SpewBoxState` on + the console's own per-tick pump instead, the SAME seam the graphical + overlay's `SpewBoxController.Tick` reads, so server- and plugin-driven + `ClientLocal` interface text prints too. S6: `Program.cs` now resolves + `standardOutputIsTerminal` next to the stdin probe and threads it through + `HeadlessEntryPoint.Run` → `HeadlessProcessHost`, which no longer reads + `System.Console.IsOutputRedirected` itself. S7: a multi-session process + launched with `--console` now reports `_diagnostics.Message("console", + "single-session only")` instead of silently skipping console attachment. + N1: corrected two stale dispatch-order doc comments + (`HeadlessSessionHost.SubmitConsoleLine`, `HeadlessConsoleController`'s + class remarks) to the real `ChatCommandRouter.Submit` order: retail's + client-command catalog, local `/help`, plugin verbs, the unregistered- + channel-tag fallback, an explicit server command, then plain chat. N2: + `HeadlessCommandLine.Console` renamed to `ConsoleEnabled`. N3: `validate` + mode now rejects `--console` outright rather than silently ignoring it. + N4: **`/status` and `/quit` are console-intercepted verbs — they never + reach `ChatCommandRouter`, unlike `@status`, which is a real server + command and still passes through untouched.** N5: `HeadlessConsoleRenderer` + now dims only lifecycle/command/portal lines; chat and interface text + print at the terminal's default weight. + Every new/changed test was shown to fail first against a targeted + mutation of the corresponding production code (see each commit's own + body for the specific mutation) before the fix landed; one commit per + item, all with `Co-Authored-By: Claude Fable 5.1`. + Suites (Release): `dotnet test tests/AcDream.Headless.Tests` → 207 + passed / 1 pre-existing Linux-lane failure + (`LinuxRejectsGroupOrOtherCredentialPermissions`) / 208 total (up from + 193/1/194 before this round — 14 new/changed tests). `dotnet test + tests/AcDream.App.Tests --filter "FullyQualifiedName~LaunchOptions"` → + 4/4 passed, including the corrected `OnlyTheSixProductBehaviorFlagsDefaultOn` + (renamed from Five). `dotnet build AcDream.slnx -c Release` green + throughout. + +### Connected proof recipe (owner runs; NOT run by the implementer) + +Against a running local ACE at `127.0.0.1:9000` with MossTank loaded for +the second half: + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +dotnet run --project src\AcDream.Headless\AcDream.Headless.csproj --no-build -c Release -- ` + run --config ` + -user testaccount -password testpassword --console +``` + +The referenced config's one session should target character `+Acdream` +(server guid `0x5000000A`) against `127.0.0.1:9000`, an `idle` bot policy, +and (for the second half) the MossTank plugin id under `plugins`. Once the +console prints `entered world`: + +1. Type `/say hello` and press Enter — expect the SAME line ACE echoes back + to any other observer (a retail client or a second acdream session + watching `+Acdream`) to also print `[Local] You: hello` in this console + (the server's own HearSpeech echo, rendered through the normal chat + event stream). +2. Type `/status` — expect a line with `generation=`, `position=` (a real + cell/local-frame triple once in world), and `plugins=N loaded`. +3. With MossTank loaded, type `/vt start` (or whatever verb MossTank + registers) — expect MossTank's own handler to run (check its own + status/log output) and confirm NOTHING was sent to the wire for that + line (no `@vt` server command). +4. Type `/quit` — expect a graceful ACE logout (same as the existing + Ctrl+C behavior) and the process to exit 0. + +This is not a visual gate; the owner (or the lead) runs it opportunistically +before considering the plan CLOSED. +- 2026-09-07 narrow re-check: all twelve fix items CLOSED; MERGE-READY. Merged into the campaign branch at `8cb284d6f`; the unknown-verb pin re-targeted to the chat scroll after AD-124 (`074a1561b`). **Connected proof PASSED (lead, 2026-09-07):** `acdream-headless run --config --console` with scripted stdin — `/say hello` → the server's echo printed as `[Local] You: hello`; `/status` → `generation=1 position=unknown plugins=0 loaded` (idle policy has no movement controller); `/quit` → `[session] graceful logout confirmed`, exit 0. The MossTank half (`/vt start`) is owed with slice 2's autostart work. Follow-ups filed as #489 (SpewBox growth without a console; polish; and the JSON diagnostics stream interleaving with chat lines in console mode — the console should quiet or redirect it). Status: CLOSED. diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index 4fff0e82..160fb201 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -117,7 +117,18 @@ list boxes (owner live-client report, 2026-09-07), so a plugin `` now draws the flat VTank/Decal `HudCombo` box (list-matching fill/border, a left-aligned value, and a small ▾) by default; `style="retail"` opts back into the gold face for a panel that genuinely wants it. Any other value -throws `FormatException` at `Build`. +throws `FormatException` at `Build`. The plain style covers the WHOLE menu, +closed and open: a follow-up owner report (still 2026-09-07 — "Drop down +menus look horrible, there is also a checkmark on the text there") found the +OPEN popup still drew retail's tan/orange gradient panel, its ornate gold +scrollbar, and a baked checkmark glyph on the current entry even with +`style="plain"`. The open popup now matches ``'s own chrome too: a +flat fill + 1px border, one row per entry in the list text color, the +current entry filled like a list selection, the hovered entry a slightly +lighter fill, and no checkmark; more entries than the row cap show a plain +1px-bordered scrollbar track with a flat thumb, no DAT scrollbar art. +`style="retail"` keeps the sprite popup (gradient panel, checkmark-bearing +row art, ornate scrollbar) exactly as before, unchanged. Common to every element via `ApplyCommon`: `name`/`id` (a stable control name), `visible` (literal `true`/`false` or a bound `bool` property), diff --git a/src/AcDream.App/Plugins/AppAutomationSurface.cs b/src/AcDream.App/Plugins/AppAutomationSurface.cs index a4f87d0b..9ae412f7 100644 --- a/src/AcDream.App/Plugins/AppAutomationSurface.cs +++ b/src/AcDream.App/Plugins/AppAutomationSurface.cs @@ -1170,9 +1170,12 @@ internal sealed class AppAutomationSurface } /// - /// Routed to retail's ClientLocal log type (0x1A) — the channel the client - /// uses for its own notices. Nothing reaches the server, so a plugin cannot - /// accidentally speak in the player's name. + /// Owner direction 2026-09-07 (register row AD-124): plugin-originated + /// text now lands in the chat window (retail Default/0x00), + /// matching Decal's own AddChatText behavior — not retail's + /// ClientLocal (0x1A) SpewBox-only channel this previously used. + /// Nothing reaches the server, so a plugin cannot accidentally speak in + /// the player's name. /// public void PostSystemMessage(string text) { @@ -1181,7 +1184,7 @@ internal sealed class AppAutomationSurface RuntimeCommunicationState? communication; lock (_gate) communication = _communication; - communication?.AddText(text, RetailLogTextType.ClientLocal); + communication?.AddText(text, RetailLogTextType.Default); } public bool Submit(string text) diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 810886a0..95a03f66 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -160,6 +160,22 @@ public sealed class UiMenu : UiElement private bool _draggingPopupThumb; private float _popupThumbDragOffset; + /// Index into of the row under the pointer while + /// the plain popup is open, or -1. Presentation-only (see + /// 's doc) — retail's sprite popup has no + /// equivalent hover concept, so this never affects the retail draw path. + private int _hoveredPopupIndex = -1; + + /// Test seam, same rationale as . + internal int HoveredPopupIndexForTest => _hoveredPopupIndex; + + /// + /// The plain popup needs continuous MouseMove while open to keep its hover + /// highlight tracking the cursor (retail's sprite popup has no such state, so + /// this only matters when is false). + /// + public override bool ReceivesHoverMouseMove => _open && !RetailButtonArt; + private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px) // The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px // square; the label starts just past it (box width + small gap) so text aligns with @@ -339,6 +355,28 @@ public sealed class UiMenu : UiElement /// with the list rows beneath it. public const float PlainPadding = 3f; + // ── Plain OPEN-popup chrome (RetailButtonArt = false). Owner live-client + // report 2026-09-07 ("Drop down menus look horrible, there is also a + // checkmark on the text there"): the S7 fix above only replaced the + // CLOSED-state button face — opening the dropdown still drew retail's + // tan/orange gradient panel (PopupBgSprite), the row-highlight sprites + // (whose art bakes a checkbox/checkmark glyph into the leftmost ~17px — + // see TextIndent's doc comment), and the ornate scrollbar chrome. VTank's + // own open combo (VVS HudCombo, docs/research/vtank-kb/08-ui-views.md §2) + // is a plain dark list — no gradient, no baked checkmark — so the plain + // popup below reuses UiMarkupList's own list palette (same rationale as + // PlainBackgroundColor/PlainBorderColor above) rather than inventing a + // third color scheme. + /// The current entry's row fill — identical value to + /// so a plugin's open dropdown + /// reads as the same widget family as its lists. + public Vector4 PlainSelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f); + /// A slightly lighter fill for the row under the pointer (no + /// separate glyph or sprite swap — fills only, mirroring + /// 's "tint, never a sprite swap" rule + /// for the closed state). + public Vector4 PlainHoverColor { get; set; } = new(0.40f, 0.33f, 0.14f, 0.95f); + private bool _open; /// @@ -377,6 +415,7 @@ public sealed class UiMenu : UiElement OnOpen?.Invoke(); } _open = value; + _hoveredPopupIndex = -1; // stale hover from the last time this popup was open if (FindRoot() is not { } root) return; if (value) root.SetActivePopup(this, () => SetOpen(false)); else root.ClearActivePopup(this); @@ -617,8 +656,29 @@ public sealed class UiMenu : UiElement /// pass) greys out the part of the popup that overlaps it. protected override void OnDrawOverlay(UiRenderContext ctx) { + if (!_open) return; + + // Owner live-client report 2026-09-07: the S7 closed-state fix left the + // OPEN popup drawing retail's gradient/checkmark art regardless of + // RetailButtonArt. Plain mode needs no SpriteResolve at all — it draws + // only untextured fills/outlines (see DrawGridPopupPlain/ + // DrawScrollablePopupPlain's own doc comments). + if (!RetailButtonArt) + { + ctx.PushAlphaAbsolute(1f); + try + { + if (Scrollable) + DrawScrollablePopupPlain(ctx); + else + DrawGridPopupPlain(ctx); + } + finally { ctx.PopAlpha(); } + return; + } + var resolve = SpriteResolve; - if (!_open || resolve is null) return; + if (resolve is null) return; // Force OPAQUE (a menu reads solid even though the chat window is translucent). // Draw bevel → panel fill → row sprites → labels, all through the sprite bucket @@ -772,6 +832,152 @@ public sealed class UiMenu : UiElement } } + // ── Plain OPEN-popup drawing (RetailButtonArt = false) ────────────────── + // + // Owner live-client report 2026-09-07: no DAT art at all — a flat fill + // background, a 1px border, one row per entry in the list text color, the + // current entry filled like a list selection, the hovered entry a slightly + // lighter fill, and NO checkmark (retail's row-highlight sprites bake a + // checkbox/checkmark glyph into their leftmost ~17px — see TextIndent's + // doc comment — which a flat DrawFill simply cannot draw, so plain mode + // has none by construction). These mirror DrawGridPopup/DrawScrollablePopup's + // shape exactly (same column/row math, same VisibleTopRow/EnabledProvider + // rules) so hit-testing (OnHitTest/OnEvent, unchanged) stays byte-identical + // to what it already computes for the retail path. + + /// Plain counterpart of — flat fill + + /// 1px outline instead of the bevel/panel sprites, per-row selected/hover + /// fills instead of highlight sprites, / + /// labels left-aligned at + /// instead of the authored / + /// justification (plain mode has no baked + /// checkbox glyph to align past, and no authored per-menu justification + /// convention — VTank's own list rows are always left-aligned). + private void DrawGridPopupPlain(UiRenderContext ctx) + { + float outerTop = PopupTop; + float inX = Border, inY = outerTop + Border; + + ctx.DrawFill(0f, outerTop, OuterW, OuterH, PlainBackgroundColor); + ctx.DrawRectOutline(0f, outerTop, OuterW, OuterH, PlainBorderColor, 1f); + + for (int i = 0; i < Items.Count; i++) + { + int col = i / RowsPerColumn, row = i % RowsPerColumn; + float x = inX + col * ColumnWidth, y = inY + row * RowHeight; + bool selected = Equals(Items[i].Payload, Selected); + if (selected) + ctx.DrawFill(x, y, ColumnWidth, RowHeight, PlainSelectedColor); + else if (i == _hoveredPopupIndex) + ctx.DrawFill(x, y, ColumnWidth, RowHeight, PlainHoverColor); + } + + float textY = (RowHeight - LineH()) * 0.5f; + for (int i = 0; i < Items.Count; i++) + { + int col = i / RowsPerColumn, row = i % RowsPerColumn; + bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true; + DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + PlainPadding, + inY + row * RowHeight + textY, + avail ? PlainTextColor : TextColorGhosted); + } + } + + /// Plain counterpart of — same + /// -sliced single column, plain + /// selected/hover row fills, and a plain scrollbar + /// () instead of the sprite chrome. + private void DrawScrollablePopupPlain(UiRenderContext ctx) + { + ConfigurePopupScroll(); + + float outerTop = PopupTop; + float inX = Border, inY = outerTop + Border; + + ctx.DrawFill(0f, outerTop, OuterW, OuterH, PlainBackgroundColor); + ctx.DrawRectOutline(0f, outerTop, OuterW, OuterH, PlainBorderColor, 1f); + + int start = VisibleTopRow; + int count = System.Math.Min(EffectiveVisibleRows, Items.Count - start); + float textY = (RowHeight - LineH()) * 0.5f; + for (int i = 0; i < count; i++) + { + int idx = start + i; + float y = inY + i * RowHeight; + bool selected = Equals(Items[idx].Payload, Selected); + if (selected) + ctx.DrawFill(inX, y, ColumnWidth, RowHeight, PlainSelectedColor); + else if (idx == _hoveredPopupIndex) + ctx.DrawFill(inX, y, ColumnWidth, RowHeight, PlainHoverColor); + } + for (int i = 0; i < count; i++) + { + int idx = start + i; + bool avail = EnabledProvider?.Invoke(Items[idx].Payload) ?? true; + DrawLabel(ctx, Items[idx].Label, inX + PlainPadding, inY + i * RowHeight + textY, + avail ? PlainTextColor : TextColorGhosted); + } + + DrawPopupScrollbarPlain(ctx, inX + ColumnWidth, inY); + } + + /// + /// Plain counterpart of : a 1px-bordered + /// track and a flat thumb, both in — no DAT + /// thumb/track/arrow-button art at all. Shares the exact same + /// geometry (so the thumb's drawn + /// position matches 's hit-test + /// math), but draws no separate up/down button glyphs — plain mode has no + /// art for them and the click regions already work through geometry alone + /// ( is unchanged). + /// + private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y) + { + if (!IsPopupScrollbarPresentationVisible) return; + + ctx.DrawFill(x, y, ScrollbarWidth, InteriorH, PlainBackgroundColor); + ctx.DrawRectOutline(x, y, ScrollbarWidth, InteriorH, PlainBorderColor, 1f); + + if (!PopupScroll.HasOverflow) return; + + float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH); + float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent); + float trackTop = decExtent; + float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent); + var (ty, th) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen); + ctx.DrawFill(x + 1f, y + ty, MathF.Max(0f, ScrollbarWidth - 2f), th, PlainBorderColor); + } + + /// + /// Recomputes the hovered popup row from a MouseMove's local (lx,ly) — + /// same convention 's MouseDown handling already uses + /// (/-relative). Plain-mode-only: + /// see 's doc comment for why this is + /// never invoked on the retail sprite-popup path. + /// + private void UpdatePlainPopupHover(float lx, float ly) + { + float ix = lx - Border, iy = ly - (PopupTop + Border); + _hoveredPopupIndex = Scrollable ? HoveredScrollableIndex(ix, iy) : HoveredGridIndex(ix, iy); + } + + private int HoveredGridIndex(float ix, float iy) + { + if (ix < 0 || ix >= InteriorW || iy < 0 || iy >= InteriorH) return -1; + int col = (int)(ix / ColumnWidth); + int row = (int)(iy / RowHeight); + int idx = col * RowsPerColumn + row; + return row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count ? idx : -1; + } + + private int HoveredScrollableIndex(float ix, float iy) + { + if (ix < 0 || ix >= ColumnWidth || iy < 0 || iy >= InteriorH) return -1; + int row = (int)(iy / RowHeight); + int idx = VisibleTopRow + row; + return row >= 0 && row < EffectiveVisibleRows && idx >= 0 && idx < Items.Count ? idx : -1; + } + /// Draw the universal 8-piece retail window bevel (corners + tiled edges + /// tiled centre fill) framing the rect (,, /// ,). Reuses the same geometry + @@ -846,11 +1052,25 @@ public sealed class UiMenu : UiElement } } + // Plain-mode hover tracking (see ReceivesHoverMouseMove's doc comment): + // continuous MouseMove while the plain popup is open recomputes the + // hovered row for DrawGridPopupPlain/DrawScrollablePopupPlain. Checked + // BEFORE the MouseUp/HoverLeave/MouseDown-only gates below since, like + // the Scrollable drag block above, it spans an event type none of them + // handle. + if (!RetailButtonArt && _open && e.Type == UiEventType.MouseMove) + { + UpdatePlainPopupHover(e.Data1, e.Data2); + return true; + } + if (e.Type is UiEventType.MouseUp or UiEventType.HoverLeave or UiEventType.CaptureChanged) { _facePressed = false; // the momentary face flick ends here + if (e.Type == UiEventType.HoverLeave) + _hoveredPopupIndex = -1; return false; } diff --git a/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs b/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs index 38009512..73de5088 100644 --- a/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs +++ b/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs @@ -4,7 +4,8 @@ internal sealed record HeadlessCommandLine( string Command, string ConfigurationPath, HeadlessPathOverrides Paths, - HeadlessDirectCredentials? DirectCredentials) + HeadlessDirectCredentials? DirectCredentials, + bool ConsoleEnabled = false) { internal static HeadlessCommandLine Parse( IReadOnlyList arguments) @@ -23,15 +24,26 @@ internal sealed record HeadlessCommandLine( string? cacheDirectory = null; string? user = null; string? password = null; - for (int index = 1; index < arguments.Count; index += 2) + bool console = false; + int index = 1; + while (index < arguments.Count) { + string name = arguments[index]; + // --console is a bare flag (no value token) — the interactive + // console for the run command (see HeadlessConsoleOptions). + if (name == "--console") + { + console = true; + index += 1; + continue; + } + if (index + 1 >= arguments.Count) { throw new HeadlessCommandLineException( "Every command option requires a value."); } - string name = arguments[index]; string value = arguments[index + 1]; if (string.IsNullOrWhiteSpace(value)) { @@ -65,6 +77,7 @@ internal sealed record HeadlessCommandLine( throw new HeadlessCommandLineException( "Unknown command option."); } + index += 2; } if (configurationPath is null) @@ -82,6 +95,14 @@ internal sealed record HeadlessCommandLine( throw new HeadlessCommandLineException( "Direct credentials are valid only for run mode."); } + // N3: reject rather than silently ignore --console for validate mode + // — validate never starts a session, so there is nothing for the + // console to attach to. + if (console && command != "run") + { + throw new HeadlessCommandLineException( + "--console is valid only for run mode."); + } return new HeadlessCommandLine( command, @@ -92,7 +113,8 @@ internal sealed record HeadlessCommandLine( cacheDirectory), user is null ? null - : new HeadlessDirectCredentials(user, password!)); + : new HeadlessDirectCredentials(user, password!), + console); } private static void SetOnce(ref string? destination, string value) diff --git a/src/AcDream.Headless/Configuration/HeadlessConsoleOptions.cs b/src/AcDream.Headless/Configuration/HeadlessConsoleOptions.cs new file mode 100644 index 00000000..88239404 --- /dev/null +++ b/src/AcDream.Headless/Configuration/HeadlessConsoleOptions.cs @@ -0,0 +1,53 @@ +namespace AcDream.Headless.Configuration; + +/// +/// Typed resolution for the headless interactive console (docs/plans/ +/// 2026-09-07-headless-console.md). Three inputs, first match wins: +/// the --console command-line flag, the +/// ACDREAM_HEADLESS_CONSOLE environment variable, and finally a +/// terminal-shaped default — on when stdin is a real console (an operator +/// typing at a keyboard), off when it is redirected (a script, CI runner, or +/// piped fixture, where a background reader thread blocked on +/// ReadLine would never see input and would just sit idle). See +/// docs/launch-options.md for the documented row this owns. +/// +/// +/// S1 fix (2026-09-07 review round): the environment variable is a +/// default-on override once it is SET at all, not a bare "equals 1" test — +/// ACDREAM_HEADLESS_CONSOLE=0 must disable the console even when +/// stdin is a real terminal, matching the +/// ACDREAM_RETAIL_CLOSE_DEGRADES / ACDREAM_RETAIL_UI +/// convention (any value other than the literal string "0" enables). +/// An UNSET variable still falls through to the terminal-shaped default — +/// this flag's "default on" is conditional on stdin, unlike those two, but +/// once set at all it behaves identically. +/// +internal static class HeadlessConsoleOptions +{ + internal const string EnvironmentVariable = "ACDREAM_HEADLESS_CONSOLE"; + + internal static bool Resolve( + bool commandLineFlag, + bool standardInputIsTerminal) => + Resolve( + commandLineFlag, + Environment.GetEnvironmentVariable, + standardInputIsTerminal); + + internal static bool Resolve( + bool commandLineFlag, + Func env, + bool standardInputIsTerminal) + { + ArgumentNullException.ThrowIfNull(env); + if (commandLineFlag) + return true; + if (env(EnvironmentVariable) is null) + return standardInputIsTerminal; + // Default-on once the flag is set at all: any value other than the + // literal string "0" enables the console — the same + // ACDREAM_RETAIL_CLOSE_DEGRADES / ACDREAM_RETAIL_UI idiom. + return !string.Equals( + env("ACDREAM_HEADLESS_CONSOLE"), "0", StringComparison.Ordinal); + } +} diff --git a/src/AcDream.Headless/HeadlessEntryPoint.cs b/src/AcDream.Headless/HeadlessEntryPoint.cs index e5576e54..4bd8406b 100644 --- a/src/AcDream.Headless/HeadlessEntryPoint.cs +++ b/src/AcDream.Headless/HeadlessEntryPoint.cs @@ -46,7 +46,9 @@ internal static class HeadlessEntryPoint TextReader standardInput, TextWriter output, TextWriter error, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool standardInputIsTerminal = false, + bool standardOutputIsTerminal = false) { ArgumentNullException.ThrowIfNull(arguments); ArgumentNullException.ThrowIfNull(standardInput); @@ -74,13 +76,18 @@ internal static class HeadlessEntryPoint configuredPaths.Merge(commandLine.Paths)); if (commandLine.Command == "run") { + bool consoleEnabled = HeadlessConsoleOptions.Resolve( + commandLine.ConsoleEnabled, + standardInputIsTerminal); using var host = new HeadlessProcessHost( configuration, paths, standardInput, output, directCredentials: - commandLine.DirectCredentials); + commandLine.DirectCredentials, + consoleEnabled: consoleEnabled, + standardOutputIsTerminal: standardOutputIsTerminal); return (int)host.RunAsync(cancellationToken) .GetAwaiter() .GetResult(); diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs new file mode 100644 index 00000000..de8fdbfa --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs @@ -0,0 +1,58 @@ +using AcDream.Core.Chat; +using AcDream.Runtime; + +namespace AcDream.Headless.Hosting; + +/// +/// Presentation for the console's rendered chat lines. A distinct, terminal- +/// shaped format from the graphical ChatVM.FormatEntry retail prose +/// (Headless cannot reference AcDream.UI.Abstractions — see the +/// dependency-boundary test — and a script piping console output wants a +/// stable, greppable "[Label] Sender: text" shape more than retail's exact +/// sentence). It uses the SAME channel-name strings the graphical SpewBox +/// shows (, "Tell", "Local") per +/// the plan's requirement, just not the same sentence template. +/// +internal static class HeadlessConsoleChatFormatter +{ + /// Formats one chat event for the console, or + /// when this kind renders nothing (there are + /// none today — kept for forward compatibility with a future silent + /// kind). + internal static string? Format(in RuntimeChatEntry entry) + { + var kind = (ChatKind)entry.Kind; + return kind switch + { + ChatKind.LocalSpeech or ChatKind.RangedSpeech => + $"[Local] {SpeakerLabel(entry.Sender)}: {entry.Text}", + ChatKind.Channel => + $"[{ChannelLabel(entry)}] {SpeakerLabel(entry.Sender)}: {entry.Text}", + ChatKind.Tell => FormatTell(entry), + ChatKind.Emote or ChatKind.SoulEmote => + $"* {entry.Sender} {entry.Text}", + ChatKind.Popup => $"[Popup] {entry.Text}", + // System/Combat lines arrive pre-formatted (system messages, + // combat translator output) — render bare, matching retail's own + // no-prefix system-chat convention (Campaign CH user-gate round + // 1, item B). + _ => entry.Text, + }; + } + + private static string FormatTell(in RuntimeChatEntry entry) => + // SenderGuid != 0 is an incoming whisper (see ChatLog.OnTellReceived); + // == 0 is our own outbound echo, where Sender carries the target + // name (ChatLog.OnSelfSent). Both directions get the "[Tell]" label + // the plan asks for; the "You -> " marker is what disambiguates an + // outgoing tell from an incoming one in the bracket-label shape. + entry.SenderGuid != 0 + ? $"[Tell] {entry.Sender}: {entry.Text}" + : $"[Tell] You -> {entry.Sender}: {entry.Text}"; + + private static string SpeakerLabel(string sender) => + string.IsNullOrEmpty(sender) || sender == "You" ? "You" : sender; + + private static string ChannelLabel(in RuntimeChatEntry entry) => + string.IsNullOrEmpty(entry.ChannelName) ? "Channel" : entry.ChannelName; +} diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleController.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleController.cs new file mode 100644 index 00000000..51e66bbe --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleController.cs @@ -0,0 +1,116 @@ +using AcDream.Runtime.Chat; + +namespace AcDream.Headless.Hosting; + +/// +/// The console's own orchestration: owns the background reader +/// () and, once per session tick +/// (), drains every line queued since the last call +/// and dispatches each one IN ORDER, on the calling thread — never the +/// reader thread (Slice K's monotonic scheduler contract; see +/// 's own doc). +/// +/// +/// /quit and /status are console-only controls (the plan's +/// "Control" section) — they never reach , +/// matching retail's own client-local commands. Every other line goes +/// through , which a production caller binds to +/// HeadlessSessionHost.SubmitConsoleLine — the exact +/// pipeline (retail's client-command +/// catalog first, then local /help, then the plugin-verb registry, +/// then the retail unregistered-channel-tag fallback, then an explicit +/// server command, then plain chat) LoginCommandSequence and the +/// graphical chat box both already use. +/// +internal sealed class HeadlessConsoleController : IDisposable +{ + private readonly HeadlessConsoleInputReader _reader; + private readonly TextWriter _output; + private readonly Func _submit; + private readonly Func _statusText; + private readonly CancellationTokenSource _quitRequested; + + internal HeadlessConsoleController( + TextReader input, + TextWriter output, + Func submit, + Func statusText, + CancellationTokenSource quitRequested) + { + ArgumentNullException.ThrowIfNull(input); + _output = output ?? throw new ArgumentNullException(nameof(output)); + _submit = submit ?? throw new ArgumentNullException(nameof(submit)); + _statusText = statusText ?? throw new ArgumentNullException(nameof(statusText)); + _quitRequested = quitRequested + ?? throw new ArgumentNullException(nameof(quitRequested)); + _reader = new HeadlessConsoleInputReader(input); + } + + /// Number of lines handled by the most recent + /// call — a test seam for the reader-thread + /// ordering assertion. + internal int LastDrainCount { get; private set; } + + /// Test seam: lets a bounded-fixture test wait for the + /// background reader thread to reach EOF before calling + /// , instead of sleeping or polling. + internal HeadlessConsoleInputReader Reader => _reader; + + internal void DrainDue() + { + int count = 0; + while (_reader.TryDequeue(out string line)) + { + Handle(line); + count++; + } + LastDrainCount = count; + } + + private void Handle(string rawLine) + { + string trimmed = rawLine.Trim(); + if (trimmed.Length == 0) + return; + + if (trimmed.Equals("/quit", StringComparison.OrdinalIgnoreCase)) + { + WriteLine("quitting (graceful logout)"); + _quitRequested.Cancel(); + return; + } + + if (trimmed.Equals("/status", StringComparison.OrdinalIgnoreCase)) + { + WriteLine(_statusText()); + return; + } + + // S4 (2026-09-07 review round): mirrors + // LoginCommandSequence.DrainDue's own try/catch and + // UnknownCommand/Dropped reporting — a console typo (a bad line, a + // downstream bug in a plugin verb handler) must never escape to the + // scheduler's per-session quarantine catch and fault the whole + // session, and the operator deserves the same "this line did + // nothing" signal LoginCommandSequence already gives a login-line + // failure. + try + { + SubmitOutcome outcome = _submit(rawLine); + if (outcome is SubmitOutcome.UnknownCommand or SubmitOutcome.Dropped) + WriteLine($"not handled ({outcome}): {rawLine}"); + } + catch (Exception error) + { + WriteLine($"command failed: {error.GetBaseException().Message}"); + } + } + + private void WriteLine(string text) + { + _output.WriteLine(text); + _output.Flush(); + } + + public void Dispose() => _reader.Dispose(); +} diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs new file mode 100644 index 00000000..ea159b24 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs @@ -0,0 +1,94 @@ +using System.Collections.Concurrent; + +namespace AcDream.Headless.Hosting; + +/// +/// Reads lines from a on one dedicated background +/// thread and hands them to whoever drains . Slice K's +/// scheduler contract binds every mutating call to one thread for a session's +/// whole lifetime (#368 — collision generations refuse migration), so console +/// input can never be executed from this thread: it only ever enqueues, and +/// the session tick is the sole reader of . +/// +/// +/// has no cancellable overload, so a real +/// Console.In reader can be blocked on it when the process wants to +/// exit. The thread is a background thread (does not keep the process alive) +/// and only requests the loop stop at its next +/// opportunity — it does not abort a pending read. A closed/EOF input (a +/// piped fixture reaching its last line, or the real console's stdin handle +/// closing) ends the loop on its own; lets a test +/// wait for that deterministically instead of polling or sleeping. +/// +internal sealed class HeadlessConsoleInputReader : IDisposable +{ + private readonly TextReader _input; + private readonly ConcurrentQueue _queue = new(); + private readonly Thread _thread; + private volatile bool _stopRequested; + + internal HeadlessConsoleInputReader(TextReader input) + { + _input = input ?? throw new ArgumentNullException(nameof(input)); + _thread = new Thread(ReadLoop) + { + IsBackground = true, + Name = "acdream-headless-console-reader", + }; + _thread.Start(); + } + + /// Set once the reader loop has returned (EOF or stop request). + /// Tests wait on this instead of sleeping/polling for a deterministic + /// "every line the fixture will ever produce has been enqueued" signal. + /// + internal ManualResetEventSlim EndOfInput { get; } = new(initialState: false); + + /// Dequeues the next queued line in FIFO order, or returns + /// if none is queued yet. Never blocks. + internal bool TryDequeue(out string line) => _queue.TryDequeue(out line!); + + private void ReadLoop() + { + try + { + while (!_stopRequested) + { + string? line = _input.ReadLine(); + if (line is null) + return; + _queue.Enqueue(line); + } + } + catch (ObjectDisposedException) + { + // The input was disposed out from under a pending read (process + // teardown racing the reader thread) — end the loop quietly, + // same as EOF. + } + catch (IOException) + { + // A redirected stream can fail mid-read (e.g. a broken pipe). + // Treat it the same as EOF rather than crashing the process. + } + finally + { + EndOfInput.Set(); + } + } + + /// Requests the read loop stop at its next opportunity. Does + /// not abort a already in progress — + /// the thread is background, so it cannot block process exit. + /// Deliberately does NOT dispose : the read + /// loop's own finally sets it from the reader thread, and racing + /// that against a Dispose() here (an unhandled + /// on a background thread + /// terminates the process) is worse than leaking one small + /// synchronization handle for the process's remaining lifetime. + /// + public void Dispose() + { + _stopRequested = true; + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs new file mode 100644 index 00000000..3e60e3a6 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs @@ -0,0 +1,111 @@ +using AcDream.Runtime; + +namespace AcDream.Headless.Hosting; + +/// +/// One presentation over the K2 bot event stream +/// () — the SAME typed events a headless +/// bot policy observes (HeadlessBotPolicy.cs) — rendered as plain +/// lines. Every write goes through , so a test can +/// assert on exactly what a real console would have printed without a +/// terminal. +/// +internal sealed class HeadlessConsoleRenderer : IRuntimeEventObserver +{ + private const string Reset = ""; + private const string Dim = ""; + + private readonly TextWriter _output; + private readonly bool _useColor; + + internal HeadlessConsoleRenderer(TextWriter output, bool useColor) + { + _output = output ?? throw new ArgumentNullException(nameof(output)); + _useColor = useColor; + } + + public void OnChat(in RuntimeChatDelta delta) + { + string? line = HeadlessConsoleChatFormatter.Format(delta.Entry); + if (!string.IsNullOrEmpty(line)) + WriteLine(line, dim: false); + } + + /// + /// Retail's transient "interface text" (SpewBox, ClientLocal + /// type) never touches — + /// see RuntimeCommunicationState.AddText — so it never reaches + /// . HeadlessConsoleSpewBoxPump calls this + /// directly, once per console tick, for whatever text is newly visible + /// in the polled — the + /// SAME seam the graphical overlay's own SpewBox controller reads, so + /// server- and plugin-driven interface text prints here too, not only + /// the console's own submissions. Default weight (N5) — this is + /// player-visible interface text, not scheduling noise. + /// + internal void WriteInterfaceText(string text) => WriteLine(text, dim: false); + + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + switch (delta.Current) + { + case RuntimeLifecycleState.InWorld: + WriteLine("entered world", dim: true); + break; + case RuntimeLifecycleState.Stopping: + WriteLine("disconnecting", dim: true); + break; + case RuntimeLifecycleState.Faulted: + WriteLine("session faulted", dim: true); + break; + } + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + if (delta.Status == RuntimeCommandStatus.Rejected) + { + WriteLine( + $"command rejected: {delta.Domain} {delta.Text}".TrimEnd(), + dim: true); + } + } + + public void OnPortal(in RuntimePortalDelta delta) + { + if (delta.Portal.IsMaterialized) + { + WriteLine( + $"portal -> cell 0x{delta.Portal.DestinationCell:X8}", + dim: true); + } + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnCombat(in RuntimeCombatDelta delta) + { + } + + /// + /// N5 (2026-09-07 review round): only lifecycle/command/portal lines are + /// dimmed — scheduling and session-status noise, not player-visible + /// content. Chat and interface text print at the terminal's default + /// weight. + /// + private void WriteLine(string text, bool dim) + { + _output.WriteLine(_useColor && dim ? Dim + text + Reset : text); + _output.Flush(); + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs new file mode 100644 index 00000000..a901c89d --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs @@ -0,0 +1,63 @@ +using AcDream.Core.Chat; + +namespace AcDream.Headless.Hosting; + +/// +/// S5 (2026-09-07 review round, docs/plans/2026-09-07-headless-console.md): +/// polls on the console's own per-tick pump — the +/// SAME seam AcDream.App.UI.SpewBoxController.Tick drives for the +/// graphical overlay. Retail's transient "interface text" +/// (, routed by +/// RuntimeCommunicationState.AddText) never touches +/// RuntimeCommunicationState.Chat/RuntimeChatDelta, so it is +/// otherwise invisible to a console that only observes the chat event +/// stream — this is true for EVERY producer of that text (a bad-args +/// refusal from the console's own submit, but also a server-driven refusal +/// or a plugin's own interface-text write), not just the console's own +/// submissions. This replaces the earlier per-call +/// HeadlessConsoleChatFeedback decorator, which only ever saw text +/// produced by the console's own SubmitConsoleLine calls. +/// +internal sealed class HeadlessConsoleSpewBoxPump +{ + private readonly SpewBoxState _spewBox; + private readonly Func _nowSeconds; + private readonly Action _writeInterfaceText; + private SpewBoxEntry[] _lastSeen = []; + + internal HeadlessConsoleSpewBoxPump( + SpewBoxState spewBox, + Func nowSeconds, + Action writeInterfaceText) + { + _spewBox = spewBox ?? throw new ArgumentNullException(nameof(spewBox)); + _nowSeconds = nowSeconds + ?? throw new ArgumentNullException(nameof(nowSeconds)); + _writeInterfaceText = writeInterfaceText + ?? throw new ArgumentNullException(nameof(writeInterfaceText)); + } + + /// + /// Drains any pending SpewBox text into the visible set (exactly + /// 's contract — the same drain + /// SpewBoxVM.Lines performs for the graphical overlay) and prints + /// any entry that was not part of the previous call's visible snapshot. + /// + /// + /// is newest-first + /// (retail's InsertItem(item, 0)); this walks it back-to-front so + /// newly-visible entries print in the order they were actually + /// enqueued, not newest-first. + /// + internal void Pump() + { + _spewBox.Tick(_nowSeconds()); + SpewBoxEntry[] current = _spewBox.Snapshot(); + for (int i = current.Length - 1; i >= 0; i--) + { + if (Array.IndexOf(_lastSeen, current[i]) < 0) + _writeInterfaceText(current[i].Text); + } + _lastSeen = current; + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index 3a73be8b..323d8015 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -15,6 +15,16 @@ internal sealed class HeadlessProcessHost : IDisposable private readonly HeadlessDiagnosticWriter _diagnostics; private readonly HeadlessProcessContentOwner? _content; private readonly HeadlessProcessResourceSampler _resources; + /// + /// Headless console (docs/plans/2026-09-07-headless-console.md): always + /// created, cancelled only by /quit — linking it into the + /// scheduler's run token below costs nothing when the console is + /// disabled (it simply never fires) and keeps + /// free of a console-shaped branch. + /// + private readonly CancellationTokenSource _consoleQuitRequested = new(); + private readonly HeadlessConsoleController? _console; + private readonly IDisposable? _consoleRendererSubscription; private int _disposeIndex; private bool _disposed; @@ -26,7 +36,9 @@ internal sealed class HeadlessProcessHost : IDisposable ILiveSessionOperations? sessionOperations = null, TimeProvider? timeProvider = null, IHeadlessProcessContentFactory? contentFactory = null, - HeadlessDirectCredentials? directCredentials = null) + HeadlessDirectCredentials? directCredentials = null, + bool consoleEnabled = false, + bool standardOutputIsTerminal = false) { ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(paths); @@ -65,6 +77,8 @@ internal sealed class HeadlessProcessHost : IDisposable paths.VtankProfilesDirectory); HeadlessProcessContentOwner? content = null; HeadlessProcessResourceSampler? resources = null; + HeadlessConsoleController? console = null; + IDisposable? consoleRendererSubscription = null; // FA6: constructed unconditionally — cheap, and every non-gate // session simply never reads or writes it (see the coordinator's // own class doc). @@ -137,9 +151,62 @@ internal sealed class HeadlessProcessHost : IDisposable _resources = resources; _content = content; _disposeIndex = _sessions.Length - 1; + + // Headless console (docs/plans/2026-09-07-headless-console.md): + // "Multi-session. Out of scope for the first cut" — attach only + // to a single-session process. Constructed AFTER every + // session's credential resolution above (which may itself read + // a line from standardInput for a StandardInput-provider + // credential) so the console's own reader thread never races a + // password prompt for the same stream. + if (consoleEnabled && _sessions.Length == 1) + { + HeadlessSessionHost session = _sessions[0]; + var renderer = new HeadlessConsoleRenderer( + diagnostics, + useColor: standardOutputIsTerminal); + consoleRendererSubscription = + session.Runtime.Subscribe(renderer); + HeadlessConsoleController controller = new( + standardInput, + diagnostics, + session.SubmitConsoleLine, + () => BuildStatusText(session), + _consoleQuitRequested); + // S5 (2026-09-07 review round): poll the SAME SpewBoxState + // seam the graphical overlay's SpewBoxController.Tick reads + // (RuntimeCommunicationState.AddText's ClientLocal branch — + // it never touches Chat/RuntimeChatDelta) so server- and + // plugin-driven interface text prints too, not only the + // console's own submissions. Replaces the earlier per-call + // HeadlessConsoleChatFeedback decorator, which only saw text + // produced by THIS console's own SubmitConsoleLine calls. + var spewPump = new HeadlessConsoleSpewBoxPump( + session.Runtime.CommunicationOwner.SpewBox, + () => session.Runtime.Clock.SimulationTimeSeconds, + renderer.WriteInterfaceText); + session.ConsolePump = () => + { + controller.DrainDue(); + spewPump.Pump(); + }; + console = controller; + } + else if (consoleEnabled) + { + // S7 (2026-09-07 review round): a silent skip here read as + // "--console worked" to an operator with no way to tell + // otherwise — the launcher's multi-session mode is a + // legitimate, common configuration, so say so explicitly. + _diagnostics.Message("console", "single-session only"); + } + _console = console; + _consoleRendererSubscription = consoleRendererSubscription; } catch { + console?.Dispose(); + consoleRendererSubscription?.Dispose(); resources?.Dispose(); for (int index = sessions.Count - 1; index >= 0; index--) sessions[index].Dispose(); @@ -148,6 +215,29 @@ internal sealed class HeadlessProcessHost : IDisposable } } + /// + /// /status: generation, position (or "unknown" without a live + /// movement controller — a content-less host, or before the first + /// accepted placement), and the plugin-visible macro state this host + /// can actually observe today (loaded-plugin count — no plugin + /// currently reports a richer status string; see the plan's "if the + /// plugin reports one"). + /// + private static string BuildStatusText(HeadlessSessionHost session) + { + RuntimeMovementSnapshot movement = + session.Runtime.MovementOwner.Snapshot; + string position = movement.HasController + ? $"cell=0x{movement.Position.ObjCellId:X8} " + + $"local=({movement.Position.Frame.Origin.X:F2}," + + $"{movement.Position.Frame.Origin.Y:F2}," + + $"{movement.Position.Frame.Origin.Z:F2})" + : "unknown"; + return $"generation={session.Runtime.Generation.Value} " + + $"position={position} " + + $"plugins={session.Plugins.LoadedCount} loaded"; + } + internal HeadlessSessionHost Session => _sessions.Length == 1 ? _sessions[0] : throw new InvalidOperationException( @@ -249,12 +339,21 @@ internal sealed class HeadlessProcessHost : IDisposable _scheduler.CaptureSnapshot(), _content); + // Headless console: /quit cancels _consoleQuitRequested, which this + // linked token propagates into the scheduler's own wait loop — + // Run() returns normally (its loop condition simply goes false), + // the SAME graceful-exit path an external Ctrl+C/SIGTERM already + // takes. Linking costs nothing when the console never fires. + using CancellationTokenSource linkedQuit = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _consoleQuitRequested.Token); try { - _scheduler.Run(cancellationToken); + _scheduler.Run(linkedQuit.Token); } catch (OperationCanceledException) - when (cancellationToken.IsCancellationRequested) + when (linkedQuit.IsCancellationRequested) { } catch (Exception error) @@ -277,6 +376,9 @@ internal sealed class HeadlessProcessHost : IDisposable { if (_disposed) return; + _console?.Dispose(); + _consoleRendererSubscription?.Dispose(); + _consoleQuitRequested.Dispose(); while (_disposeIndex >= 0) { _sessions[_disposeIndex].Dispose(); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 0dbde856..4453cbbe 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -183,6 +183,25 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly IHeadlessBotPolicy _policy; private readonly IDisposable _policySubscription; private readonly HeadlessPluginSession _pluginSession; + /// + /// Headless console (docs/plans/2026-09-07-headless-console.md): the + /// SAME plugin-verb registry 's bus + /// forwards to (via TryHandlePluginCommand) and + /// hands to every loaded + /// plugin. Exposed only so a test can register a verb directly without + /// loading a real plugin assembly — production callers reach it + /// exclusively through / + /// , never this field. + /// + private readonly AcDream.Core.Plugins.PluginCommandRegistry _pluginCommands; + /// + /// Headless console: the SAME retained bus LoginCommandSequence + /// submits through — see . One instance + /// for the host's whole lifetime; + /// attaches/detaches a fresh to it on + /// every (re)connect, exactly as it does today for login commands. + /// + private readonly LiveChatCommandSurface _chatCommandSurface; private readonly LiveSessionHost _liveSession; private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease? @@ -453,6 +472,8 @@ internal sealed class HeadlessSessionHost : IDisposable Runtime = runtime; Commands = commands; _liveSession = liveSession; + _pluginCommands = pluginCommands; + _chatCommandSurface = chatCommandSurface; _statusWriter = statusWriter; _localPlayerFrame = runtime.CreateLocalPlayerFrameController( @@ -521,7 +542,20 @@ internal sealed class HeadlessSessionHost : IDisposable /// internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder; internal HeadlessPluginSession Plugins => _pluginSession; + /// Test seam (mirrors 's own + /// pattern): registers a plugin verb directly against the SAME registry + /// a real loaded plugin would use, without loading a plugin assembly. + /// + internal AcDream.Core.Plugins.PluginCommandRegistry PluginCommands => + _pluginCommands; internal string SessionId => _descriptor.Id; + /// + /// Headless console: invoked at the end of every so + /// console input drains ON the session tick, in order, never on the + /// reader thread. (every non-console host) costs + /// nothing extra per tick. + /// + internal Action? ConsolePump { get; set; } internal string ActiveCharacterName { get; private set; } = string.Empty; internal bool IsPolicyComplete => @@ -563,6 +597,31 @@ internal sealed class HeadlessSessionHost : IDisposable _pendingConfirmation = null; } + /// + /// The headless console's ONE entry point for a typed line — the exact + /// pipeline already submits through: + /// against this host's retained + /// . Dispatch order (matching + /// 's own class doc): retail's client- + /// command catalog first, then the local /help presentation + /// command, then the plugin-verb registry, then the retail unregistered- + /// channel-tag fallback, then an explicit server command, then plain + /// chat. Retail's transient interface text (bad-args refusals, unknown- + /// command text — never routed through + /// , see + /// RuntimeCommunicationState.AddText's ClientLocal branch) + /// lands in the shared + /// exactly like every other producer of that text; the console's own + /// per-tick pump polls it (see HeadlessConsoleSpewBoxPump) + /// instead of this call decorating its own feedback. + /// + internal SubmitOutcome SubmitConsoleLine(string line) => + ChatCommandRouter.Submit( + line, + new RuntimeChatCommandFeedback(Runtime.CommunicationOwner), + _chatCommandSurface, + ChatChannelKind.Say); + internal RuntimeSessionStartResult Start() { // Campaign LA slice LA1: "started" = session host start — the @@ -607,6 +666,10 @@ internal sealed class HeadlessSessionHost : IDisposable _localPlayerFrame.RunPostNetworkCommandPhase(); Runtime.ActionOwner.CombatAttack.Tick(); _policy.Tick(Runtime, Commands); + // Headless console: drain any input queued by the background reader + // thread since the last tick, in order, on THIS thread — never the + // reader thread (see HeadlessConsoleInputReader's own doc). + ConsolePump?.Invoke(); } internal RuntimeTeardownAcknowledgement Stop(string reason = "stopped") diff --git a/src/AcDream.Headless/Program.cs b/src/AcDream.Headless/Program.cs index 4fae4031..0b08d42c 100644 --- a/src/AcDream.Headless/Program.cs +++ b/src/AcDream.Headless/Program.cs @@ -27,7 +27,9 @@ try Console.In, Console.Out, Console.Error, - cancellation.Token); + cancellation.Token, + standardInputIsTerminal: !Console.IsInputRedirected, + standardOutputIsTerminal: !Console.IsOutputRedirected); } finally { diff --git a/src/AcDream.Plugin.Abstractions/Automation.cs b/src/AcDream.Plugin.Abstractions/Automation.cs index 894d062b..2e968d1e 100644 --- a/src/AcDream.Plugin.Abstractions/Automation.cs +++ b/src/AcDream.Plugin.Abstractions/Automation.cs @@ -302,10 +302,20 @@ public interface IPluginChat Array.Empty(); /// - /// Post a client-local system line, the channel retail uses for the - /// client's own notices. It is local to this client: nothing is sent to the - /// server and no other player sees it. + /// Post a plugin-originated system line into the chat window. It is + /// local to this client: nothing is sent to the server and no other + /// player sees it. /// + /// + /// Owner direction 2026-09-07 (register row AD-124): this used to route + /// through retail's ClientLocal (0x1A) channel — the SpewBox + /// overlay every ChatInterface window's default filter excludes. + /// The owner explicitly overrode that for plugin text, matching Decal's + /// own AddChatText behavior: plugin output now lands in the chat + /// transcript (retail Default/0x00) so it is actually visible and + /// scrolls back, never the transient overlay. See + /// AppAutomationSurface.PostSystemMessage for the implementation. + /// void PostSystemMessage(string text); /// diff --git a/src/AcDream.Runtime/Chat/ChatCommandRouter.cs b/src/AcDream.Runtime/Chat/ChatCommandRouter.cs index 3bb86ed9..ffd0e8d2 100644 --- a/src/AcDream.Runtime/Chat/ChatCommandRouter.cs +++ b/src/AcDream.Runtime/Chat/ChatCommandRouter.cs @@ -120,13 +120,19 @@ public static class ChatCommandRouter // Command-shaped but no letter verb ("/", "//shrug", "@ x"): // refuse locally rather than putting junk on the wire or in speech. // #363/#367: this is one of retail's DoHelp-family "Unknown - // command" fallbacks (0x1A ClientLocal, SpewBox-only) — routed - // through the interface-text seam now that one exists, instead of - // the chat scroll. + // command" fallbacks — retail itself types it 0x1A ClientLocal + // (SpewBox-only). Owner-directed override 2026-09-07 (register row + // AD-124): unknown-command refusals specifically must reach the + // chat window instead, so ShowSystemMessage (chat scroll, retail + // Default/0x00) replaces ShowInterfaceText (SpewBox) HERE ONLY — + // do not "fix" this back to ShowInterfaceText; that would silently + // re-hide the refusal the owner asked to keep visible. Real + // retail-command bad-argument refusals (AP-183) are UNCHANGED and + // still use ShowInterfaceText/SpewBox elsewhere in this file. if (trimmed[0] is '/' or '@' && (trimmed.Length == 1 || !char.IsLetter(trimmed[1]))) { - feedback.ShowInterfaceText( + feedback.ShowSystemMessage( $"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands."); return SubmitOutcome.UnknownCommand; } @@ -345,7 +351,11 @@ public static class ChatCommandRouter // SAME fallback an unregistered verb gets — DoHelp's help- // pointer-null guard skips its callback branch entirely. See // RetailCommandHelpTable.CatalogVerbsWithNoRetailHelp's remarks. - feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand); + // Owner-directed override 2026-09-07 (register row AD-124): + // this is an "Unknown command" refusal, so ShowSystemMessage + // (chat scroll) replaces ShowInterfaceText (SpewBox) here — + // do not revert. + feedback.ShowSystemMessage(RetailCommandHelpTable.UnknownCommand); return; } @@ -370,10 +380,14 @@ public static class ChatCommandRouter return; } - // Retail types this 0x1A (ClientLocal) -> SpewBox-only. #363/#367: - // now routed through IChatCommandFeedback.ShowInterfaceText instead - // of the chat scroll — see RetailCommandHelpTable.UnknownCommand. - feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand); + // Retail types this 0x1A (ClientLocal) -> SpewBox-only. #363/#367 + // originally routed it through IChatCommandFeedback.ShowInterfaceText + // for exactly that reason. Owner-directed override 2026-09-07 + // (register row AD-124): "Unknown command" refusals must reach the + // chat window instead, so ShowSystemMessage replaces + // ShowInterfaceText here — see RetailCommandHelpTable.UnknownCommand + // and do not revert this to ShowInterfaceText. + feedback.ShowSystemMessage(RetailCommandHelpTable.UnknownCommand); } private static bool EqAny(string value, params string[] options) diff --git a/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs b/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs index 001920f5..5b0b7c62 100644 --- a/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs +++ b/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs @@ -210,15 +210,29 @@ namespace AcDream.Runtime.Chat; /// /// /// -/// Issue #363 (2026-08-10): ChatCommandRouter now routes this +/// Issue #363 (2026-08-10): ChatCommandRouter routed this /// fallback (and every other 0x1A command-refusal call site) through /// IChatCommandFeedback.ShowInterfaceText — an optional hook the host /// wires to RuntimeCommunicationState.AddText, the same SpewBox /// chokepoint every other producer of interface text uses. The retained /// ChatVM implements this four-member feedback seam without entering -/// command-routing code. Closes ISSUES.md #367 and retires register row +/// command-routing code. Closed ISSUES.md #367 and retired register row /// AP-186. /// +/// +/// +/// Owner-directed override 2026-09-07 (register row AD-124): the +/// paragraph above still describes retail's own behavior faithfully, but +/// acdream no longer matches it for exactly this +/// text (both its call sites in ChatCommandRouter.EmitVerbHelp) and +/// the sibling "Unknown command: {verb}." refusal in +/// ChatCommandRouter.Submit's own body: those three sites now call +/// IChatCommandFeedback.ShowSystemMessage (the chat scroll, retail +/// Default/0x00) instead of ShowInterfaceText (SpewBox), so an +/// unknown command is actually visible and stays in the transcript. Every +/// OTHER 0x1A refusal this class documents (bad-args, AP-183) is +/// unchanged and still SpewBox-only. +/// /// public static class RetailCommandHelpTable { @@ -266,9 +280,16 @@ public static class RetailCommandHelpTable // acclient_2013_pseudo_c.txt:395052 (u"Unknown command", UTF-16LE) -- // DoHelp's fallback when the verb hash lookup fails, or resolves to an // entry with no registered help callback. Retail types this 0x1A - // (ClientLocal) -- SpewBox-only; see the class remarks' routing note -- - // ChatCommandRouter routes it through IChatCommandFeedback.ShowInterfaceText - // (issue #363), closing #367. + // (ClientLocal) -- SpewBox-only; see the class remarks' routing note. + // Owner-directed override 2026-09-07 (register row AD-124): acdream + // now routes THIS text (and the sibling "Unknown command: {verb}." + // refusal in ChatCommandRouter.Submit's own body) through + // IChatCommandFeedback.ShowSystemMessage (chat scroll) instead of + // ShowInterfaceText (SpewBox) — a deliberate deviation from retail's + // own 0x1A typing, scoped to unknown-command text only. Do not revert + // this to ShowInterfaceText without a fresh owner direction; every + // other 0x1A refusal in ChatCommandRouter (bad-args, AP-183) is + // unaffected and still uses ShowInterfaceText/SpewBox. public const string UnknownCommand = "Unknown command"; // @mr/@pr are registered with a NULL function pointer in the 2013 diff --git a/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs index 4281ebe5..460913cb 100644 --- a/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs @@ -107,10 +107,14 @@ public sealed class LaunchOptionsDocumentationTests } /// - /// The five flags that default ON. All are product/retail behaviors wearing an - /// A/B off-switch (=0 disables) — none is a diagnostic. FROZEN: - /// a diagnostic that activates without its env var set taxes every run - /// and every measurement silently, so growing this set fails. + /// The six flags that default ON. All are product/retail behaviors wearing an + /// A/B off-switch (=0 disables) — none is a diagnostic. + /// ACDREAM_HEADLESS_CONSOLE (added 2026-09-07, S1 fix round) is the + /// odd one out: its UNSET default is terminal-shaped, not unconditionally + /// on — but once it is SET AT ALL it reads the identical =0-disables + /// idiom this regex detects, so it belongs in this set on the same terms. + /// FROZEN: a diagnostic that activates without its env var set taxes every + /// run and every measurement silently, so growing this set fails. /// private static readonly IReadOnlySet DefaultOnBehaviorFlags = new HashSet(StringComparer.Ordinal) @@ -120,6 +124,7 @@ public sealed class LaunchOptionsDocumentationTests "ACDREAM_CAMERA_ALIGN_SLOPE", "ACDREAM_RETAIL_CLOSE_DEGRADES", "ACDREAM_RETAIL_UI", + "ACDREAM_HEADLESS_CONSOLE", }; /// @@ -134,7 +139,7 @@ public sealed class LaunchOptionsDocumentationTests RegexOptions.Compiled); [Fact] - public void OnlyTheFiveProductBehaviorFlagsDefaultOn() + public void OnlyTheSixProductBehaviorFlagsDefaultOn() { var defaultOn = new HashSet(StringComparer.Ordinal); foreach ((string path, _) in SourceFiles()) diff --git a/tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs b/tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs index 9982ace3..b3221fde 100644 --- a/tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs +++ b/tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs @@ -146,6 +146,31 @@ public sealed class AppAutomationSurfaceTests Assert.Equal(0, second.CommunicationOwner.SubscriberCount); } + /// + /// Owner-directed override 2026-09-07 (register row AD-124): plugin + /// output ("Unknown commands like /vt or stuff from plugins ... should + /// go to the chatbox") must land in the chat log, never the transient + /// SpewBox overlay retail's own ClientLocal (0x1A) typing used to send + /// it to — the same VTank-faithful destination Decal's own + /// AddChatText uses. + /// + [Fact] + public void PostSystemMessage_RoutesToChatLog_NeverSpewBox() + { + using var runtime = GameRuntimeTestFactory.Create(); + using var surface = new AppAutomationSurface(); + surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast); + + surface.PostSystemMessage("MossTank: buffs applied."); + + var entry = Assert.Single(runtime.CommunicationOwner.Chat.Snapshot()); + Assert.Equal("MossTank: buffs applied.", entry.Text); + Assert.Equal((uint)RetailLogTextType.Default, entry.LogTextType); + + runtime.CommunicationOwner.SpewBox.Tick(0d); + Assert.Equal(0, runtime.CommunicationOwner.SpewBox.Count); + } + [Fact] public void InventoryCompletionProjectsTheCanonicalRequestReceipt() { diff --git a/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs b/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs index 013181e6..66c5bf68 100644 --- a/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs +++ b/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs @@ -208,4 +208,243 @@ public sealed class UiMenuPlainStyleTests Assert.Equal(1, QuadCount(segs, FontTexture)); Assert.Equal(0, QuadCount(segs, 0u)); } + + // ── OPEN-popup coverage (owner live-client report 2026-09-07: "Drop down + // menus look horrible, there is also a checkmark on the text there") ──── + // + // The S7 fix above only replaced the CLOSED-state button face. The tests + // below pin the OPEN popup: plain mode draws no sprite/gradient/checkmark + // art at all (only untextured fills via DrawFill/DrawRectOutline, exactly + // like UiMarkupList's own chrome), while the retail popup — the class + // default, and every non-markup UiMenu caller — is unchanged (the + // existing golden above only covers the closed state; the golden here + // covers the open popup). + + private const float PlainRowHeight = 18f; + private const float PlainColumnWidth = 90f; + + private static UiMenu MakePopupMenu( + bool retailButtonArt, int itemCount, int rowsPerColumn, bool scrollable, + System.Action? countResolveCall = null) + { + var items = Enumerable.Range(0, itemCount) + .Select(i => new UiMenu.MenuItem(i == 0 ? "W" : $"row{i}", (object?)i)) + .ToArray(); + return new UiMenu + { + Width = 100f, Height = 20f, + DatFont = MakeFont(), + // Retail tests read the texture id straight back (id => (id, w, h)) so a + // texture id is a specific sprite by construction — the same convention + // UiAncestorClipTests uses. Plain tests wrap this to prove it is NEVER + // invoked (no gradient/sprite of ANY kind, not just the ones this class + // happens to name). + SpriteResolve = id => + { + countResolveCall?.Invoke(1); + return (id, 8, 8); + }, + RetailButtonArt = retailButtonArt, + NormalSprite = 0x06004D65u, + PressedSprite = 0x06004D66u, + PopupBgSprite = 0x0600124Cu, + ItemNormalSprite = 0x0600124Eu, + ItemHighlightSprite = 0x0600124Du, + // Non-zero retail scrollbar chrome ids (UiScrollbar.cs's own doc-cited + // values) so a plain test can assert these are never resolved — a zero + // id would be indistinguishable from "never set", and would collide + // with the untextured-fill bucket's own texture-0 key. + ScrollTrackSprite = 0x06004C5Fu, + ScrollThumbSprite = 0x06004C63u, + ScrollThumbTopSprite = 0x06004C60u, + ScrollThumbBottomSprite = 0x06004C66u, + ScrollUpSprite = 0x06004C6Cu, + ScrollDownSprite = 0x06004C69u, + ColumnWidth = PlainColumnWidth, + RowHeight = PlainRowHeight, + RowsPerColumn = rowsPerColumn, + Scrollable = scrollable, + OpenUpward = false, // downward: PopupTop == Height, simplest math for these tests + Items = items, + ButtonLabelProvider = () => "W", + }; + } + + private static bool HasFillQuad( + System.Collections.Generic.IReadOnlyList<(uint Texture, System.Collections.Generic.IReadOnlyList Verts)> segs, + float x, float y, float w, float h, Vector4 color, float tol = 0.05f) + { + foreach (var seg in segs) + { + if (seg.Texture != 0u) continue; + var v = seg.Verts; + for (int b = 0; b + FloatsPerQuad <= v.Count; b += FloatsPerQuad) + { + float qx = v[b], qy = v[b + 1]; + float qw = v[b + 8] - qx, qh = v[b + 9] - qy; + float r = v[b + 4], g = v[b + 5], bl = v[b + 6], a = v[b + 7]; + if (MathF.Abs(qx - x) < tol && MathF.Abs(qy - y) < tol + && MathF.Abs(qw - w) < tol && MathF.Abs(qh - h) < tol + && MathF.Abs(r - color.X) < tol && MathF.Abs(g - color.Y) < tol + && MathF.Abs(bl - color.Z) < tol && MathF.Abs(a - color.W) < tol) + return true; + } + } + return false; + } + + /// Opens the popup (MouseDown on the closed face) then, if given, + /// hovers a row via MouseMove — the same (Data1,Data2) local-coordinate + /// convention already uses for MouseDown. + private static void OpenAndHover(UiMenu menu, int? hoverRow = null) + { + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: 10))); + Assert.True(menu.IsOpen); + if (hoverRow is { } row) + { + // ix = lx - Border, iy = ly - (PopupTop + Border); PopupTop == Height (20) + // for these OpenUpward=false menus, Border == RetailChromeSprites.Border (5). + int ly = 20 + RetailChromeSprites.Border + row * (int)PlainRowHeight + (int)(PlainRowHeight / 2); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseMove, Data1: 10, Data2: ly))); + } + } + + [Fact] + public void Plain_OpenPopup_GridMode_DrawsFlatFillsSelectedAndHover_NoSpriteResolveCalls() + { + int resolveCalls = 0; + var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 7, scrollable: false, + countResolveCall: n => resolveCalls += n); + menu.Selected = 1; // row 1 is "current" + OpenAndHover(menu, hoverRow: 2); // row 2 is hovered (not selected) + + var (renderer, ctx) = MakeContext(200f, 200f); + menu.DrawOverlays(ctx); + var segs = renderer.DebugSpriteSegmentVerts; + + Assert.Equal(0, resolveCalls); // no DAT art resolved at all — not even by id + Assert.Equal(0, QuadCount(segs, 0x0600124Cu)); // retail PopupBgSprite never drawn + Assert.Equal(0, QuadCount(segs, 0x0600124Du)); // retail ItemHighlightSprite (bakes the checkmark) never drawn + Assert.Equal(0, QuadCount(segs, 0x0600124Eu)); // retail ItemNormalSprite never drawn + + float outerTop = menu.Height; // OpenUpward=false + float outerW = menu.PopupOuterWidth, outerH = menu.PopupOuterHeight; + float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border; + + Assert.True(HasFillQuad(segs, 0f, outerTop, outerW, outerH, menu.PlainBackgroundColor), + "expected the plain popup background fill"); + Assert.True(HasFillQuad(segs, inX, inY + 1 * PlainRowHeight, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor), + "expected row 1 (selected/current) filled with PlainSelectedColor"); + Assert.True(HasFillQuad(segs, inX, inY + 2 * PlainRowHeight, PlainColumnWidth, PlainRowHeight, menu.PlainHoverColor), + "expected row 2 (hovered) filled with PlainHoverColor"); + + // background(1) + outline(4 sides) + selected row(1) + hovered row(1) = 7, + // nothing else untextured. + Assert.Equal(7, QuadCount(segs, 0u)); + } + + [Fact] + public void Plain_OpenPopup_RowText_LeftAlignedAtPlainPadding() + { + var menu = MakePopupMenu(retailButtonArt: false, itemCount: 1, rowsPerColumn: 7, scrollable: false); + OpenAndHover(menu); + + var (renderer, ctx) = MakeContext(200f, 200f); + menu.DrawOverlays(ctx); + + // Item 0's label is "W" — the one glyph MakeFont() defines — so exactly + // one FontTexture quad renders, at column 0's PlainPadding inset (no + // authored TextIndent/centering in plain mode). + var glyphSeg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == FontTexture); + Assert.Equal(RetailChromeSprites.Border + UiMenu.PlainPadding, glyphSeg.Verts[0], 3); + } + + [Fact] + public void Plain_OpenPopup_ScrollableOverflow_DrawsPlainTrackAndFlatThumb_NoDatArt() + { + int resolveCalls = 0; + var menu = MakePopupMenu(retailButtonArt: false, itemCount: 12, rowsPerColumn: 5, scrollable: true, + countResolveCall: n => resolveCalls += n); + menu.Selected = 0; // row 0 (visible) is "current" + OpenAndHover(menu); + + var (renderer, ctx) = MakeContext(200f, 200f); + menu.DrawOverlays(ctx); + var segs = renderer.DebugSpriteSegmentVerts; + + Assert.True(menu.PopupScroll.HasOverflow); + Assert.Equal(0, resolveCalls); + Assert.Equal(0, QuadCount(segs, menu.ScrollTrackSprite)); + Assert.Equal(0, QuadCount(segs, menu.ScrollThumbSprite)); + + float outerTop = menu.Height; + float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border; + float scrollbarX = inX + PlainColumnWidth; + + Assert.True(HasFillQuad(segs, inX, inY, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor), + "expected visible row 0 (selected/current) filled with PlainSelectedColor"); + Assert.True(HasFillQuad(segs, scrollbarX, inY, menu.ScrollbarWidth, 5 * PlainRowHeight, menu.PlainBackgroundColor), + "expected the scrollbar track background fill"); + + // popup bg(1)+outline(4) + selected row(1) + scrollbar bg(1)+outline(4) + thumb(1) = 12. + Assert.Equal(12, QuadCount(segs, 0u)); + } + + [Fact] + public void Plain_ScrollablePopup_ContentFits_DrawsTrackWithNoThumb() + { + var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 5, scrollable: true); + OpenAndHover(menu); + + var (renderer, ctx) = MakeContext(200f, 200f); + menu.DrawOverlays(ctx); + var segs = renderer.DebugSpriteSegmentVerts; + + Assert.False(menu.PopupScroll.HasOverflow); + + // popup bg(1)+outline(4) + scrollbar bg(1)+outline(4) = 10, no thumb quad + // (nothing selected/hovered here either). + Assert.Equal(10, QuadCount(segs, 0u)); + } + + [Fact] + public void Plain_OpenPopup_HitTesting_SelectsHoveredRow_ClosesPopup() + { + // The new hover-tracking MouseMove handling must not change what a + // MouseDown on the same row does — same rows, same scroll, same pick. + object? picked = null; + var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 7, scrollable: false); + menu.OnSelect = p => picked = p; + OpenAndHover(menu, hoverRow: 2); + + int ly = 20 + RetailChromeSprites.Border + 2 * (int)PlainRowHeight + (int)(PlainRowHeight / 2); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: ly))); + + Assert.Equal(2, picked); + Assert.False(menu.IsOpen); + } + + [Fact] + public void Retail_OpenPopup_DrawIsByteForByteUnchanged_RegressionGolden() + { + // A golden pin for the OPEN popup on a retail-styled (RetailButtonArt=true, + // the class default) menu — proves the S7-follow-up refactor of + // OnDrawOverlay (adding the plain branch) left the retail branch + // byte-identical: same bevel, same panel-fill sprite, same per-row + // highlight/normal sprite, and critically NO untextured fill anywhere + // (the plain path is a fully separate branch, never blended in). + var menu = MakePopupMenu(retailButtonArt: true, itemCount: 2, rowsPerColumn: 7, scrollable: false); + menu.Selected = 1; + OpenAndHover(menu); + + var (renderer, ctx) = MakeContext(200f, 200f); + menu.DrawOverlays(ctx); + var segs = renderer.DebugSpriteSegmentVerts; + + Assert.Equal(1, QuadCount(segs, RetailChromeSprites.CenterFill)); // bevel drawn + Assert.Equal(1, QuadCount(segs, 0x0600124Cu)); // PopupBgSprite panel fill + Assert.Equal(1, QuadCount(segs, 0x0600124Du)); // ItemHighlightSprite (row 1, selected) + Assert.Equal(1, QuadCount(segs, 0x0600124Eu)); // ItemNormalSprite (row 0) + Assert.Equal(0, QuadCount(segs, 0u)); // no untextured fill in the retail path + } } diff --git a/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs b/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs new file mode 100644 index 00000000..81d1e9f1 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs @@ -0,0 +1,860 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Net; +using System.Text; +using AcDream.Core.Chat; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Headless.Configuration; +using AcDream.Headless.Credentials; +using AcDream.Headless.Diagnostics; +using AcDream.Headless.Hosting; +using AcDream.Headless.Platform; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime; +using AcDream.Runtime.Chat; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Tests; + +/// +/// docs/plans/2026-09-07-headless-console.md — the headless interactive +/// console. Two layers: / +/// tested in isolation (no live +/// server, no ), then +/// tested against a real +/// host wired to — the same +/// no-network fixture pattern HeadlessSessionHostTests already uses +/// for LoginCommandSequence, proving the console reuses the EXACT +/// same pipeline rather than a second parser. +/// +public sealed class HeadlessConsoleTests +{ + // ── HeadlessConsoleOptions (typed option resolution) ───────────────── + + [Theory] + [InlineData(true, "0", false, true)] // CLI flag always wins, even over env "0" + [InlineData(false, "1", false, true)] // env var "1" wins over terminal default + [InlineData(false, "yes", false, true)] // any non-"0" env value enables (RETAIL_CLOSE_DEGRADES/RETAIL_UI idiom) + // S1 fix (2026-09-07 review round): ACDREAM_HEADLESS_CONSOLE=0 must + // disable the console even when stdin IS a real terminal — the earlier + // `== "1"` test let "0" silently fall through to the terminal-shaped + // default instead of acting as the documented A/B off-switch. + [InlineData(false, "0", true, false)] // env var "0" disables even when stdin is a terminal + [InlineData(false, "0", false, false)] // env var "0" disables when stdin is redirected too + [InlineData(false, null, true, true)] // no flag/env -> terminal-shaped default (on) + [InlineData(false, null, false, false)] // no flag/env -> terminal-shaped default (off) + public void ResolvePrefersFlagThenEnvironmentThenTerminalDefault( + bool commandLineFlag, + string? environmentValue, + bool standardInputIsTerminal, + bool expected) + { + bool resolved = HeadlessConsoleOptions.Resolve( + commandLineFlag, + _ => environmentValue, + standardInputIsTerminal); + + Assert.Equal(expected, resolved); + } + + [Fact] + public void CommandLineParsesTheBareConsoleFlag() + { + HeadlessCommandLine parsed = HeadlessCommandLine.Parse( + ["run", "--config", "bot.json", "--console"]); + + Assert.True(parsed.ConsoleEnabled); + Assert.Equal("bot.json", parsed.ConfigurationPath); + } + + [Fact] + public void CommandLineWithoutTheFlagDefaultsConsoleOff() + { + HeadlessCommandLine parsed = HeadlessCommandLine.Parse( + ["run", "--config", "bot.json"]); + + Assert.False(parsed.ConsoleEnabled); + } + + /// + /// N3: validate mode rejects --console outright (rather than + /// silently ignoring it) — validate never starts a session, so there is + /// nothing for the console to attach to. + /// + [Fact] + public void ValidateModeRejectsTheConsoleFlag() + { + Assert.Throws(() => + HeadlessCommandLine.Parse( + ["validate", "--config", "bot.json", "--console"])); + } + + // ── HeadlessConsoleInputReader: reader-thread/ordering ─────────────── + + /// + /// The required reader-thread test: lines produced by the background + /// thread are drained, in FIFO order, entirely on the CALLING thread. + /// The reader thread itself never runs anything beyond + /// ConcurrentQueue.Enqueue — there is no dispatch code it could + /// execute — so this also structurally proves "never executed on the + /// reader thread," not just orders the output. + /// + [Fact] + public void LinesQueuedByTheReaderThreadDrainInOrderOnTheCallingThread() + { + using var input = new System.IO.StringReader( + "one" + Environment.NewLine + + "two" + Environment.NewLine + + "three" + Environment.NewLine); + using var reader = new HeadlessConsoleInputReader(input); + + Assert.True( + reader.EndOfInput.Wait(TimeSpan.FromSeconds(5)), + "the reader thread never reached EOF"); + + int callingThread = Environment.CurrentManagedThreadId; + var drained = new List(); + while (reader.TryDequeue(out string line)) + { + drained.Add(line); + // Proves the dequeue (and everything a caller does with the + // line) runs on THIS thread, not the reader thread. + Assert.Equal(callingThread, Environment.CurrentManagedThreadId); + } + + Assert.Equal(["one", "two", "three"], drained); + } + + /// + /// S2 (2026-09-07 review round): makes the "never runs on the reader + /// thread" pin FALSIFIABLE rather than merely structurally argued. The + /// prior test proves ordering but infers "never the reader thread" from + /// the reader loop's own code having nothing to dispatch — this test + /// records the ACTUAL thread id ran on + /// (via ) and asserts, from + /// inside the controller's own submit callback, that the executing + /// thread is neither that reader thread nor any other unexpected + /// thread — it must be exactly the thread that called + /// . + /// + [Fact] + public void SubmitRunsOnTheDrainCallersThreadNeverTheReaderThread() + { + using var fixture = new ThreadIdRecordingTextReader( + new System.IO.StringReader("hello" + Environment.NewLine)); + int? observedSubmitThreadId = null; + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + fixture, + TextWriter.Null, + line => + { + observedSubmitThreadId = Environment.CurrentManagedThreadId; + return SubmitOutcome.Sent; + }, + () => string.Empty, + quit); + + Assert.True(WaitForEndOfInput(controller)); + int drainCallerThreadId = Environment.CurrentManagedThreadId; + controller.DrainDue(); + + Assert.NotNull(fixture.ReadLineThreadId); + Assert.NotNull(observedSubmitThreadId); + Assert.NotEqual(fixture.ReadLineThreadId, observedSubmitThreadId); + Assert.Equal(drainCallerThreadId, observedSubmitThreadId); + } + + // ── HeadlessConsoleController: /quit, /status, dispatch ordering ───── + + [Fact] + public void ControllerDrainsEveryLineQueuedSinceTheLastTickInOrderOnOneCall() + { + // Simulates "input queued during a busy tick": every line is + // enqueued by the reader thread before DrainDue is ever called — + // one DrainDue call must still process all of them, in order. + using var input = new System.IO.StringReader( + "alpha" + Environment.NewLine + + "beta" + Environment.NewLine + + "gamma" + Environment.NewLine); + var handled = new List(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + TextWriter.Null, + line => + { + handled.Add(line); + return SubmitOutcome.Sent; + }, + () => string.Empty, + quit); + + Assert.True( + WaitForEndOfInput(controller), + "the reader thread never reached EOF"); + controller.DrainDue(); + + Assert.Equal(["alpha", "beta", "gamma"], handled); + Assert.Equal(3, controller.LastDrainCount); + + // A second drain with nothing queued does nothing — proves DrainDue + // does not re-process already-handled lines. + controller.DrainDue(); + Assert.Equal(["alpha", "beta", "gamma"], handled); + Assert.Equal(0, controller.LastDrainCount); + } + + [Fact] + public void QuitRequestsCancellationAndNeverReachesSubmit() + { + using var input = new System.IO.StringReader("/quit" + Environment.NewLine); + var submitted = new List(); + var output = new StringWriter(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + output, + line => + { + submitted.Add(line); + return SubmitOutcome.Sent; + }, + () => string.Empty, + quit); + + Assert.True(WaitForEndOfInput(controller)); + controller.DrainDue(); + + Assert.True(quit.IsCancellationRequested); + Assert.Empty(submitted); + Assert.Contains("quitting", output.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void StatusPrintsTheProvidedStatusTextAndNeverReachesSubmit() + { + using var input = new System.IO.StringReader("/status" + Environment.NewLine); + var submitted = new List(); + var output = new StringWriter(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + output, + line => + { + submitted.Add(line); + return SubmitOutcome.Sent; + }, + () => "generation=1 position=unknown plugins=0 loaded", + quit); + + Assert.True(WaitForEndOfInput(controller)); + controller.DrainDue(); + + Assert.Empty(submitted); + Assert.Contains( + "generation=1 position=unknown plugins=0 loaded", + output.ToString()); + } + + /// + /// S4: and + /// get a visible console line — + /// matching LoginCommandSequence.DrainDue's own reporting for the + /// same two outcomes — instead of silently doing nothing. + /// + [Theory] + [InlineData(SubmitOutcome.UnknownCommand)] + [InlineData(SubmitOutcome.Dropped)] + public void UnknownOrDroppedOutcomePrintsAVisibleLine(SubmitOutcome outcome) + { + using var input = new System.IO.StringReader("garbage" + Environment.NewLine); + var output = new StringWriter(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + output, + _ => outcome, + () => string.Empty, + quit); + + Assert.True(WaitForEndOfInput(controller)); + controller.DrainDue(); + + Assert.Contains("garbage", output.ToString()); + Assert.Contains(outcome.ToString(), output.ToString()); + } + + /// + /// S4: a throwing submit callback (a console typo hitting a downstream + /// bug in a plugin verb handler, say) never escapes DrainDue — + /// it must never reach the scheduler's per-session quarantine catch and + /// fault the whole session over one bad console line. Mirrors + /// LoginCommandSequence.DrainDue's own try/catch. + /// + [Fact] + public void SubmitFailurePrintsALineAndNeverEscapesDrainDue() + { + using var input = new System.IO.StringReader("boom" + Environment.NewLine); + var output = new StringWriter(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + output, + _ => throw new InvalidOperationException("fixture failure"), + () => string.Empty, + quit); + + Assert.True(WaitForEndOfInput(controller)); + controller.DrainDue(); + + Assert.Contains("fixture failure", output.ToString()); + } + + // ── HeadlessConsoleChatFormatter: channel-prefixed rendering ───────── + + [Theory] + [InlineData("Bob", 0x50000010u, "hi", "[Local] Bob: hi")] + [InlineData("", 0u, "hi", "[Local] You: hi")] + public void FormatsLocalSpeechWithTheLocalLabel( + string sender, uint senderGuid, string text, string expected) + { + var entry = new RuntimeChatEntry( + Revision: 1, + SenderGuid: senderGuid, + Kind: (int)ChatKind.LocalSpeech, + Sender: sender, + Text: text, + ChannelName: string.Empty); + + Assert.Equal(expected, HeadlessConsoleChatFormatter.Format(entry)); + } + + [Fact] + public void FormatsChannelBroadcastWithItsFriendlyName() + { + var entry = new RuntimeChatEntry( + Revision: 1, + SenderGuid: 0x50000010u, + Kind: (int)ChatKind.Channel, + Sender: "Bob", + Text: "group up", + ChannelName: "Fellowship"); + + Assert.Equal( + "[Fellowship] Bob: group up", + HeadlessConsoleChatFormatter.Format(entry)); + } + + [Theory] + [InlineData(0x50000010u, "Bob", "hi", "[Tell] Bob: hi")] + [InlineData(0u, "Bob", "hi", "[Tell] You -> Bob: hi")] + public void FormatsTellWithDirection( + uint senderGuid, string sender, string text, string expected) + { + var entry = new RuntimeChatEntry( + Revision: 1, + SenderGuid: senderGuid, + Kind: (int)ChatKind.Tell, + Sender: sender, + Text: text, + ChannelName: string.Empty); + + Assert.Equal(expected, HeadlessConsoleChatFormatter.Format(entry)); + } + + // ── HeadlessConsoleRenderer: N5 dim-weight rules ───────────────────── + + /// + /// N5: chat and interface text are player-visible content, not + /// scheduling noise — they must print at the terminal's default weight, + /// never dimmed, even when color is enabled. + /// + [Fact] + public void ChatAndInterfaceTextPrintAtDefaultWeightNeverDimmed() + { + var output = new StringWriter(); + var renderer = new HeadlessConsoleRenderer(output, useColor: true); + var entry = new RuntimeChatEntry( + Revision: 1, + SenderGuid: 0x50000010u, + Kind: (int)ChatKind.LocalSpeech, + Sender: "Bob", + Text: "hi", + ChannelName: string.Empty); + + renderer.OnChat(new RuntimeChatDelta(default, entry)); + renderer.WriteInterfaceText("Unknown command: /x"); + + string text = output.ToString(); + Assert.DoesNotContain("[2m", text); + Assert.Contains("[Local] Bob: hi", text); + Assert.Contains("Unknown command: /x", text); + } + + /// + /// N5: lifecycle, command, and portal lines are scheduling/session- + /// status noise, not player-visible content — dimmed when color is + /// enabled. + /// + [Fact] + public void LifecycleCommandAndPortalLinesAreDimmedWhenColorIsEnabled() + { + var output = new StringWriter(); + var renderer = new HeadlessConsoleRenderer(output, useColor: true); + + renderer.OnLifecycle(new RuntimeLifecycleDelta( + default, RuntimeLifecycleState.Starting, RuntimeLifecycleState.InWorld)); + renderer.OnCommand(new RuntimeCommandDelta( + default, RuntimeCommandDomain.Chat, 0, RuntimeCommandStatus.Rejected, Text: "boom")); + renderer.OnPortal(new RuntimePortalDelta( + default, + new RuntimePortalSnapshot( + Generation: 1, + RuntimePortalKind.Portal, + Readiness: new RuntimeDestinationReadiness( + 1, 0x12345678u, false, false, 0, true, true, true), + Materialized: true, + Completed: false, + Cancelled: false, + WorldViewportObserved: true, + WorldSimulationAvailable: true, + InvariantFailureCount: 0, + WaitCueShown: false, + PortalMaterializationCount: 1))); + + string[] lines = output.ToString() + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(3, lines.Length); + Assert.All(lines, line => Assert.Contains("[2m", line)); + } + + // ── HeadlessSessionHost.SubmitConsoleLine: the real dispatch pipeline ─ + + [Fact] + public void SlashSayProducesTheSameOutboundTalkActionTheGraphicalRouteSends() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + + SubmitOutcome outcome = host.SubmitConsoleLine("/say hello"); + + Assert.Equal(SubmitOutcome.Sent, outcome); + byte[] body = Assert.Single(captured); + Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body)); + Assert.Equal("hello", TalkText(body)); + } + + [Fact] + public void PlainTextProducesTheSameOutboundTalkActionAsSlashSay() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + + SubmitOutcome outcome = host.SubmitConsoleLine("hello"); + + Assert.Equal(SubmitOutcome.Sent, outcome); + byte[] body = Assert.Single(captured); + Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body)); + Assert.Equal("hello", TalkText(body)); + } + + [Fact] + public void PluginVerbReachesTheRegisteredPluginCommandWithoutTouchingTheWire() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + var received = new List(); + using IDisposable registration = host.PluginCommands.Register( + "vt", + command => received.Add(command)); + + SubmitOutcome outcome = host.SubmitConsoleLine("/vt start"); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + PluginCommand command = Assert.Single(received); + Assert.Equal("vt", command.Verb); + Assert.Equal("start", command.Arguments); + Assert.Empty(captured); + } + + /// + /// S5 rework: SubmitConsoleLine no longer takes a per-call + /// interface-text callback — retail's transient interface text + /// (ClientLocal) lands in the shared + /// exactly like every other + /// producer of that text (see RuntimeCommunicationState.AddText), + /// and the console's own per-tick pump polls it — proven directly here + /// against the real rather + /// than a decorator only this call site could see. + /// + [Fact] + public void UnknownVerbProducesTheSameChatLineTheChatBoxShows() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + + // A bare "/" is retail's degenerate-prefix case (no letter verb) — + // ChatCommandRouter refuses it locally instead of sending it to the + // server or speech (ChatCommandRouterTests. + // DegeneratePrefix_UnknownCommand_ShowsRefusal_ViaInterfaceTextSeam + // pins the exact same text for the graphical route). + SubmitOutcome outcome = host.SubmitConsoleLine("/"); + + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + // Owner override 2026-09-07 (register row AD-124): the refusal lands + // in the CHAT scroll, not the SpewBox — the same line the graphical + // chat box shows (ChatCommandRouterFeedbackRoutingTests pins that + // route). The console sees it through OnChat, so the SpewBox stays + // empty. + var chatEntry = Assert.Single(host.Runtime.CommunicationOwner.Chat.Snapshot()); + Assert.Contains("Unknown command:", chatEntry.Text); + SpewBoxState spewBox = host.Runtime.CommunicationOwner.SpewBox; + spewBox.Tick(host.Runtime.Clock.SimulationTimeSeconds); + Assert.Empty(spewBox.Snapshot()); + } + + // ── HeadlessConsoleSpewBoxPump: server/plugin-driven interface text ── + + /// + /// S5: the pump must surface interface text that never went through the + /// console at all — a stand-in for a server- or plugin-driven + /// ClientLocal write reaching RuntimeCommunicationState.AddText + /// directly, exactly the case the deleted per-call + /// HeadlessConsoleChatFeedback decorator could never see (it only + /// ever wrapped THIS console's own SubmitConsoleLine feedback). + /// + [Fact] + public void PumpPrintsInterfaceTextNotOriginatingFromTheConsole() + { + var spewBox = new SpewBoxState(); + var printed = new List(); + double now = 0d; + var pump = new HeadlessConsoleSpewBoxPump(spewBox, () => now, printed.Add); + + // Simulates a plugin's own Log/interface-text write, or a server- + // driven refusal — never called HeadlessConsoleController.Handle or + // HeadlessSessionHost.SubmitConsoleLine. + spewBox.Enqueue("[vt] navigation route loaded"); + + pump.Pump(); + + Assert.Equal(["[vt] navigation route loaded"], printed); + + // A second pump with nothing new enqueued must not reprint the + // still-visible entry. + now += 0.1d; + pump.Pump(); + Assert.Equal(["[vt] navigation route loaded"], printed); + } + + // ── HeadlessProcessHost: end-to-end console wiring ─────────────────── + + /// + /// S3: an end-to-end proof that a console line, read from a plain + /// , reaches the real session's + /// SubmitConsoleLine pipeline through the actual + /// wiring (background reader thread → + /// per-tick ConsolePumpChatCommandRouter.Submit → the + /// wire), and that /quit ends + /// through the SAME graceful path an external cancellation takes — + /// , not an error code. + /// + [Fact] + public async Task ConsoleLineReachesTheSessionAndQuitEndsTheProcessGracefully() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = [Descriptor()], + }; + using var diagnostics = new StringWriter(); + using var input = new System.IO.StringReader( + "hello" + Environment.NewLine + "/quit" + Environment.NewLine); + using var host = new HeadlessProcessHost( + configuration, + HeadlessPathSet.Resolve(new HeadlessPathOverrides()), + input, + diagnostics, + operations, + new FakeTimeProvider(), + directCredentials: new HeadlessDirectCredentials("account", "password"), + consoleEnabled: true); + + HeadlessExitCode exitCode = await host.RunAsync(CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(HeadlessExitCode.Success, exitCode); + byte[] body = Assert.Single(captured); + Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body)); + Assert.Equal("hello", TalkText(body)); + } + + /// + /// S6: standardOutputIsTerminal is threaded in as a constructor + /// parameter, not read from the real System.Console inside + /// — proven by flipping only the + /// parameter (this test process's OWN stdout is redirected by the test + /// host either way) and observing the renderer's dim-vs-plain choice + /// follow it. + /// + [Theory] + [InlineData(true, true)] + [InlineData(false, false)] + public async Task StandardOutputIsTerminalParameterControlsColorNotTheRealConsole( + bool standardOutputIsTerminal, bool expectDimmed) + { + var operations = new FixtureSessionOperations(); + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = [Descriptor()], + }; + using var diagnostics = new StringWriter(); + using var input = new System.IO.StringReader("/quit" + Environment.NewLine); + using var host = new HeadlessProcessHost( + configuration, + HeadlessPathSet.Resolve(new HeadlessPathOverrides()), + input, + diagnostics, + operations, + new FakeTimeProvider(), + directCredentials: new HeadlessDirectCredentials("account", "password"), + consoleEnabled: true, + standardOutputIsTerminal: standardOutputIsTerminal); + + await host.RunAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10)); + + string text = diagnostics.ToString(); + Assert.Contains("entered world", text); + Assert.Equal(expectDimmed, text.Contains("[2m")); + } + + /// + /// S7: a multi-session process with --console must tell the + /// operator why the console never attached (the launcher's multi-session + /// mode is a legitimate, common configuration) instead of silently + /// doing nothing — see HeadlessDiagnosticWriter.Message's "console" + /// category. + /// + [Fact] + public void TwoSessionsWithConsoleFlagReportsSingleSessionOnly() + { + // StandardInput credentials (one line per session, consumed by + // HeadlessCredentialResolver BEFORE the console reader thread ever + // starts — see HeadlessProcessHost's own constructor comment) avoid + // needing an ACE-shaped Environment credential just to reach the + // console-wiring branch this test targets. + HeadlessSessionDescriptor StandardInputDescriptor(string id) => + Descriptor() with + { + Id = id, + Credential = new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.StandardInput, + Reference = "fixture", + }, + }; + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = + [ + StandardInputDescriptor("one"), + StandardInputDescriptor("two"), + ], + }; + var operations = new FixtureSessionOperations(); + using var diagnostics = new StringWriter(); + using var input = new System.IO.StringReader( + "password-one" + Environment.NewLine + + "password-two" + Environment.NewLine); + using var host = new HeadlessProcessHost( + configuration, + HeadlessPathSet.Resolve(new HeadlessPathOverrides()), + input, + diagnostics, + operations, + new FakeTimeProvider(), + directCredentials: null, + consoleEnabled: true); + + Assert.Contains("single-session only", diagnostics.ToString()); + } + + private static bool WaitForEndOfInput(HeadlessConsoleController controller) => + controller.Reader.EndOfInput.Wait(TimeSpan.FromSeconds(5)); + + private static uint ActionOpcode(byte[] body) => + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, sizeof(uint))); + + private static string TalkText(byte[] body) + { + ushort length = BinaryPrimitives.ReadUInt16LittleEndian( + body.AsSpan(12, sizeof(ushort))); + return Encoding.ASCII.GetString(body, 14, length); + } + + private static HeadlessSessionDescriptor Descriptor() => new() + { + Id = "console-bot", + Endpoint = new HeadlessEndpointDescriptor + { + Host = "127.0.0.1", + Port = 9000, + }, + Account = "account", + Character = new HeadlessCharacterSelector + { + Name = "headless", + }, + Policy = new HeadlessBotPolicyDescriptor + { + Id = "idle", + }, + Credential = new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.Environment, + Reference = "CONSOLE_BOT_PASSWORD", + }, + }; + + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + public Action? GameActionCapture { get; init; } + + public CharacterList.Parsed? Characters { get; init; } = new( + 0u, + [ + new CharacterList.Character(0x50000001u, "Other", 0u), + new CharacterList.Character(0x50000002u, "Headless", 0u), + ], + [], + 11, + "account", + true, + true); + + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) + { + var session = new WorldSession(endpoint); + session.GameActionCapture = GameActionCapture; + return session; + } + + public void Connect(WorldSession session, string user, string password) + { + } + + public CharacterList.Parsed? GetCharacters(WorldSession session) => + Characters; + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) => session.Dispose(); + } + + /// + /// S2: wraps a real and records the managed + /// thread id every call actually ran on — the + /// background reader thread's own id, since only + /// ever calls it. + /// + private sealed class ThreadIdRecordingTextReader(TextReader inner) + : TextReader + { + internal int? ReadLineThreadId { get; private set; } + + public override string? ReadLine() + { + ReadLineThreadId = Environment.CurrentManagedThreadId; + return inner.ReadLine(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + inner.Dispose(); + base.Dispose(disposing); + } + } + + /// + /// S3/S6/S7: a real-clock , distinct from + /// , for a + /// integration test that runs the actual scheduler loop on its own + /// dedicated thread. Real elapsed time (not a manually-stepped fake) is + /// deliberate here: owns its + /// own background thread, and stepping a manual clock from the test + /// thread while that thread's scheduler loop waits on a + /// armed from the SAME provider would race the two + /// threads for no benefit — the default 15 ms turn period already makes + /// these tests fast. + /// + private sealed class FakeTimeProvider : TimeProvider + { + public override long GetTimestamp() => Stopwatch.GetTimestamp(); + + public override long TimestampFrequency => Stopwatch.Frequency; + } +} diff --git a/tests/AcDream.Runtime.Tests/Chat/ChatCommandRouterFeedbackRoutingTests.cs b/tests/AcDream.Runtime.Tests/Chat/ChatCommandRouterFeedbackRoutingTests.cs new file mode 100644 index 00000000..becd3ea2 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Chat/ChatCommandRouterFeedbackRoutingTests.cs @@ -0,0 +1,111 @@ +using AcDream.Core.Chat; +using AcDream.Runtime.Chat; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Chat; + +/// +/// Owner direction 2026-09-07 (verbatim): "Unknown commands like /vt or +/// stuff from plugins shall now go to the SpewBox. They should go to the +/// chatbox." Register row AD-124 records the deviation from retail's own +/// ClientLocal (0x1A) typing for exactly these two families. This file pins +/// the half of that change at the Runtime +/// layer — bound to a real +/// — since the existing router +/// coverage in AcDream.UI.Abstractions.Tests only exercises the +/// ChatVM feedback implementation. The plugin-text half is pinned at +/// the App layer (AppAutomationSurfaceTests.PostSystemMessage_RoutesToChatLog_NeverSpewBox), +/// since AppAutomationSurface is the App-layer production +/// implementation of IPluginChat. +/// +public sealed class ChatCommandRouterFeedbackRoutingTests +{ + [Fact] + public void DegeneratePrefix_UnknownCommandRefusal_RoutesToChatLog_NeverSpewBox() + { + // "/" alone (no letter verb) is the degenerate-prefix guard's + // "Unknown command: {verb}." refusal — retail itself types this + // 0x1A (ClientLocal / SpewBox-only); the owner override moves it to + // the chat scroll (Default/0x00) instead. + using var communication = new RuntimeCommunicationState(); + var feedback = new RuntimeChatCommandFeedback(communication); + + SubmitOutcome outcome = ChatCommandRouter.Submit( + "/", feedback, NullCommandBus.Instance, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + ChatEntry entry = Assert.Single(communication.Chat.Snapshot()); + Assert.Contains("Unknown command:", entry.Text); + Assert.Equal((uint)RetailLogTextType.Default, entry.LogTextType); + + communication.SpewBox.Tick(0d); + Assert.Equal(0, communication.SpewBox.Count); + } + + [Fact] + public void HelpUnresolvedVerb_UnknownCommandText_RoutesToChatLog_NeverSpewBox() + { + // "/help nonsenseverb" hits EmitVerbHelp's final unresolved-verb + // fallback (RetailCommandHelpTable.UnknownCommand), the exact + // existing retail-swept text — only the destination changes. + using var communication = new RuntimeCommunicationState(); + var feedback = new RuntimeChatCommandFeedback(communication); + + SubmitOutcome outcome = ChatCommandRouter.Submit( + "/help nonsenseverb", feedback, NullCommandBus.Instance, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + ChatEntry entry = Assert.Single(communication.Chat.Snapshot()); + Assert.Equal(RetailCommandHelpTable.UnknownCommand, entry.Text); + Assert.Equal((uint)RetailLogTextType.Default, entry.LogTextType); + + communication.SpewBox.Tick(0d); + Assert.Equal(0, communication.SpewBox.Count); + } + + [Fact] + public void HelpConfirmedNullVerb_UnknownCommandText_RoutesToChatLog_NeverSpewBox() + { + // "index" is one of the four catalog verbs retail registers with a + // genuinely NULL help pointer (RetailCommandHelpTable. + // CatalogVerbsWithNoRetailHelp) — EmitVerbHelp's OTHER "Unknown + // command" call site, distinct from the unresolved-verb fallback + // above. + using var communication = new RuntimeCommunicationState(); + var feedback = new RuntimeChatCommandFeedback(communication); + + SubmitOutcome outcome = ChatCommandRouter.Submit( + "/help index", feedback, NullCommandBus.Instance, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + ChatEntry entry = Assert.Single(communication.Chat.Snapshot()); + Assert.Equal(RetailCommandHelpTable.UnknownCommand, entry.Text); + Assert.Equal((uint)RetailLogTextType.Default, entry.LogTextType); + + communication.SpewBox.Tick(0d); + Assert.Equal(0, communication.SpewBox.Count); + } + + [Fact] + public void RealCommandBadArguments_StillRoutesToSpewBox_NeverChatLog() + { + // Boundary pin: AP-183's bad-argument refusals of REAL retail + // commands are UNCHANGED by the owner's 2026-09-07 direction, which + // named only unknown commands and plugin text. "/ls now" (Lifestone + // with bad args) must still land in the SpewBox exclusively. + using var communication = new RuntimeCommunicationState(); + var feedback = new RuntimeChatCommandFeedback(communication); + + SubmitOutcome outcome = ChatCommandRouter.Submit( + "/ls now", feedback, NullCommandBus.Instance, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Empty(communication.Chat.Snapshot()); + + communication.SpewBox.Tick(0d); + Assert.Equal(1, communication.SpewBox.Count); + Assert.Equal( + "Please see @help lifestone for more information on how to use this command.", + communication.SpewBox.Snapshot()[0].Text); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs index 15cb0cc5..770aae9c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs @@ -194,4 +194,36 @@ public sealed class ChatVMTests // The stored body never carries the stamp in either state. Assert.Equal("hi", log.Snapshot()[0].Text); } + + /// + /// Owner-directed override 2026-09-07 (register row AD-124): plugin + /// output (AppAutomationSurface.PostSystemMessage, the + /// production implementation of IPluginChat.PostSystemMessage) + /// now funnels into RuntimeCommunicationState.AddText(text, + /// RetailLogTextType.Default), which calls + /// Chat.OnSystemMessage(text, (uint)Default) — the exact call + /// this test performs directly on the shared , + /// matching Decal's own AddChatText behavior for plugin text. + /// Any bound to that log (the production chat + /// window) must show the line; it must never depend on the + /// SpewBox seam, which this call + /// never touches. + /// + [Fact] + public void RecentLines_ShowsPluginSystemMessage_TaggedDefault() + { + var log = new ChatLog(); + var vm = new ChatVM(log, displayLimit: 50); + + log.OnSystemMessage( + "MossTank: buffs applied.", + chatType: (uint)RetailLogTextType.Default); + + Assert.Equal( + "MossTank: buffs applied.", + Assert.Single(vm.RecentLines())); + Assert.Equal( + (uint)RetailLogTextType.Default, + Assert.Single(log.Snapshot()).LogTextType); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs index f9f50ad6..72cf6f1c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -406,28 +406,35 @@ public class ChatCommandRouterTests } [Fact] - public void HelpVerb_UnknownVerb_ShowsRetailUnknownCommandText_ViaInterfaceTextSeam() + public void HelpVerb_UnknownVerb_ShowsRetailUnknownCommandText_InChatLog_TaggedDefault() { // Campaign CH user-gate round 3 (2026-08-10): retail's own DoHelp // fallback text is "Unknown command" (swept verbatim), not an - // acdream-invented "No help available" message. Retail types this - // 0x1A (ClientLocal / SpewBox-only). Issue #363/#367: now routed - // through the interface-text seam as ONE entry (no HelpPrefixNote - // wrapper — DoHelp's fallback bypasses the two-entry shape - // entirely), not the chat scroll. + // acdream-invented "No help available" message. Retail itself types + // this 0x1A (ClientLocal / SpewBox-only). Owner-directed override + // 2026-09-07 (register row AD-124): "Unknown command" refusals now + // route to the CHAT SCROLL (ShowSystemMessage, Default/0x00) instead + // of the interface-text/SpewBox seam — the seam stays empty. var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); var outcome = ChatCommandRouter.Submit("/help nonsenseverb", vm, bus, ChatChannelKind.Say); Assert.Equal(SubmitOutcome.ClientHandled, outcome); Assert.Empty(bus.Published); - Assert.Equal(RetailCommandHelpTable.UnknownCommand, Assert.Single(interfaceTexts)); - Assert.Empty(log.Snapshot()); + Assert.Empty(interfaceTexts); + var entry = Assert.Single(log.Snapshot()); + Assert.Equal(RetailCommandHelpTable.UnknownCommand, entry.Text); + Assert.Equal((uint)RetailLogTextType.Default, entry.LogTextType); } [Fact] - public void HelpVerb_UnknownVerb_NoInterfaceSinkWired_FallsBackToChatLog_TaggedClientLocal() + public void HelpVerb_UnknownVerb_NoInterfaceSinkWired_StillRoutesToChatLog_TaggedDefault() { + // Owner-directed override 2026-09-07 (register row AD-124): + // ShowSystemMessage never depended on OnInterfaceText wiring in the + // first place, so headless / no-window hosts see the identical + // chat-log entry whether or not a sink is wired — unlike the old + // ShowInterfaceText null-fallback this test used to pin. var (vm, log, bus) = Fixture(); var outcome = ChatCommandRouter.Submit("/help nonsenseverb", vm, bus, ChatChannelKind.Say); @@ -435,7 +442,7 @@ public class ChatCommandRouterTests Assert.Equal(SubmitOutcome.ClientHandled, outcome); var entry = Assert.Single(log.Snapshot()); Assert.Equal(RetailCommandHelpTable.UnknownCommand, entry.Text); - Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType); + Assert.Equal((uint)RetailLogTextType.Default, entry.LogTextType); } [Fact] @@ -588,19 +595,23 @@ public class ChatCommandRouterTests } [Fact] - public void DegeneratePrefix_UnknownCommand_ShowsRefusal_ViaInterfaceTextSeam() + public void DegeneratePrefix_UnknownCommand_ShowsRefusal_InChatLog_TaggedDefault() { // "/" alone (no letter verb) — the pre-existing "Unknown command: - // {verb}." refusal, now also routed through the interface-text - // seam (issue #367). + // {verb}." refusal. Owner-directed override 2026-09-07 (register + // row AD-124): routed to the chat scroll (ShowSystemMessage, + // Default/0x00), NOT the interface-text/SpewBox seam issue #367 + // originally moved it to. var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); var outcome = ChatCommandRouter.Submit("/", vm, bus, ChatChannelKind.Say); Assert.Equal(SubmitOutcome.UnknownCommand, outcome); Assert.Empty(bus.Published); - Assert.Contains("Unknown command:", Assert.Single(interfaceTexts)); - Assert.Empty(log.Snapshot()); + Assert.Empty(interfaceTexts); + var entry = Assert.Single(log.Snapshot()); + Assert.Contains("Unknown command:", entry.Text); + Assert.Equal((uint)RetailLogTextType.Default, entry.LogTextType); } [Fact] diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs index 85c43d41..03f47abd 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs @@ -395,10 +395,13 @@ public sealed class RetailCommandHelpTableTests // assignment, unlike every extracted verb above -- confirming a // genuinely NULL help function pointer. Retail's own DoHelp skips // its help-callback branch entirely for these and falls to the - // SAME "Unknown command" 0x1A text an unregistered verb gets, even - // though the verb dispatches fine for ordinary (non-help) use. - // Showing the catalog's own invented summary here would be - // retail-inaccurate. + // SAME "Unknown command" text an unregistered verb gets (retail + // itself types it 0x1A), even though the verb dispatches fine for + // ordinary (non-help) use. Showing the catalog's own invented + // summary here would be retail-inaccurate. Owner-directed override + // 2026-09-07 (register row AD-124): acdream routes this "Unknown + // command" text to the CHAT SCROLL (Default/0x00) rather than + // retail's own SpewBox-only 0x1A typing. var log = new AcDream.Core.Chat.ChatLog(); var vm = new ChatVM(log, displayLimit: 50); var bus = new RecordingCommandBus(); @@ -411,7 +414,7 @@ public sealed class RetailCommandHelpTableTests Assert.Single(entries); Assert.Equal(RetailCommandHelpTable.UnknownCommand, entries[0].Text); Assert.Equal( - (uint)AcDream.Core.Chat.RetailLogTextType.ClientLocal, + (uint)AcDream.Core.Chat.RetailLogTextType.Default, entries[0].LogTextType); }