fix(chat): consolidated-review fixes — retail /help Detail extraction, seam wiring test

SHOULD-FIX 1: RetailClientCommandCatalog's ~45 catalog leaf verbs were
showing acdream-authored Summary text for /help <verb> instead of
retail's own Detail_HelpType(2) text. Byte-swept every Help* handler
against the PDB-paired acclient.exe (verified MATCH), confirmed each
Detail/Summary branch by reading the actual decompiled if/else shape
(address order and string length both proved unreliable alone), and
fixed a sweep_weenie_strings.py 800-char truncation bug that silently
dropped several longer Detail branches. Resolved every ambiguous
CmdHashData-registered verb (hor/hr/hom/hoa/alh/ah/friends_add/
friends_remove/squelch/unsquelch) by reading for Binary Ninja's
nullptr-4th-arg decompiler artifact instead of trusting it. Coverage:
42 of 47 distinct catalog Definitions verbatim-extracted, 4
confirmed-null (index/clist/on/off register with a genuinely null help
pointer — DoHelp falls to UnknownCommand for these, now reproduced),
1 honest UNVERIFIED (messagetypes builds its text from a runtime enum
table, not a static string). ChatCommandRouter now prefers retail
Detail text over the catalog summary; RetailCommandHelpTable's class
doc no longer overclaims its own scope.

SHOULD-FIX 2: extracted the a5a7eb4f-class OnInterfaceText wiring into
a testable CreateChatViewModel method and added
ComposedChatViewModelWiresOnInterfaceTextToSpewBox, which the prior
FakeFactory-based test suite could never exercise.

SHOULD-FIX 3: retires register row AP-113. DoLifestone/DoMarketplace
print their own 0x1A refusal text (byte-recovered, UTF-16LE) instead
of falling through to the generic 0x26 fallback; ChatCommandRouter's
comment corrected to state the fallback's real scope.

SHOULD-FIX 4: corrected the divergence register's stale AP section
header sentence about AP-190's opacity default (refuted by cc582899).

NITs: (a) HeadlessStaticStateAudit routes through the injected
HeadlessDiagnosticWriter instead of Console.WriteLine; (b) a bounded
300-pump liveness diagnostic on the IsQuiescent conductor gate (no
retry, no behavior change); (c) fixed the #365 hydration test's doc
comment contradiction against diagnosis §8; (d) the 0x26 fallback
dispatches on WeenieErrorMessages' own Type instead of hardcoding
ClientLocal.

Full Release suite: 12,553 passed / 4 skipped / 0 failed (baseline
03404b71: 12,542/4/0; net +11 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 16:22:36 +02:00
parent 03404b7121
commit f7a6f46ba0
15 changed files with 947 additions and 85 deletions

View file

@ -13,6 +13,7 @@ using AcDream.Core.Items;
using AcDream.Core.Spells;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.Tests.Composition;
@ -149,6 +150,43 @@ public sealed class InteractionRetainedUiCompositionTests
], fixture.Factory.Releases);
}
/// <summary>
/// Consolidated-review round (2026-08-10), SHOULD-FIX 2: the a5a7eb4f
/// defect class — a composed <see cref="ChatVM.OnInterfaceText"/> hook
/// wired but never transferred to production — had no test, because
/// <see cref="FakeFactory.CreateRetainedUi"/> above substitutes a
/// <see cref="Stub{T}"/> <see cref="ChatVM"/> for the composition-order
/// tests and never exercises the REAL wiring in
/// <see cref="RetailInteractionRetainedUiCompositionFactory.CreateChatViewModel"/>.
/// This test calls that production method directly (it only touches
/// <see cref="InteractionRetainedUiDependencies.Communication"/>, no
/// GPU/dat/UiHost dependencies, so the existing null-heavy
/// <see cref="Fixture"/> dependencies are sufficient without a full
/// <see cref="Fixture.Compose"/>) and proves the hook both EXISTS and
/// actually reaches <see cref="RuntimeCommunicationState.SpewBox"/>,
/// not merely that it was assigned.
/// </summary>
[Fact]
public void ComposedChatViewModelWiresOnInterfaceTextToSpewBox()
{
using var fixture = new Fixture(retailUi: true);
ChatVM chat = RetailInteractionRetainedUiCompositionFactory
.CreateChatViewModel(fixture.Dependencies);
Assert.NotNull(chat.OnInterfaceText);
const string probeText = "consolidated-review SHOULD-FIX 2 probe";
chat.ShowInterfaceText(probeText);
// ShowInterfaceText only enqueues; SpewBoxState decouples enqueue
// from visibility (see its own class remarks) -- Tick drains it.
fixture.Dependencies.Runtime.CommunicationOwner.SpewBox.Tick(0);
Assert.Contains(
fixture.Dependencies.Runtime.CommunicationOwner.SpewBox.Snapshot(),
entry => entry.Text == probeText);
}
[Fact]
public void GameWindowUsesPhaseAndContainsNoRetainedUiConstructionBody()
{

View file

@ -1558,14 +1558,32 @@ public sealed class HeadlessSessionHostTests
/// a DIFFERENT landblock, the same shape as one of the OTHER eight
/// landblocks in production's real 3x3 publication plan — proving Step
/// 3a's gate covers "an admission is open ANYWHERE in the plan", not
/// just the center. Must FAIL on the pre-Step-3a tree — verified by
/// temporarily reverting the three <c>IsQuiescent</c> gates in
/// <c>HeadlessSessionWorldProjection.ProjectSpawn</c>/
/// <c>ProjectPosition</c>/<c>PumpFirstEntry</c> and confirming this test
/// fails (the conductor reaches <c>PublicationCommitted</c> — a non-null
/// dormant controller — while the unrelated admission is still held
/// open, since driving is unconditional pre-fix).
/// just the center.
/// </summary>
/// <remarks>
/// Consolidated-review round (2026-08-10), NIT (c): what THIS bounded
/// xunit test proves is the GATE, not the #365 hydration stall's
/// closure — that broader claim's evidence is the separate live-ACE
/// end-to-end run recorded in
/// <c>docs/research/2026-08-10-365-headless-hydration-diagnosis.md</c>
/// §8 ("hydration now succeeds ... entityCount reaches 136"). This
/// test's own scope is narrower and fully mechanical: the conductor
/// stays undriven (<c>runtime.MovementOwner.Controller</c> stays null)
/// for as long as the unrelated admission is held open, and drives and
/// reaches <c>IsRuntimePublished</c> within a bounded tick budget once
/// it releases. Temporarily reverting the three <c>IsQuiescent</c>
/// gates in <c>HeadlessSessionWorldProjection.ProjectSpawn</c>/
/// <c>ProjectPosition</c>/<c>PumpFirstEntry</c> (never committed — see
/// §8's "Verification that the new test... actually discriminates")
/// makes this test fail the SAME way §8 itself observed: the
/// <c>Assert.Null(runtime.MovementOwner.Controller)</c> checks below
/// fail because the controller is ALREADY built AND published
/// (<c>CanExecuteLiveMovement = True</c>) while the unrelated admission
/// is still open — not merely "reached <c>PublicationCommitted</c>,
/// still dormant" as an earlier draft of this comment claimed; that
/// wording described a weaker intermediate state than what reverting
/// the gate actually produces.
/// </remarks>
[Fact]
public void RealAdmissionNeverDrivesTheConductorWhileOpenAndHydratesOnceReleased()
{

View file

@ -1,5 +1,6 @@
using AcDream.Core.Physics;
using AcDream.Headless.Configuration;
using AcDream.Headless.Diagnostics;
using AcDream.Headless.Hosting;
namespace AcDream.Headless.Tests;
@ -8,9 +9,13 @@ namespace AcDream.Headless.Tests;
/// #365 Step 1: the audit's refusal rationale is multi-root attribution
/// ambiguity, which does not hold for a process that owns exactly one
/// session. These tests mutate <see cref="PhysicsDiagnostics"/> process-
/// global probe flags and swap <see cref="Console.Out"/>, so they run in
/// their own non-parallel collection — see
/// <see cref="HeadlessStaticStateAuditCollection"/>.
/// global probe flags, so they run in their own non-parallel collection —
/// see <see cref="HeadlessStaticStateAuditCollection"/>. Consolidated-
/// review round (2026-08-10), NIT (a): the single-session log line now
/// routes through the injected <see cref="HeadlessDiagnosticWriter"/>
/// instead of <see cref="Console.Out"/>, so these tests capture its
/// backing <see cref="TextWriter"/> directly rather than swapping the
/// process-global console.
/// </summary>
[CollectionDefinition(
HeadlessStaticStateAuditCollection.Name,
@ -31,17 +36,11 @@ public sealed class HeadlessStaticStateAuditTests : IDisposable
public void SingleSessionWithProbeEnabledIsAllowedAndLoggedLoudly()
{
PhysicsDiagnostics.ProbeParkEnabled = true;
var originalOut = Console.Out;
using var captured = new StringWriter();
Console.SetOut(captured);
try
{
HeadlessStaticStateAudit.ValidateProcessIsolation(sessionCount: 1);
}
finally
{
Console.SetOut(originalOut);
}
var diagnostics = new HeadlessDiagnosticWriter(captured);
HeadlessStaticStateAudit.ValidateProcessIsolation(
sessionCount: 1, diagnostics);
Assert.Contains(
nameof(PhysicsDiagnostics.ProbeParkEnabled),
@ -52,17 +51,11 @@ public sealed class HeadlessStaticStateAuditTests : IDisposable
[Fact]
public void SingleSessionWithNoProbesEnabledIsSilent()
{
var originalOut = Console.Out;
using var captured = new StringWriter();
Console.SetOut(captured);
try
{
HeadlessStaticStateAudit.ValidateProcessIsolation(sessionCount: 1);
}
finally
{
Console.SetOut(originalOut);
}
var diagnostics = new HeadlessDiagnosticWriter(captured);
HeadlessStaticStateAudit.ValidateProcessIsolation(
sessionCount: 1, diagnostics);
Assert.Equal(string.Empty, captured.ToString());
}
@ -71,11 +64,12 @@ public sealed class HeadlessStaticStateAuditTests : IDisposable
public void MultiSessionWithProbeEnabledStillThrowsNamingTheProbe()
{
PhysicsDiagnostics.ProbeParkEnabled = true;
var diagnostics = new HeadlessDiagnosticWriter(TextWriter.Null);
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessStaticStateAudit.ValidateProcessIsolation(
sessionCount: 2));
sessionCount: 2, diagnostics));
Assert.Contains(
nameof(PhysicsDiagnostics.ProbeParkEnabled),
@ -86,9 +80,11 @@ public sealed class HeadlessStaticStateAuditTests : IDisposable
[Fact]
public void MultiSessionWithNoProbesEnabledIsAllowed()
{
var diagnostics = new HeadlessDiagnosticWriter(TextWriter.Null);
Exception? exception = Record.Exception(
() => HeadlessStaticStateAudit.ValidateProcessIsolation(
sessionCount: 3));
sessionCount: 3, diagnostics));
Assert.Null(exception);
}

View file

@ -93,20 +93,23 @@ public class ChatCommandRouterTests
}
[Fact]
public void LifestoneWithArguments_ShowsRetailBadArgsRefusal_ViaInterfaceTextSeam()
public void LifestoneWithArguments_ShowsRetailsBespokeRefusal_ViaInterfaceTextSeam()
{
// #363 / register row AP-183: a Definition with no bespoke
// InvalidArgumentsText falls to retail's own generic bad-args
// fallback (HandleFailureEvent(0x26), "That is not a valid
// command.") at 0x1A ClientLocal — never the acdream-invented
// "Usage: /lifestone" line this used to synthesize.
// Consolidated-review round (2026-08-10), SHOULD-FIX 3: retires
// register row AP-113. DoLifestone prints ITS OWN 0x1A string for
// bad args and returns 1 -- retail never reaches the generic
// HandleFailureEvent(0x26) fallback for it. Byte-recovered from the
// PDB-paired acclient.exe (Binary Ninja mis-attributes the literal
// to an unrelated vtable-slot symbol).
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit("/ls now", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Empty(bus.Published);
Assert.Equal("That is not a valid command.", Assert.Single(interfaceTexts));
Assert.Equal(
"Please see @help lifestone for more information on how to use this command.",
Assert.Single(interfaceTexts));
Assert.Empty(log.Snapshot());
}
@ -122,10 +125,51 @@ public class ChatCommandRouterTests
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
var entry = Assert.Single(log.Snapshot());
Assert.Equal("That is not a valid command.", entry.Text);
Assert.Equal(
"Please see @help lifestone for more information on how to use this command.",
entry.Text);
Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType);
}
[Fact]
public void PkArenaWithArguments_ShowsRetailsGenericBadArgsFallback_ViaInterfaceTextSeam()
{
// #363 / register row AP-183: a Definition with no bespoke
// InvalidArgumentsText (PkArena never had one, unlike Lifestone/
// Marketplace above) falls to retail's own generic bad-args
// fallback (HandleFailureEvent(0x26), "That is not a valid
// command.") at 0x1A ClientLocal -- never the acdream-invented
// "Usage: /pkarena" line this used to synthesize. Carries the
// generic-fallback coverage the two Lifestone tests above used to
// own before Lifestone got its own bespoke text.
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit("/pka now", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Empty(bus.Published);
Assert.Equal("That is not a valid command.", Assert.Single(interfaceTexts));
Assert.Empty(log.Snapshot());
}
[Fact]
public void MarketplaceWithArguments_ShowsRetailsBespokeRefusal_ViaInterfaceTextSeam()
{
// Same methodology and same vtable-mislabeling artifact as
// Lifestone above -- see RetailClientCommandCatalog.Marketplace's
// own citation.
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit("/mar now", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Empty(bus.Published);
Assert.Equal(
"Please see @help marketplace for more information on how to use this command.",
Assert.Single(interfaceTexts));
Assert.Empty(log.Snapshot());
}
[Fact]
public void PkLiteAlias_ResolvesAsClientHandled_NotTheServerTextPath()
{

View file

@ -255,6 +255,182 @@ public sealed class RetailCommandHelpTableTests
entries[1].Text);
}
// ── Consolidated-review round (2026-08-10), SHOULD-FIX 1 ────────────
// Catalog leaf-verb Detail extraction: /help die and /help lifestone
// pinned byte-exact through the router (the actual user-visible
// outcome, not just the table's stored string — same pattern as
// DeathGroup_ThroughRouter_PrintsRetailsCompleteTwoEntryShape above),
// plus a coverage-count assertion so a future extraction pass (or an
// accidental regression that silently drops an override) is caught.
[Fact]
public void HelpDie_ThroughRouter_PrintsRetailsExactDetailText()
{
// HelpDie @0x005784a0: Summary @0x005784ad ("Kills your character
// and leaves a corpse..."), Detail @0x005784b4 (the long paragraph
// below) -- DoHelp always calls Detail_HelpType(2) for a resolved
// verb, never Summary. Byte-swept against the PDB-paired
// C:\Users\erikn\Downloads\acclient.exe (verified MATCH).
var log = new AcDream.Core.Chat.ChatLog();
var vm = new ChatVM(log, displayLimit: 50);
var bus = new RecordingCommandBus();
var outcome = ChatCommandRouter.Submit(
"/help die", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
var entries = log.Snapshot();
Assert.Equal(2, entries.Length);
Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text);
Assert.Equal(
RetailCommandHelpTable.ForMoreInformationPrefix
+ RetailCommandHelpTable.DieDetail,
entries[1].Text);
Assert.Equal(
"@die - If you wish to kill your character and leave a corpse, "
+ "you may use the @die command. This will result in your "
+ "character's death, you will leave behind a corpse with some "
+ "of your items, and you will appear at your lifestone. If "
+ "you wish to travel to your lifestone without leaving behind "
+ "a corpse, you may use the @lifestone command.\n",
RetailCommandHelpTable.DieDetail);
}
[Fact]
public void HelpLifestone_ThroughRouter_PrintsRetailsExactDetailText()
{
// HelpLifestone @0x00578500 is UNCONDITIONAL (no Summary/Detail
// branch at all) -- the same string prints for every HelpType,
// confirmed by reading the decompiled body directly (no if/else).
var log = new AcDream.Core.Chat.ChatLog();
var vm = new ChatVM(log, displayLimit: 50);
var bus = new RecordingCommandBus();
var outcome = ChatCommandRouter.Submit(
"/help lifestone", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
var entries = log.Snapshot();
Assert.Equal(2, entries.Length);
Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text);
Assert.Equal(
RetailCommandHelpTable.ForMoreInformationPrefix
+ RetailCommandHelpTable.LifestoneDetail,
entries[1].Text);
Assert.Equal(
"@lifestone - Returns you to the last lifestone you used without killing you.\n",
RetailCommandHelpTable.LifestoneDetail);
}
[Fact]
public void HelpAlh_RoutesToTheSameHelpAllegianceTextAsHelpAllegiance()
{
// alh/ah are registered with HelpAllegiance as their OWN help
// pointer (confirmed via InitializeCommands's field-store
// registration pattern, not the CmdHashData-constructor pattern) --
// the exact same function "allegiance"/"all" use, so /help alh must
// show byte-identical text to /help allegiance, not a separate
// (possibly stale) copy.
Assert.True(RetailCommandHelpTable.TryGetCatalogVerbDetailText(
"alh", out string alhText));
Assert.Equal(RetailCommandHelpTable.AllegianceOverview, alhText);
}
[Theory]
[InlineData("index")]
[InlineData("clist")]
[InlineData("on")]
[InlineData("off")]
public void ConfirmedNullHelpVerb_ThroughRouter_ShowsUnknownCommandNotCatalogSummary(
string verb)
{
// index/clist/on/off are registered in InitializeCommands with a
// plain `int32_t var_3c_NN = 0;` local -- no preceding Help-pointer
// 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.
var log = new AcDream.Core.Chat.ChatLog();
var vm = new ChatVM(log, displayLimit: 50);
var bus = new RecordingCommandBus();
var outcome = ChatCommandRouter.Submit(
$"/help {verb}", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
var entries = log.Snapshot();
Assert.Single(entries);
Assert.Equal(RetailCommandHelpTable.UnknownCommand, entries[0].Text);
Assert.Equal(
(uint)AcDream.Core.Chat.RetailLogTextType.ClientLocal,
entries[0].LogTextType);
}
[Fact]
public void CatalogLeafVerbCoverage_ExtractedVsConfirmedNullVsUnverified_MatchesConsolidatedReviewCount()
{
// Coverage-count assertion (SHOULD-FIX 1's explicit acceptance
// criterion): dedupe RetailClientCommandCatalog.KnownVerbs down to
// one entry per distinct Definition via its (unique-per-Definition)
// catalog summary text, excluding the two meta-dispatchers
// ("house"/"hou" and "allegiance"/"all" resolve subcommand-by-
// subcommand, not as a single leaf Definition), then classify each
// distinct Definition into exactly one bucket. A future extraction
// pass should bump ExtractedCount down UnverifiedCount and update
// this pin in the same commit; an accidental regression that drops
// an override without updating CatalogVerbsWithNoRetailHelp would
// fail this test instead of silently reverting to a summary.
var seenSummaries = new HashSet<string>(StringComparer.Ordinal);
int extractedCount = 0;
int confirmedNullCount = 0;
int unverifiedCount = 0;
foreach (string verb in RetailClientCommandCatalog.KnownVerbs)
{
if (verb is "house" or "hou" or "allegiance" or "all")
continue;
Assert.True(
RetailClientCommandCatalog.TryGetHelpText(verb, out string summary),
$"catalog verb '{verb}' has no summary text");
if (!seenSummaries.Add(summary))
continue; // an alias of a Definition already counted
bool confirmedNull = RetailCommandHelpTable.CatalogVerbsWithNoRetailHelp
.Contains(verb);
bool extracted = RetailCommandHelpTable.TryGetCatalogVerbDetailText(
verb, out _);
// A verb must never be BOTH confirmed-null AND carry an
// extracted override -- that would mean ChatCommandRouter's
// ordering (confirmed-null checked first) is hiding a real
// Detail text, or the two tables disagree about the same verb.
Assert.False(
confirmedNull && extracted,
$"'{verb}' is both confirmed-null and has an extracted Detail override");
if (confirmedNull)
confirmedNullCount++;
else if (extracted)
extractedCount++;
else
unverifiedCount++;
}
// Measured, not hand-counted -- 47 distinct leaf Definitions total
// (squelch/unsquelch and friends/friends_add/friends_remove each
// count separately here since they are DISTINCT Definitions with
// their own summary text, even though several share ONE extracted
// Detail text -- see the CmdHashData note in RetailCommandHelpTable's
// class remarks).
Assert.Equal(42, extractedCount);
Assert.Equal(4, confirmedNullCount);
Assert.Equal(1, unverifiedCount);
}
private sealed class RecordingCommandBus : ICommandBus
{
public List<object> Published { get; } = new();