merge(vt): plain <menu> popup from the campaign branch into the slice-7 panel work (ledger union)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 10:54:25 +02:00
commit dbdde0783d
30 changed files with 2530 additions and 83 deletions

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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` | `=<path>` | 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` | `=<int>` | 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` | `=<int>` | 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` | `=<int>` (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 <path>` | The versioned headless session-configuration document. Required. | — |
| `--config-dir` / `--data-dir` / `--cache-dir` `<path>` | 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`

View file

@ -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.4S7.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-`<menu>`-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.4S7.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.4S7.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.4S7.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.

View file

@ -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 <path-to-a-one-session-config.json> `
-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 <one idle session, +Acdream> --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.

View file

@ -117,7 +117,18 @@ list boxes (owner live-client report, 2026-09-07), so a plugin `<menu>` 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 `<list>`'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),

View file

@ -1170,9 +1170,12 @@ internal sealed class AppAutomationSurface
}
/// <summary>
/// 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 <c>Default</c>/0x00),
/// matching Decal's own <c>AddChatText</c> 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.
/// </summary>
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)

View file

@ -160,6 +160,22 @@ public sealed class UiMenu : UiElement
private bool _draggingPopupThumb;
private float _popupThumbDragOffset;
/// <summary>Index into <see cref="Items"/> of the row under the pointer while
/// the plain popup is open, or -1. Presentation-only (see
/// <see cref="PlainHoverColor"/>'s doc) — retail's sprite popup has no
/// equivalent hover concept, so this never affects the retail draw path.</summary>
private int _hoveredPopupIndex = -1;
/// <summary>Test seam, same rationale as <see cref="CurrentFaceSpriteForTest"/>.</summary>
internal int HoveredPopupIndexForTest => _hoveredPopupIndex;
/// <summary>
/// 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 <see cref="RetailButtonArt"/> is false).
/// </summary>
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.</summary>
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.
/// <summary>The current entry's row fill — identical value to
/// <see cref="UiMarkupList.SelectedColor"/> so a plugin's open dropdown
/// reads as the same widget family as its lists.</summary>
public Vector4 PlainSelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
/// <summary>A slightly lighter fill for the row under the pointer (no
/// separate glyph or sprite swap — fills only, mirroring
/// <see cref="PlainOpenBorderColor"/>'s "tint, never a sprite swap" rule
/// for the closed state).</summary>
public Vector4 PlainHoverColor { get; set; } = new(0.40f, 0.33f, 0.14f, 0.95f);
private bool _open;
/// <summary>
@ -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.</summary>
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.
/// <summary>Plain counterpart of <see cref="DrawGridPopup"/> — flat fill +
/// 1px outline instead of the bevel/panel sprites, per-row selected/hover
/// fills instead of highlight sprites, <see cref="PlainTextColor"/>/
/// <see cref="TextColorGhosted"/> labels left-aligned at
/// <see cref="PlainPadding"/> instead of the authored <see cref="TextIndent"/>/
/// <see cref="ItemTextCentered"/> 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).</summary>
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);
}
}
/// <summary>Plain counterpart of <see cref="DrawScrollablePopup"/> — same
/// <see cref="VisibleTopRow"/>-sliced single column, plain
/// selected/hover row fills, and a plain scrollbar
/// (<see cref="DrawPopupScrollbarPlain"/>) instead of the sprite chrome.</summary>
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);
}
/// <summary>
/// Plain counterpart of <see cref="DrawPopupScrollbar"/>: a 1px-bordered
/// track and a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
/// thumb/track/arrow-button art at all. Shares the exact same
/// <see cref="UiScrollbar.ThumbRect"/> geometry (so the thumb's drawn
/// position matches <see cref="HandleScrollablePopupMouseDown"/>'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
/// (<see cref="HandleScrollablePopupMouseDown"/> is unchanged).
/// </summary>
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);
}
/// <summary>
/// Recomputes the hovered popup row from a MouseMove's local (lx,ly) —
/// same convention <see cref="OnEvent"/>'s MouseDown handling already uses
/// (<see cref="PopupTop"/>/<see cref="Border"/>-relative). Plain-mode-only:
/// see <see cref="ReceivesHoverMouseMove"/>'s doc comment for why this is
/// never invoked on the retail sprite-popup path.
/// </summary>
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;
}
/// <summary>Draw the universal 8-piece retail window bevel (corners + tiled edges +
/// tiled centre fill) framing the rect (<paramref name="x"/>,<paramref name="y"/>,
/// <paramref name="w"/>,<paramref name="h"/>). 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;
}

View file

@ -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<string> 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)

View file

@ -0,0 +1,53 @@
namespace AcDream.Headless.Configuration;
/// <summary>
/// Typed resolution for the headless interactive console (docs/plans/
/// 2026-09-07-headless-console.md). Three inputs, first match wins:
/// the <c>--console</c> command-line flag, the
/// <c>ACDREAM_HEADLESS_CONSOLE</c> 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
/// <c>ReadLine</c> would never see input and would just sit idle). See
/// docs/launch-options.md for the documented row this owns.
/// </summary>
/// <remarks>
/// 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 —
/// <c>ACDREAM_HEADLESS_CONSOLE=0</c> must disable the console even when
/// stdin is a real terminal, matching the
/// <c>ACDREAM_RETAIL_CLOSE_DEGRADES</c> / <c>ACDREAM_RETAIL_UI</c>
/// convention (any value other than the literal string <c>"0"</c> 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.
/// </remarks>
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<string, string?> 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);
}
}

View file

@ -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();

View file

@ -0,0 +1,58 @@
using AcDream.Core.Chat;
using AcDream.Runtime;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Presentation for the console's rendered chat lines. A distinct, terminal-
/// shaped format from the graphical <c>ChatVM.FormatEntry</c> retail prose
/// (Headless cannot reference <c>AcDream.UI.Abstractions</c> — 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 (<see cref="RuntimeChatEntry.ChannelName"/>, "Tell", "Local") per
/// the plan's requirement, just not the same sentence template.
/// </summary>
internal static class HeadlessConsoleChatFormatter
{
/// <summary>Formats one chat event for the console, or
/// <see langword="null"/> when this kind renders nothing (there are
/// none today — kept for forward compatibility with a future silent
/// kind).</summary>
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;
}

View file

@ -0,0 +1,116 @@
using AcDream.Runtime.Chat;
namespace AcDream.Headless.Hosting;
/// <summary>
/// The console's own orchestration: owns the background reader
/// (<see cref="HeadlessConsoleInputReader"/>) and, once per session tick
/// (<see cref="DrainDue"/>), 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
/// <see cref="HeadlessConsoleInputReader"/>'s own doc).
/// </summary>
/// <remarks>
/// <c>/quit</c> and <c>/status</c> are console-only controls (the plan's
/// "Control" section) — they never reach <see cref="ChatCommandRouter"/>,
/// matching retail's own client-local commands. Every other line goes
/// through <paramref name="submit"/>, which a production caller binds to
/// <c>HeadlessSessionHost.SubmitConsoleLine</c> — the exact
/// <see cref="ChatCommandRouter.Submit"/> pipeline (retail's client-command
/// catalog first, then local <c>/help</c>, then the plugin-verb registry,
/// then the retail unregistered-channel-tag fallback, then an explicit
/// server command, then plain chat) <c>LoginCommandSequence</c> and the
/// graphical chat box both already use.
/// </remarks>
internal sealed class HeadlessConsoleController : IDisposable
{
private readonly HeadlessConsoleInputReader _reader;
private readonly TextWriter _output;
private readonly Func<string, SubmitOutcome> _submit;
private readonly Func<string> _statusText;
private readonly CancellationTokenSource _quitRequested;
internal HeadlessConsoleController(
TextReader input,
TextWriter output,
Func<string, SubmitOutcome> submit,
Func<string> 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);
}
/// <summary>Number of lines handled by the most recent
/// <see cref="DrainDue"/> call — a test seam for the reader-thread
/// ordering assertion.</summary>
internal int LastDrainCount { get; private set; }
/// <summary>Test seam: lets a bounded-fixture test wait for the
/// background reader thread to reach EOF before calling
/// <see cref="DrainDue"/>, instead of sleeping or polling.</summary>
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();
}

View file

@ -0,0 +1,94 @@
using System.Collections.Concurrent;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Reads lines from a <see cref="TextReader"/> on one dedicated background
/// thread and hands them to whoever drains <see cref="TryDequeue"/>. 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 <see cref="TryDequeue"/>.
/// </summary>
/// <remarks>
/// <see cref="TextReader.ReadLine"/> has no cancellable overload, so a real
/// <c>Console.In</c> 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 <see cref="Dispose"/> 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; <see cref="EndOfInput"/> lets a test
/// wait for that deterministically instead of polling or sleeping.
/// </remarks>
internal sealed class HeadlessConsoleInputReader : IDisposable
{
private readonly TextReader _input;
private readonly ConcurrentQueue<string> _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();
}
/// <summary>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.
/// </summary>
internal ManualResetEventSlim EndOfInput { get; } = new(initialState: false);
/// <summary>Dequeues the next queued line in FIFO order, or returns
/// <see langword="false"/> if none is queued yet. Never blocks.</summary>
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();
}
}
/// <summary>Requests the read loop stop at its next opportunity. Does
/// not abort a <see cref="TextReader.ReadLine"/> already in progress —
/// the thread is background, so it cannot block process exit.
/// Deliberately does NOT dispose <see cref="EndOfInput"/>: the read
/// loop's own <c>finally</c> sets it from the reader thread, and racing
/// that against a Dispose() here (an unhandled
/// <see cref="ObjectDisposedException"/> on a background thread
/// terminates the process) is worse than leaking one small
/// synchronization handle for the process's remaining lifetime.
/// </summary>
public void Dispose()
{
_stopRequested = true;
}
}

View file

@ -0,0 +1,111 @@
using AcDream.Runtime;
namespace AcDream.Headless.Hosting;
/// <summary>
/// One presentation over the K2 bot event stream
/// (<see cref="IRuntimeEventObserver"/>) — the SAME typed events a headless
/// bot policy observes (<c>HeadlessBotPolicy.cs</c>) — rendered as plain
/// lines. Every write goes through <see cref="WriteLine"/>, so a test can
/// assert on exactly what a real console would have printed without a
/// terminal.
/// </summary>
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);
}
/// <summary>
/// Retail's transient "interface text" (SpewBox, <c>ClientLocal</c>
/// type) never touches <see cref="RuntimeCommunicationState.Chat"/> —
/// see <c>RuntimeCommunicationState.AddText</c> — so it never reaches
/// <see cref="OnChat"/>. <c>HeadlessConsoleSpewBoxPump</c> calls this
/// directly, once per console tick, for whatever text is newly visible
/// in the polled <see cref="AcDream.Core.Chat.SpewBoxState"/> — 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.
/// </summary>
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)
{
}
/// <summary>
/// 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.
/// </summary>
private void WriteLine(string text, bool dim)
{
_output.WriteLine(_useColor && dim ? Dim + text + Reset : text);
_output.Flush();
}
}

View file

@ -0,0 +1,63 @@
using AcDream.Core.Chat;
namespace AcDream.Headless.Hosting;
/// <summary>
/// S5 (2026-09-07 review round, docs/plans/2026-09-07-headless-console.md):
/// polls <see cref="SpewBoxState"/> on the console's own per-tick pump — the
/// SAME seam <c>AcDream.App.UI.SpewBoxController.Tick</c> drives for the
/// graphical overlay. Retail's transient "interface text"
/// (<see cref="RetailLogTextType.ClientLocal"/>, routed by
/// <c>RuntimeCommunicationState.AddText</c>) never touches
/// <c>RuntimeCommunicationState.Chat</c>/<c>RuntimeChatDelta</c>, 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
/// <c>HeadlessConsoleChatFeedback</c> decorator, which only ever saw text
/// produced by the console's own <c>SubmitConsoleLine</c> calls.
/// </summary>
internal sealed class HeadlessConsoleSpewBoxPump
{
private readonly SpewBoxState _spewBox;
private readonly Func<double> _nowSeconds;
private readonly Action<string> _writeInterfaceText;
private SpewBoxEntry[] _lastSeen = [];
internal HeadlessConsoleSpewBoxPump(
SpewBoxState spewBox,
Func<double> nowSeconds,
Action<string> writeInterfaceText)
{
_spewBox = spewBox ?? throw new ArgumentNullException(nameof(spewBox));
_nowSeconds = nowSeconds
?? throw new ArgumentNullException(nameof(nowSeconds));
_writeInterfaceText = writeInterfaceText
?? throw new ArgumentNullException(nameof(writeInterfaceText));
}
/// <summary>
/// Drains any pending SpewBox text into the visible set (exactly
/// <see cref="SpewBoxState.Tick"/>'s contract — the same drain
/// <c>SpewBoxVM.Lines</c> performs for the graphical overlay) and prints
/// any entry that was not part of the previous call's visible snapshot.
/// </summary>
/// <remarks>
/// <see cref="SpewBoxState.Snapshot"/> is newest-first
/// (retail's <c>InsertItem(item, 0)</c>); this walks it back-to-front so
/// newly-visible entries print in the order they were actually
/// enqueued, not newest-first.
/// </remarks>
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;
}
}

View file

@ -15,6 +15,16 @@ internal sealed class HeadlessProcessHost : IDisposable
private readonly HeadlessDiagnosticWriter _diagnostics;
private readonly HeadlessProcessContentOwner? _content;
private readonly HeadlessProcessResourceSampler _resources;
/// <summary>
/// Headless console (docs/plans/2026-09-07-headless-console.md): always
/// created, cancelled only by <c>/quit</c> — linking it into the
/// scheduler's run token below costs nothing when the console is
/// disabled (it simply never fires) and keeps <see cref="RunOnUpdateThread"/>
/// free of a console-shaped branch.
/// </summary>
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
}
}
/// <summary>
/// <c>/status</c>: 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").
/// </summary>
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();

View file

@ -183,6 +183,25 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly IHeadlessBotPolicy _policy;
private readonly IDisposable _policySubscription;
private readonly HeadlessPluginSession _pluginSession;
/// <summary>
/// Headless console (docs/plans/2026-09-07-headless-console.md): the
/// SAME plugin-verb registry <see cref="_chatCommandSurface"/>'s bus
/// forwards to (via <c>TryHandlePluginCommand</c>) and
/// <see cref="HeadlessPluginSession.Create"/> 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 <see cref="SubmitConsoleLine"/> /
/// <see cref="LoginCommandSequence"/>, never this field.
/// </summary>
private readonly AcDream.Core.Plugins.PluginCommandRegistry _pluginCommands;
/// <summary>
/// Headless console: the SAME retained bus <c>LoginCommandSequence</c>
/// submits through — see <see cref="SubmitConsoleLine"/>. One instance
/// for the host's whole lifetime; <see cref="CreateEventRoute"/>
/// attaches/detaches a fresh <see cref="LiveChatCommandRoute"/> to it on
/// every (re)connect, exactly as it does today for login commands.
/// </summary>
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
/// </summary>
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
internal HeadlessPluginSession Plugins => _pluginSession;
/// <summary>Test seam (mirrors <see cref="OptionsSeeder"/>'s own
/// pattern): registers a plugin verb directly against the SAME registry
/// a real loaded plugin would use, without loading a plugin assembly.
/// </summary>
internal AcDream.Core.Plugins.PluginCommandRegistry PluginCommands =>
_pluginCommands;
internal string SessionId => _descriptor.Id;
/// <summary>
/// Headless console: invoked at the end of every <see cref="Tick"/> so
/// console input drains ON the session tick, in order, never on the
/// reader thread. <see langword="null"/> (every non-console host) costs
/// nothing extra per tick.
/// </summary>
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;
}
/// <summary>
/// The headless console's ONE entry point for a typed line — the exact
/// pipeline <see cref="LoginCommandSequence"/> already submits through:
/// <see cref="ChatCommandRouter.Submit"/> against this host's retained
/// <see cref="_chatCommandSurface"/>. Dispatch order (matching
/// <see cref="ChatCommandRouter"/>'s own class doc): retail's client-
/// command catalog first, then the local <c>/help</c> 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 cref="AcDream.Runtime.RuntimeChatDelta"/>, see
/// <c>RuntimeCommunicationState.AddText</c>'s <c>ClientLocal</c> branch)
/// lands in the shared <see cref="RuntimeCommunicationState.SpewBox"/>
/// exactly like every other producer of that text; the console's own
/// per-tick pump polls it (see <c>HeadlessConsoleSpewBoxPump</c>)
/// instead of this call decorating its own feedback.
/// </summary>
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")

View file

@ -27,7 +27,9 @@ try
Console.In,
Console.Out,
Console.Error,
cancellation.Token);
cancellation.Token,
standardInputIsTerminal: !Console.IsInputRedirected,
standardOutputIsTerminal: !Console.IsOutputRedirected);
}
finally
{

View file

@ -302,10 +302,20 @@ public interface IPluginChat
Array.Empty<PluginChatMessage>();
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Owner direction 2026-09-07 (register row AD-124): this used to route
/// through retail's <c>ClientLocal</c> (0x1A) channel — the SpewBox
/// overlay every <c>ChatInterface</c> window's default filter excludes.
/// The owner explicitly overrode that for plugin text, matching Decal's
/// own <c>AddChatText</c> behavior: plugin output now lands in the chat
/// transcript (retail <c>Default</c>/0x00) so it is actually visible and
/// scrolls back, never the transient overlay. See
/// <c>AppAutomationSurface.PostSystemMessage</c> for the implementation.
/// </remarks>
void PostSystemMessage(string text);
/// <summary>

View file

@ -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)

View file

@ -210,15 +210,29 @@ namespace AcDream.Runtime.Chat;
/// </para>
///
/// <para>
/// <b>Issue #363 (2026-08-10):</b> <c>ChatCommandRouter</c> now routes this
/// <b>Issue #363 (2026-08-10):</b> <c>ChatCommandRouter</c> routed this
/// fallback (and every other <c>0x1A</c> command-refusal call site) through
/// <c>IChatCommandFeedback.ShowInterfaceText</c> — an optional hook the host
/// wires to <c>RuntimeCommunicationState.AddText</c>, the same SpewBox
/// chokepoint every other producer of interface text uses. The retained
/// <c>ChatVM</c> 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.
/// </para>
///
/// <para>
/// <b>Owner-directed override 2026-09-07 (register row AD-124):</b> the
/// paragraph above still describes retail's own behavior faithfully, but
/// acdream no longer matches it for exactly this <see cref="UnknownCommand"/>
/// text (both its call sites in <c>ChatCommandRouter.EmitVerbHelp</c>) and
/// the sibling "Unknown command: {verb}." refusal in
/// <c>ChatCommandRouter.Submit</c>'s own body: those three sites now call
/// <c>IChatCommandFeedback.ShowSystemMessage</c> (the chat scroll, retail
/// <c>Default</c>/0x00) instead of <c>ShowInterfaceText</c> (SpewBox), so an
/// unknown command is actually visible and stays in the transcript. Every
/// OTHER <c>0x1A</c> refusal this class documents (bad-args, AP-183) is
/// unchanged and still SpewBox-only.
/// </para>
/// </summary>
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

View file

@ -107,10 +107,14 @@ public sealed class LaunchOptionsDocumentationTests
}
/// <summary>
/// The five flags that default ON. All are product/retail behaviors wearing an
/// A/B off-switch (<c>=0</c> 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 (<c>=0</c> disables) — none is a diagnostic.
/// <c>ACDREAM_HEADLESS_CONSOLE</c> (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 <c>=0</c>-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.
/// </summary>
private static readonly IReadOnlySet<string> DefaultOnBehaviorFlags =
new HashSet<string>(StringComparer.Ordinal)
@ -120,6 +124,7 @@ public sealed class LaunchOptionsDocumentationTests
"ACDREAM_CAMERA_ALIGN_SLOPE",
"ACDREAM_RETAIL_CLOSE_DEGRADES",
"ACDREAM_RETAIL_UI",
"ACDREAM_HEADLESS_CONSOLE",
};
/// <summary>
@ -134,7 +139,7 @@ public sealed class LaunchOptionsDocumentationTests
RegexOptions.Compiled);
[Fact]
public void OnlyTheFiveProductBehaviorFlagsDefaultOn()
public void OnlyTheSixProductBehaviorFlagsDefaultOn()
{
var defaultOn = new HashSet<string>(StringComparer.Ordinal);
foreach ((string path, _) in SourceFiles())

View file

@ -146,6 +146,31 @@ public sealed class AppAutomationSurfaceTests
Assert.Equal(0, second.CommunicationOwner.SubscriberCount);
}
/// <summary>
/// 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
/// <c>AddChatText</c> uses.
/// </summary>
[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()
{

View file

@ -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<int>? 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<float> 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;
}
/// <summary>Opens the popup (MouseDown on the closed face) then, if given,
/// hovers a row via MouseMove — the same (Data1,Data2) local-coordinate
/// convention <see cref="UiMenu.OnEvent"/> already uses for MouseDown.</summary>
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
}
}

View file

@ -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;
/// <summary>
/// docs/plans/2026-09-07-headless-console.md — the headless interactive
/// console. Two layers: <see cref="HeadlessConsoleInputReader"/> /
/// <see cref="HeadlessConsoleController"/> tested in isolation (no live
/// server, no <see cref="HeadlessSessionHost"/>), then
/// <see cref="HeadlessSessionHost.SubmitConsoleLine"/> tested against a real
/// host wired to <see cref="FixtureSessionOperations"/> — the same
/// no-network fixture pattern <c>HeadlessSessionHostTests</c> already uses
/// for <c>LoginCommandSequence</c>, proving the console reuses the EXACT
/// same pipeline rather than a second parser.
/// </summary>
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);
}
/// <summary>
/// N3: validate mode rejects <c>--console</c> outright (rather than
/// silently ignoring it) — validate never starts a session, so there is
/// nothing for the console to attach to.
/// </summary>
[Fact]
public void ValidateModeRejectsTheConsoleFlag()
{
Assert.Throws<HeadlessCommandLineException>(() =>
HeadlessCommandLine.Parse(
["validate", "--config", "bot.json", "--console"]));
}
// ── HeadlessConsoleInputReader: reader-thread/ordering ───────────────
/// <summary>
/// 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
/// <c>ConcurrentQueue.Enqueue</c> — there is no dispatch code it could
/// execute — so this also structurally proves "never executed on the
/// reader thread," not just orders the output.
/// </summary>
[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<string>();
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);
}
/// <summary>
/// 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 <see cref="TextReader.ReadLine"/> ran on
/// (via <see cref="ThreadIdRecordingTextReader"/>) 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
/// <see cref="HeadlessConsoleController.DrainDue"/>.
/// </summary>
[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<string>();
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<string>();
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<string>();
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());
}
/// <summary>
/// S4: <see cref="SubmitOutcome.UnknownCommand"/> and
/// <see cref="SubmitOutcome.Dropped"/> get a visible console line —
/// matching <c>LoginCommandSequence.DrainDue</c>'s own reporting for the
/// same two outcomes — instead of silently doing nothing.
/// </summary>
[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());
}
/// <summary>
/// S4: a throwing submit callback (a console typo hitting a downstream
/// bug in a plugin verb handler, say) never escapes <c>DrainDue</c> —
/// it must never reach the scheduler's per-session quarantine catch and
/// fault the whole session over one bad console line. Mirrors
/// <c>LoginCommandSequence.DrainDue</c>'s own try/catch.
/// </summary>
[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 ─────────────────────
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// N5: lifecycle, command, and portal lines are scheduling/session-
/// status noise, not player-visible content — dimmed when color is
/// enabled.
/// </summary>
[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<byte[]>();
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<byte[]>();
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<byte[]>();
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<PluginCommand>();
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);
}
/// <summary>
/// S5 rework: <c>SubmitConsoleLine</c> no longer takes a per-call
/// interface-text callback — retail's transient interface text
/// (<c>ClientLocal</c>) lands in the shared
/// <see cref="AcDream.Core.Chat.SpewBoxState"/> exactly like every other
/// producer of that text (see <c>RuntimeCommunicationState.AddText</c>),
/// and the console's own per-tick pump polls it — proven directly here
/// against the real <see cref="AcDream.Core.Chat.SpewBoxState"/> rather
/// than a decorator only this call site could see.
/// </summary>
[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 ──
/// <summary>
/// S5: the pump must surface interface text that never went through the
/// console at all — a stand-in for a server- or plugin-driven
/// <c>ClientLocal</c> write reaching <c>RuntimeCommunicationState.AddText</c>
/// directly, exactly the case the deleted per-call
/// <c>HeadlessConsoleChatFeedback</c> decorator could never see (it only
/// ever wrapped THIS console's own <c>SubmitConsoleLine</c> feedback).
/// </summary>
[Fact]
public void PumpPrintsInterfaceTextNotOriginatingFromTheConsole()
{
var spewBox = new SpewBoxState();
var printed = new List<string>();
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 ───────────────────
/// <summary>
/// S3: an end-to-end proof that a console line, read from a plain
/// <see cref="StringReader"/>, reaches the real session's
/// <c>SubmitConsoleLine</c> pipeline through the actual
/// <see cref="HeadlessProcessHost"/> wiring (background reader thread →
/// per-tick <c>ConsolePump</c> → <c>ChatCommandRouter.Submit</c> → the
/// wire), and that <c>/quit</c> ends <see cref="HeadlessProcessHost.RunAsync"/>
/// through the SAME graceful path an external cancellation takes —
/// <see cref="HeadlessExitCode.Success"/>, not an error code.
/// </summary>
[Fact]
public async Task ConsoleLineReachesTheSessionAndQuitEndsTheProcessGracefully()
{
var captured = new List<byte[]>();
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));
}
/// <summary>
/// S6: <c>standardOutputIsTerminal</c> is threaded in as a constructor
/// parameter, not read from the real <c>System.Console</c> inside
/// <see cref="HeadlessProcessHost"/> — 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.
/// </summary>
[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"));
}
/// <summary>
/// S7: a multi-session process with <c>--console</c> 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 <c>HeadlessDiagnosticWriter.Message</c>'s "console"
/// category.
/// </summary>
[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<byte[]>? 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();
}
/// <summary>
/// S2: wraps a real <see cref="TextReader"/> and records the managed
/// thread id every <see cref="ReadLine"/> call actually ran on — the
/// background reader thread's own id, since only
/// <see cref="HeadlessConsoleInputReader"/> ever calls it.
/// </summary>
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);
}
}
/// <summary>
/// S3/S6/S7: a real-clock <see cref="TimeProvider"/>, distinct from
/// <see cref="TimeProvider.System"/>, for a <see cref="HeadlessProcessHost"/>
/// integration test that runs the actual scheduler loop on its own
/// dedicated thread. Real elapsed time (not a manually-stepped fake) is
/// deliberate here: <see cref="HeadlessProcessHost.RunAsync"/> owns its
/// own background thread, and stepping a manual clock from the test
/// thread while that thread's scheduler loop waits on a
/// <see cref="ITimer"/> armed from the SAME provider would race the two
/// threads for no benefit — the default 15 ms turn period already makes
/// these tests fast.
/// </summary>
private sealed class FakeTimeProvider : TimeProvider
{
public override long GetTimestamp() => Stopwatch.GetTimestamp();
public override long TimestampFrequency => Stopwatch.Frequency;
}
}

View file

@ -0,0 +1,111 @@
using AcDream.Core.Chat;
using AcDream.Runtime.Chat;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Chat;
/// <summary>
/// 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 <see cref="ChatCommandRouter"/> half of that change at the Runtime
/// layer — <see cref="RuntimeChatCommandFeedback"/> bound to a real
/// <see cref="RuntimeCommunicationState"/> — since the existing router
/// coverage in <c>AcDream.UI.Abstractions.Tests</c> only exercises the
/// <c>ChatVM</c> feedback implementation. The plugin-text half is pinned at
/// the App layer (<c>AppAutomationSurfaceTests.PostSystemMessage_RoutesToChatLog_NeverSpewBox</c>),
/// since <c>AppAutomationSurface</c> is the App-layer production
/// implementation of <c>IPluginChat</c>.
/// </summary>
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);
}
}

View file

@ -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);
}
/// <summary>
/// Owner-directed override 2026-09-07 (register row AD-124): plugin
/// output (<c>AppAutomationSurface.PostSystemMessage</c>, the
/// production implementation of <c>IPluginChat.PostSystemMessage</c>)
/// now funnels into <c>RuntimeCommunicationState.AddText(text,
/// RetailLogTextType.Default)</c>, which calls
/// <c>Chat.OnSystemMessage(text, (uint)Default)</c> — the exact call
/// this test performs directly on the shared <see cref="ChatLog"/>,
/// matching Decal's own <c>AddChatText</c> behavior for plugin text.
/// Any <see cref="ChatVM"/> bound to that log (the production chat
/// window) must show the line; it must never depend on the
/// <see cref="ChatVM.OnInterfaceText"/> SpewBox seam, which this call
/// never touches.
/// </summary>
[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);
}
}

View file

@ -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]

View file

@ -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);
}