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