fix(chat): #363 — retail 0x1A typing for command refusals via the interface-text seam
ChatVM gains an OnInterfaceText hook + ShowInterfaceText(text), the
App-layer composition wires it to RuntimeCommunicationState.AddText,
and ChatCommandRouter routes every retail-0x1A command refusal through
it instead of the chat log's 0x00 sink. UI.Abstractions still never
references Runtime directly; unwired hosts (headless, tests) fall back
to the chat log tagged ClientLocal so no text is ever silently lost.
Reclassified per register row AP-183 (DoChannelList/On/Off, DoAllegiance,
DoHouseAvailableList — the last also corrected to retail's own bad-house-
type string instead of a synthesized "Usage:" line) and newly wired two
sites that previously showed nothing at all (DoStupidChannelHack's bare
legacy-channel-verb refusal, DoReply's message-but-no-last-teller
refusal). The generic bad-args fallback now resolves WeenieErrorMessages
0x026 ("That is not a valid command.", retail's HandleFailureEvent(0x26))
instead of synthesizing "Usage: {Usage}". DoSpeaker/DoEndurance/DoTitle
are untouched — already correct at 0x00.
Also closes #367 (DoHelp's "Unknown command" fallback and the degenerate-
prefix refusal now reach the SpewBox too) and retires register row
AP-186, whose own filing proposed exactly this seam shape.
Full Release suite: 12,542 passed / 4 skipped / 0 failed (baseline
12,466/4/0 at ff2784ea).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ff2784eaa4
commit
09453ecae8
14 changed files with 702 additions and 106 deletions
|
|
@ -562,7 +562,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
var chat = new ChatVM(
|
||||
d.Communication.Chat,
|
||||
displayLimit: 200,
|
||||
commandTargets: d.Communication.CommandTargets);
|
||||
commandTargets: d.Communication.CommandTargets)
|
||||
{
|
||||
// Issue #363 / #367: routes ChatCommandRouter's 0x1A
|
||||
// (ClientLocal) command refusals to the same SpewBox
|
||||
// chokepoint every other interface-text producer uses,
|
||||
// instead of the chat scroll.
|
||||
OnInterfaceText = text =>
|
||||
d.Communication.AddText(text, RetailLogTextType.ClientLocal),
|
||||
};
|
||||
AcDream.UI.Abstractions.Panels.Settings.SettingsStore? layoutStore =
|
||||
d.Settings.LayoutStore;
|
||||
RetailUiPersistenceBindings? persistence = layoutStore is null
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
|
|
@ -51,8 +52,23 @@ public static class ChatCommandRouter
|
|||
{
|
||||
if (!clientCommand.HasValidArguments)
|
||||
{
|
||||
vm.ShowSystemMessage(clientCommand.InvalidArgumentsText
|
||||
?? $"Usage: {clientCommand.Usage}");
|
||||
// #363 / register row AP-183: retail's bad-args refusal is
|
||||
// ALWAYS 0x1A (ClientLocal / SpewBox-only) — verified
|
||||
// against five decompiled handlers (DoDie, DoChannelList/
|
||||
// On/Off, DoAllegiance, DoHouseAvailableList). A Definition
|
||||
// with its own InvalidArgumentsText is the handler's own
|
||||
// bespoke refusal string, printed before it returns
|
||||
// "handled" (1) so retail's generic fallback never fires
|
||||
// for it. A Definition with none falls to that generic
|
||||
// fallback: ClientCommunicationSystem::DoCommand
|
||||
// @0x0057E46D calls HandleFailureEvent(0x26) when a
|
||||
// registered handler returns 0 (bad args) —
|
||||
// "That is not a valid command." (WeenieErrorMessages
|
||||
// [0x026]) — never the acdream-invented "Usage: {Usage}"
|
||||
// line this branch used to synthesize.
|
||||
vm.ShowInterfaceText(clientCommand.InvalidArgumentsText
|
||||
?? WeenieErrorMessages.Resolve(0x026u, null).Text
|
||||
?? "That is not a valid command.");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
|
|
@ -66,10 +82,14 @@ 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.
|
||||
if (trimmed[0] is '/' or '@'
|
||||
&& (trimmed.Length == 1 || !char.IsLetter(trimmed[1])))
|
||||
{
|
||||
vm.ShowSystemMessage(
|
||||
vm.ShowInterfaceText(
|
||||
$"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands.");
|
||||
return SubmitOutcome.UnknownCommand;
|
||||
}
|
||||
|
|
@ -97,6 +117,23 @@ public static class ChatCommandRouter
|
|||
return SubmitOutcome.Sent;
|
||||
}
|
||||
|
||||
// #363 / register row AP-183: retail's registered legacy-channel
|
||||
// verbs (DoStupidChannelHack @0x0057B144) and /reply
|
||||
// (DoReply @0x00577910) refuse locally at 0x1A instead of silently
|
||||
// dropping the line the way ChatInputParser.Parse's pure "return
|
||||
// null" shape does for these cases.
|
||||
if (ChatInputParser.IsBareRegisteredChannelVerb(trimmed))
|
||||
{
|
||||
vm.ShowInterfaceText("You must specify the text you wish to say!");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
if (ChatInputParser.IsReplyMissingLastTeller(trimmed, vm.LastIncomingTellSender))
|
||||
{
|
||||
vm.ShowInterfaceText("Someone must @tell you first!");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
var parsed = ChatInputParser.Parse(
|
||||
trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget);
|
||||
if (parsed is { } chat)
|
||||
|
|
@ -247,12 +284,10 @@ public static class ChatCommandRouter
|
|||
return;
|
||||
}
|
||||
|
||||
// Retail types this 0x1A (ClientLocal) -> SpewBox-only; ChatVM has
|
||||
// no SpewBox routing capability yet, so this still renders via the
|
||||
// chat scroll — a pre-existing gap, not new this round. See the
|
||||
// class remarks on RetailCommandHelpTable.UnknownCommand and
|
||||
// ISSUES.md #367.
|
||||
vm.ShowSystemMessage(RetailCommandHelpTable.UnknownCommand);
|
||||
// Retail types this 0x1A (ClientLocal) -> SpewBox-only. #363/#367:
|
||||
// now routed through ChatVM.ShowInterfaceText instead of the chat
|
||||
// scroll — see the class remarks on RetailCommandHelpTable.UnknownCommand.
|
||||
vm.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
|
||||
}
|
||||
|
||||
private static bool EqAny(string value, params string[] options)
|
||||
|
|
|
|||
|
|
@ -124,6 +124,64 @@ public static class ChatInputParser
|
|||
("/o", ChatChannelKind.Olthoi),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The six retail channel groups whose registered-verb handler is
|
||||
/// <c>ClientCommunicationSystem::DoStupidChannelHack @0x0057B144</c>
|
||||
/// (command-registry doc §2.3) — the legacy 0x0147 channels. The
|
||||
/// Turbine-only channels (General/Trade/Lfg/Roleplay/Society/Olthoi)
|
||||
/// are never this handler's clients. <see cref="ChatChannelKind.Allegiance"/>
|
||||
/// (retail's <c>@a</c>) still belongs here even though acdream always
|
||||
/// sends it over Turbine once connected (<see cref="ChatChannelKind"/>'s
|
||||
/// own doc comment) — DoStupidChannelHack dispatches on the REGISTERED
|
||||
/// VERB, before Turbine send-time routing decides where the text goes;
|
||||
/// retail's <c>"a"</c>/<c>"ab"</c> are one registered verb group.
|
||||
/// </summary>
|
||||
private static readonly HashSet<ChatChannelKind> LegacyChannelHackKinds =
|
||||
[
|
||||
ChatChannelKind.Fellowship,
|
||||
ChatChannelKind.Allegiance,
|
||||
ChatChannelKind.AllegianceBroadcast,
|
||||
ChatChannelKind.Vassals,
|
||||
ChatChannelKind.Patron,
|
||||
ChatChannelKind.Monarch,
|
||||
ChatChannelKind.CoVassals,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="trimmed"/> is one of the
|
||||
/// <see cref="LegacyChannelHackKinds"/> verbs with NO message — retail's
|
||||
/// <c>DoStupidChannelHack</c> "You must specify the text you wish to
|
||||
/// say!" refusal (<c>0x1A</c> ClientLocal,
|
||||
/// acclient_2013_pseudo_c.txt:1030730, data_7da9b0). This parser stays
|
||||
/// pure (no side effects — <see cref="Parse"/> just returns
|
||||
/// <see langword="null"/> for this shape); <see cref="ChatCommandRouter"/>
|
||||
/// owns the actual refusal text and routes it to the SpewBox.
|
||||
/// </summary>
|
||||
public static bool IsBareRegisteredChannelVerb(string trimmed)
|
||||
{
|
||||
foreach (var (verb, channel) in ChannelVerbs)
|
||||
{
|
||||
if (LegacyChannelHackKinds.Contains(channel) && IsBareVerb(trimmed, [verb]))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="trimmed"/> is <c>/reply</c>/<c>/r</c>/
|
||||
/// <c>/rp</c> WITH a message but no prior incoming Tell — retail's
|
||||
/// <c>ClientCommunicationSystem::DoReply @0x00577910</c> "Someone must
|
||||
/// @tell you first!" refusal (<c>0x1A</c> ClientLocal,
|
||||
/// acclient_2013_pseudo_c.txt:387538, data_7da974), the
|
||||
/// <c>gmCCommunicationSystem::GetLastTeller() == 0</c> branch. Bare
|
||||
/// <c>/r</c> with no message at all is a DIFFERENT retail branch (its
|
||||
/// own copy of the "you must specify text" string) and is deliberately
|
||||
/// out of scope here — not named by register row AP-183 / issue #363.
|
||||
/// </summary>
|
||||
public static bool IsReplyMissingLastTeller(string trimmed, string? lastTellSender) =>
|
||||
string.IsNullOrEmpty(lastTellSender) && TryParseMessageOnly(trimmed, ReplyAliases, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Parse <paramref name="raw"/> into a <see cref="ParsedInput"/>
|
||||
/// triple, or <c>null</c> if the input is empty / whitespace /
|
||||
|
|
|
|||
|
|
@ -73,6 +73,21 @@ public sealed class ChatVM : IDisposable
|
|||
/// </summary>
|
||||
public Func<Vector3>? PositionProvider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional hook routing retail-<c>0x1A</c> (<see
|
||||
/// cref="RetailLogTextType.ClientLocal"/>) interface text — command
|
||||
/// refusals and bad-argument usage lines — to the SpewBox instead of
|
||||
/// the chat transcript. <c>AcDream.UI.Abstractions</c> must stay
|
||||
/// Runtime-independent (Code Structure Rules), so it cannot call
|
||||
/// <c>RuntimeCommunicationState.AddText</c> directly; the App-layer
|
||||
/// composition host wires this the same way it wires
|
||||
/// <see cref="FpsProvider"/>/<see cref="PositionProvider"/>. Closes
|
||||
/// ISSUES.md #367 / register row AP-186 — <see cref="ChatCommandRouter"/>
|
||||
/// no longer has to render every <c>0x1A</c> refusal through the chat
|
||||
/// scroll.
|
||||
/// </summary>
|
||||
public Action<string>? OnInterfaceText { get; init; }
|
||||
|
||||
/// <summary>Monotonic revision of the underlying transcript content.</summary>
|
||||
public long Revision => _log.Revision;
|
||||
|
||||
|
|
@ -139,6 +154,33 @@ public sealed class ChatVM : IDisposable
|
|||
/// </remarks>
|
||||
public void ShowSystemMessage(string text) => _log.OnSystemMessage(text, chatType: 0x00u);
|
||||
|
||||
/// <summary>
|
||||
/// Route a retail-<c>0x1A</c> (<see cref="RetailLogTextType.ClientLocal"/>)
|
||||
/// command refusal / usage line to the SpewBox — retail's
|
||||
/// <c>ClientSystem::AddTextToScroll(text, 0x1A, 1, windowId) @0x00563C50</c>
|
||||
/// destination for this text type is the SpewBox exclusively, never a
|
||||
/// chat window (<c>docs/research/2026-08-09-chat-retail-interface-text.md</c>
|
||||
/// §2.1/§2.2).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Prefers <see cref="OnInterfaceText"/> when the App-layer host wired
|
||||
/// it (the production graphical client). When unwired — headless, the
|
||||
/// automation probe runner, or a test fixture that only exercises the
|
||||
/// pure UI.Abstractions layer — the text still needs to reach the
|
||||
/// player somewhere, so it falls back to the ordinary chat transcript
|
||||
/// tagged with the real <see cref="RetailLogTextType.ClientLocal"/>
|
||||
/// color rather than being silently dropped. That fallback lands in
|
||||
/// the wrong PANEL (chat instead of SpewBox) but keeps the right TYPE
|
||||
/// and never loses the line — the safe default issue #363 requires.
|
||||
/// </remarks>
|
||||
public void ShowInterfaceText(string text)
|
||||
{
|
||||
if (OnInterfaceText is { } hook)
|
||||
hook(text);
|
||||
else
|
||||
_log.OnSystemMessage(text, chatType: (uint)RetailLogTextType.ClientLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drain the chat log. Used by the /clear client-side command.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -346,11 +346,18 @@ public static class RetailClientCommandCatalog
|
|||
|
||||
// ClientCommunicationSystem::DoHouseAvailableList @ 0x00570510. Exact
|
||||
// retail help: acclient_2013_pseudo_c.txt:1031049 (data_7dd9d0).
|
||||
// #363 / register row AP-183: the bad-args refusal is retail's own
|
||||
// specific string too, verified at acclient_2013_pseudo_c.txt:381481
|
||||
// (AddTextToScroll(..., 0x1a, ...)), full text at
|
||||
// acclient_2013_pseudo_c.txt:1029383 (data_7d0a98) — NOT the
|
||||
// acdream-synthesized "Usage: /hslist <house type>" line this
|
||||
// Definition used to fall back to.
|
||||
private static readonly Definition HouseAvailableList = new(
|
||||
ClientCommandId.HouseAvailableList,
|
||||
Usage: "/hslist <house type>",
|
||||
HelpText: "@hslist <house type> - Lists the number and, if appropriate, positions of houses currently available for purchase. Types include: Apartment, Cottage, Villa, Mansion",
|
||||
ValidateArguments: static arguments => HouseTypes.ContainsKey(arguments.Trim()));
|
||||
ValidateArguments: static arguments => HouseTypes.ContainsKey(arguments.Trim()),
|
||||
InvalidArgumentsText: "Please see @help hslist for more information on how to use this command");
|
||||
|
||||
// ClientCommunicationSystem::DoChannelIndex @ 0x0056E640. No help
|
||||
// string was extracted for the bare form; the verb is admin/advocate/
|
||||
|
|
|
|||
|
|
@ -91,14 +91,17 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
|
|||
/// <see cref="UnknownCommand"/>, typed <c>0x1A</c> (<c>ClientLocal</c>) —
|
||||
/// retail routes that type to the SpewBox exclusively, never the chat
|
||||
/// window (<c>docs/research/2026-08-09-chat-retail-interface-text.md</c>
|
||||
/// §2.1/§2.2). <c>ChatCommandRouter</c> operates on <c>ChatVM</c>
|
||||
/// (<c>AcDream.UI.Abstractions</c>), which has no SpewBox routing
|
||||
/// capability — wiring that would mean threading a
|
||||
/// <c>RuntimeCommunicationState</c>-shaped dependency down into a layer
|
||||
/// that must stay presentation/Runtime-independent, out of this round's
|
||||
/// scope. The unknown-verb fallback therefore still renders via the chat
|
||||
/// scroll, a pre-existing (not newly introduced) gap now tracked at
|
||||
/// ISSUES.md #367 instead of silently continuing unregistered.
|
||||
/// §2.1/§2.2).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Issue #363 (2026-08-10):</b> <c>ChatCommandRouter</c> now routes this
|
||||
/// fallback (and every other <c>0x1A</c> command-refusal call site) through
|
||||
/// <c>ChatVM.ShowInterfaceText</c> — an optional hook the App-layer host
|
||||
/// wires to <c>RuntimeCommunicationState.AddText</c>, the same SpewBox
|
||||
/// chokepoint every other producer of interface text uses. UI.Abstractions
|
||||
/// still never references Runtime directly (Code Structure Rules); the hook
|
||||
/// is the seam. Closes ISSUES.md #367 and retires register row AP-186.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailCommandHelpTable
|
||||
|
|
@ -147,9 +150,9 @@ 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
|
||||
// and ISSUES.md #367 for why ChatCommandRouter still shows it in the
|
||||
// chat scroll.
|
||||
// (ClientLocal) -- SpewBox-only; see the class remarks' routing note --
|
||||
// ChatCommandRouter now routes it through ChatVM.ShowInterfaceText
|
||||
// (issue #363), closing #367.
|
||||
public const string UnknownCommand = "Unknown command";
|
||||
|
||||
// @mr/@pr are registered with a NULL function pointer in the 2013
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue