fix(chat): CH4 review fixes — allegiance ownership guard, house-abandon confirmation

Blocker 1: an unrecognized "@allegiance <sub>" subcommand escaped
TryMatchAllegiance (which only claimed "info"/"hometown") and fell through
the unregistered-tag channel fallback, broadcasting the raw subcommand
text to the Allegiance chat channel (0x02000000). Retail's own
DoAllegiance never reaches DoChannelCommand for an unrecognized
subcommand — it claims the whole verb and prints its own client-local
refusal. TryMatchAllegiance now claims "allegiance"/"all" unconditionally
and shows retail's "Please see @help Allegiance..." text; ChatCommandRouter
also gained a blanket RetailClientCommandCatalog.KnownVerbs ownership
guard in TryDispatchChannelFallback as defense in depth.

Blocker 2: "@house abandon" sent 0x021F immediately with no confirmation.
Retail runs a real two-stage dialog before Event_AbandonHouse(); ported
both verbatim strings and chained two ShowConfirmation calls.

Should-fixes: a bare unregistered tag with no text now passes through
silently instead of showing a refusal that belongs to a different retail
function; @join/@leave update RuntimeCharacterOptionsState locally (new
SetOptionBit) before the wire push so the Turbine membership gate stops
refusing a just-joined room; @permit accepts multi-word names; @clist/
@on/@off validate shape only and raise WeenieError 0x422 for an unknown
tag; @mr/@pr help text is now the verbatim retail strings; corrected
issue #360, register row TS-68, the campaign doc's B.7 note, and a stale
RetailChannelTagTable comment; filed issue #363 + register row AP-183 for
the deferred error-typing debt.

Nits: fixed TryMatchHouse's stale doc comment, the AP-182/@title "stores
the value" comments (the binding is a no-op), IsUnregisteredFallbackTag's
olthoi false-positive, added /g and /rp binding-level conformance pins,
made @index ignore extra arguments, and noted the six removed invented
verbs in ISSUES.md.

Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's
12,190/4/0 — net +26 tests, no removals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 21:59:35 +02:00
parent 090825e703
commit 724ef2d389
17 changed files with 853 additions and 85 deletions

View file

@ -435,7 +435,18 @@ internal sealed class LiveSessionRuntimeFactory
// silent accept is exactly as faithful as a stored-but-unread
// value would be, without inventing a consumer.
SetChatTitle: _ => { },
SetSingleCharacterOption: session.SendSetSingleCharacterOption,
// CH4 REJECT-review SHOULD-FIX 4 (2026-08-09): only @join/@leave
// reach this binding (see ClientCommandController.Execute).
// Retail's PlayerModule::SetHear*Chat family writes the bit into
// the local options copy FIRST, then notifies the server — match
// that ordering so TurbineChatMembershipGate (which reads
// _domain.Character.Options) stops refusing the newly-joined
// room before the next PlayerDescription happens to arrive.
SetSingleCharacterOption: (optionId, value) =>
{
_domain.Character.Options.SetOptionBit(optionId, value);
session.SendSetSingleCharacterOption(optionId, value);
},
AddPlayerPermission: session.SendAddPlayerPermission,
RemovePlayerPermission: session.SendRemovePlayerPermission,
RequestAvailableHouses: session.SendListAvailableHouses,

View file

@ -256,9 +256,13 @@ public sealed class ClientCommandController
"This command is no longer in use, please see @allegiance officer.");
break;
// ClientCommunicationSystem::DoTitle @ 0x0057A640. No local
// chat-window title chrome exists yet (AP-182) — the value is
// stored for a future consumer, matching retail's silent
// success (no confirmation text was found at the success site).
// chat-window title chrome exists yet (AP-182) — the binding is
// a pure no-op, matching retail's silent success (no
// confirmation text was found at the success site). Corrected
// 2026-08-09 at the CH4 REJECT-review nit 11: the earlier
// wording here claimed the value "is stored for a future
// consumer", which was false — see LiveSessionRuntimeFactory's
// SetChatTitle binding (`_ => { }`).
case ClientCommandId.SetChatTitle:
_bindings.SetChatTitle(command.Arguments.Trim());
break;
@ -300,20 +304,32 @@ public sealed class ClientCommandController
case ClientCommandId.IndexChannels:
_bindings.RequestChannelIndex();
break;
// ClientCommunicationSystem::DoChannelList @ 0x0057A9B0.
// ClientCommunicationSystem::DoChannelList @ 0x0057A9B0. CH4
// REJECT-review SHOULD-FIX 6 (2026-08-09): the catalog only
// validated argument SHAPE (exactly one token); an unresolved
// tag reaches here and raises retail's own
// HandleFailureEvent(0x422) ("That channel doesn't exist.").
case ClientCommandId.ListChannel:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint listChannelId))
_bindings.RequestChannelList(listChannelId);
else
_bindings.ShowWeenieError(0x0422u);
break;
// ClientCommunicationSystem::DoChannelOn @ 0x0057AA80.
// ClientCommunicationSystem::DoChannelOn @ 0x0057AA80. Same
// 0x422 shape as ListChannel above.
case ClientCommandId.OnChannel:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint onChannelId))
_bindings.JoinGmChannel(onChannelId);
else
_bindings.ShowWeenieError(0x0422u);
break;
// ClientCommunicationSystem::DoChannelOff @ 0x0057AB50.
// ClientCommunicationSystem::DoChannelOff @ 0x0057AB50. Same
// 0x422 shape as ListChannel above.
case ClientCommandId.OffChannel:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint offChannelId))
_bindings.LeaveGmChannel(offChannelId);
else
_bindings.ShowWeenieError(0x0422u);
break;
// GameActionRecallAllegianceHometown — @alh/@ah/"@allegiance hometown".
case ClientCommandId.AllegianceHometown:
@ -323,9 +339,34 @@ public sealed class ClientCommandController
case ClientCommandId.AllegianceInfo:
_bindings.RequestAllegianceInfo(command.Arguments.Trim());
break;
// GameActionHouseAbandon — "@house abandon".
// GameActionHouseAbandon — "@house abandon". Retail's abandon
// branch (DoHouse @ 0x00580D58) opens a FIRST confirmation
// dialog (DialogFactory::MakeCallbackDialogInCurrentUI →
// HouseAbandonDialogCallback_First @0x00580E1A); only on
// accept does that callback open a SECOND dialog
// (HouseAbandonDialogCallback_Second @0x0057BE90), and only
// THAT callback's accept calls Event_AbandonHouse()
// (0x0057BF01 — the ONLY call site). Both strings recovered
// verbatim from acclient_2013_pseudo_c.txt (data_7e1460 /
// data_7e1370). CH4 REJECT-review Blocker 2 (2026-08-09):
// acdream previously sent 0x021F immediately with NO
// confirmation at all.
case ClientCommandId.HouseAbandon:
_bindings.AbandonHouse();
_bindings.ShowConfirmation(
"Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.",
firstAccepted =>
{
if (!firstAccepted)
return;
_bindings.ShowConfirmation(
"Are you absolutely certain you wish to abandon your house? Click yes only if you are sure!",
secondAccepted =>
{
if (secondAccepted)
_bindings.AbandonHouse();
});
});
break;
default:
@ -645,12 +686,15 @@ public sealed class ClientCommandController
}
// ClientCommunicationSystem::DoPermit @ 0x005785A0. Argument shape
// already validated by RetailClientCommandCatalog (exactly "add <name>"
// or "remove <name>").
// already validated by RetailClientCommandCatalog (a mode word plus at
// least one more token). CH4 REJECT-review SHOULD-FIX 5 (2026-08-09):
// retail's DoPermit joins every token after the mode word into the
// name (JoinArgsAsName), so a multi-word character name — "@permit add
// Aunt Agatha" — must resolve to "Aunt Agatha", not just "Aunt".
private void ExecutePermit(string arguments)
{
string[] parts = SplitArguments(arguments);
string name = parts[1];
string name = string.Join(' ', parts, 1, parts.Length - 1);
if (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase))
_bindings.AddPlayerPermission(name);
else

View file

@ -651,6 +651,58 @@ public sealed class RuntimeCharacterOptionsState
Interlocked.Increment(ref _revision);
}
/// <summary>
/// Set ONE character-option bit locally, by its linear
/// <c>CharacterOptionId</c> (the same id carried on the wire by
/// <c>SetSingleCharacterOption (0x0005)</c>). Retail's
/// <c>PlayerModule::SetHearGeneralChat @0x005D35C0</c> (and its five
/// <c>SetHear*Chat</c> siblings) write the bit into this LOCAL copy
/// FIRST, before the client ever notifies the server. CH4
/// REJECT-review SHOULD-FIX 4 (2026-08-09): acdream's <c>@join</c>/
/// <c>@leave</c> previously pushed only the wire message and left this
/// state untouched, so <see cref="AcDream.Runtime.Gameplay.TurbineChatMembershipGate"/>
/// kept refusing a room the player had just joined until the next
/// <c>PlayerDescription</c> happened to arrive. Only the six
/// <c>ListenTo*Chat</c> ids <c>CharacterOptionId</c> models are
/// recognized here; any other id is a silent no-op — this state only
/// tracks what the Turbine-chat membership gate needs, not a complete
/// <c>PlayerModule</c> mirror.
/// </summary>
public void SetOptionBit(uint characterOptionId, bool value)
{
(bool isOptions1, uint mask) = characterOptionId switch
{
(uint)CharacterOptionId.ListenToAllegianceChat =>
(true, (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat),
(uint)CharacterOptionId.ListenToGeneralChat =>
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat),
(uint)CharacterOptionId.ListenToTradeChat =>
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat),
(uint)CharacterOptionId.ListenToLFGChat =>
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat),
(uint)CharacterOptionId.ListenToRoleplayChat =>
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat),
(uint)CharacterOptionId.ListenToSocietyChat =>
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat),
_ => (false, 0u),
};
if (mask == 0u)
return;
if (isOptions1)
{
uint updated = value ? (Options1 | mask) : (Options1 & ~mask);
Volatile.Write(ref _options1, updated);
}
else
{
uint updated = value ? (Options2 | mask) : (Options2 & ~mask);
Volatile.Write(ref _options2, updated);
}
Interlocked.Increment(ref _revision);
}
public void ResetSession()
{
Volatile.Write(ref _options1, DefaultOptions1);

View file

@ -47,7 +47,14 @@ public enum ClientCommandId
Endurance,
/// <summary>@speaker — fixed deprecation notice ("see @allegiance officer").</summary>
Speaker,
/// <summary>@title &lt;text&gt; — sets the popup chat window's title (local state only; no title-bar chrome yet, AP-182).</summary>
/// <summary>
/// @title &lt;text&gt; — retail sets the popup chat window's title;
/// acdream's binding is a pure no-op (the value is neither stored nor
/// consumed — no title-bar chrome exists yet, AP-182, corrected
/// 2026-08-09 at the CH4 REJECT-review nit 11, which found this
/// summary's earlier "local state only" wording implied storage that
/// does not happen).
/// </summary>
SetChatTitle,
/// <summary>@chat on|off — global Speech squelch toggle (message type 2).</summary>
ChatToggle,
@ -75,4 +82,14 @@ public enum ClientCommandId
AllegianceInfo,
/// <summary>"@house abandon" — abandon the character's house.</summary>
HouseAbandon,
/// <summary>
/// CH4 REJECT-review Blocker 1 (2026-08-09): "@allegiance"/"@all" with
/// any subcommand beyond the 2 ported ones (info, hometown/ho). Never
/// dispatched — <see cref="AcDream.UI.Abstractions.Panels.Chat.
/// RetailClientCommandCatalog.Match.HasValidArguments"/> is always
/// false for this id, so <c>ChatCommandRouter</c> shows retail's own
/// "Please see @help Allegiance..." refusal and never publishes an
/// <c>ExecuteClientCommandCmd</c>.
/// </summary>
AllegianceUnrecognizedSubcommand,
}

View file

@ -1,4 +1,5 @@
using System;
using System.Linq;
namespace AcDream.UI.Abstractions.Panels.Chat;
@ -118,11 +119,28 @@ public static class ChatCommandRouter
return null;
string verb = ChatInputParser.GetVerbToken(trimmed);
string normalizedChatVerb = "/" + verb[1..].TrimEnd(',');
string tag = verb[1..].TrimEnd(',');
// CH4 REJECT-review Blocker 1 (2026-08-09): apply the catalog
// ownership rule BEFORE any channel-tag resolution. Retail's
// registered-command hash table is checked FIRST (doc §1) and
// unconditionally wins over DoChannelCommand's fallback — a
// catalog-owned verb must never resolve as a channel broadcast
// here, even if its own TryMatch declines ownership for some
// OTHER reason than "not owned" (RetailClientCommandCatalog's
// "allegiance"/"all" already claim ownership unconditionally via
// TryMatchAllegiance, so this line is currently redundant for
// that specific verb — it's the blanket guard for the rest of the
// catalog, e.g. "house", "lifestone", …). Skipping this check is
// exactly how an unmatched "@allegiance boot Bob" used to reach
// this method and broadcast "boot Bob" to the Allegiance channel.
if (RetailClientCommandCatalog.KnownVerbs.Contains(tag, StringComparer.OrdinalIgnoreCase))
return null;
string normalizedChatVerb = "/" + tag;
if (ChatInputParser.IsKnownVerb(normalizedChatVerb))
return null; // registered verb — handled by the normal channel path.
string tag = normalizedChatVerb[1..];
if (!RetailChannelTagTable.TryResolve(tag, out uint channelId))
return null;
@ -130,10 +148,17 @@ public static class ChatCommandRouter
string text = separator < 0 ? string.Empty : trimmed[(separator + 1)..].Trim();
if (text.Length == 0)
{
// Retail's DoChannelCommand: "You must specify the text you
// wish to say." — a real local error, not a passthrough.
vm.ShowSystemMessage("You must specify the text you wish to say!");
return SubmitOutcome.ClientHandled;
// CH4 REJECT-review SHOULD-FIX 3 (2026-08-09): retail's
// DoChannelCommand @0x005774A7 returns 0 SILENTLY when argc<=0
// for an UNREGISTERED tag; DoCommand's own final fallback then
// sends the raw @-line to the server via Event_Talk — this is
// passthrough, not a local refusal. "You must specify the text
// you wish to say!" belongs to DoStupidChannelHack
// @0x0057B144, which only runs for the REGISTERED channel
// verbs (fellowship, vassals, patron, monarch, covassals,
// allegiance-broadcast), never these 22 GM/faction tags. Let
// TryBuildServerCommand's passthrough handle it instead.
return null;
}
bus.Publish(new SendRawChannelCmd(channelId, text));

View file

@ -72,10 +72,22 @@ public static class RetailChannelTagTable
["olthoi"] = 0x40000000u,
// Registered-verb tags (already reachable via ChatInputParser's
// normal channel-verb path). Present here ONLY so @clist/@on/@off
// accept the same tag spellings retail's GetChannelID resolves —
// ChatCommandRouter's A.4 fallback never reaches these entries
// because IsKnownVerb intercepts them first.
// normal channel-verb path — OR, for "allegiance"/"all", via
// RetailClientCommandCatalog's unconditional ownership of that
// verb, see TryMatchAllegiance). Present here ONLY so
// @clist/@on/@off accept the same tag spellings retail's
// GetChannelID resolves — ChatCommandRouter's A.4 fallback never
// reaches these entries: "a"/"ab"/"fellowship"/"vassals"/
// "patron"/"monarch"/"co-vassals"/etc. are intercepted by
// ChatInputParser.IsKnownVerb; "allegiance" is intercepted
// EARLIER still, by RetailClientCommandCatalog.TryMatch claiming
// the verb before A.4 dispatch is ever attempted (CH4
// REJECT-review Blocker 1, 2026-08-09) — the previous wording
// here ("IsKnownVerb intercepts them first", unqualified) was
// false for "allegiance" specifically, and that gap is exactly
// how the broadcast-to-channel bug happened: an unmatched
// subcommand fell through past both catalog and IsKnownVerb,
// all the way to this table's own "allegiance" entry.
["fellowship"] = 0x00000800u,
["fellow"] = 0x00000800u,
["fellows"] = 0x00000800u,
@ -104,12 +116,47 @@ public static class RetailChannelTagTable
ByTag.TryGetValue(tag, out channelId);
/// <summary>
/// True only for the 22 tags that have NO registered send verb — the
/// Tag strings that have a genuine send verb elsewhere — either
/// <see cref="ChatInputParser"/>'s channel-verb table, or, for
/// "allegiance"/"all", <see cref="RetailClientCommandCatalog"/>'s
/// unconditional ownership of that verb — even though they also
/// resolve in <see cref="ByTag"/> above. "olthoi" is the deliberate
/// odd one out: retail's <c>GetChannelID</c> fallback resolves it
/// PRE-Turbine (registry doc §2.3, "ol (and olthoi pre-Turbine)"), but
/// once <c>StartupTurbineChatSystem</c> runs — acdream's assumed
/// steady state, see <c>TurbineChatState</c> — "olthoi"/"o" become
/// registered Turbine verbs (<c>DoTurbineChat_Olthoi</c>, §2.4) and
/// <see cref="ChatInputParser"/> owns them instead. "ol" itself is
/// never added by <c>StartupTurbineChatSystem</c>, so it remains a
/// genuine fallback-only tag in every state.
/// </summary>
private static readonly FrozenSet<string> RegisteredVerbTags =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"fellowship", "fellow", "fellows", "f", "group", "g", "party",
"vassals", "vassal", "v",
"patron", "p",
"monarch", "m",
"covassals", "covassal", "co-vassals", "c",
"a", "ab", "allegiance",
"olthoi",
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// True only for tags that have NO registered send verb anywhere — the
/// actual reachable set of <see cref="ChatCommandRouter"/>'s A.4
/// fallback dispatch. Used by the conformance test to enumerate exactly
/// the registry doc's §2.3 fallback list.
/// </summary>
/// <remarks>
/// CH4 REJECT-review nit 12 (2026-08-09): previously excluded by
/// channel-ID membership, which wrongly reported "olthoi" as
/// unregistered — it shares id <c>0x40000000</c> with the genuinely-
/// unregistered "ol", but "olthoi" (unlike "ol") has its own
/// registered Turbine verb. Excluding by TAG STRING instead
/// (<see cref="RegisteredVerbTags"/>) fixes "olthoi" without changing
/// "ol"'s (correct, unregistered) answer.
/// </remarks>
public static bool IsUnregisteredFallbackTag(string tag) =>
ByTag.TryGetValue(tag, out uint id) && id != 0x00000800u && id != 0x00001000u
&& id != 0x00002000u && id != 0x00004000u && id != 0x01000000u && id != 0x02000000u;
ByTag.ContainsKey(tag) && !RegisteredVerbTags.Contains(tag);
}

View file

@ -238,8 +238,11 @@ public static class RetailClientCommandCatalog
// ClientCommunicationSystem::DoTitle @ 0x0057A640. Exact retail help:
// acclient_2013_pseudo_c.txt:1031162 (data_7df2c4) — "@title <new
// title> - Sets the title of the popup chat window.\n". No confirmation
// text was found at the success site; acdream stores the title but has
// no title-bar chrome to render it yet (AP-182).
// text was found at the success site; acdream's binding is a pure
// no-op (the value is neither stored nor consumed — no title-bar
// chrome exists to render it yet, AP-182; corrected 2026-08-09 at the
// CH4 REJECT-review nit 11, which found the earlier "acdream stores
// the title" wording false).
private static readonly Definition SetTitle = AnyArguments(
ClientCommandId.SetChatTitle,
"/title <new title>",
@ -305,7 +308,14 @@ public static class RetailClientCommandCatalog
ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim()));
// ClientCommunicationSystem::DoPermit @ 0x005785A0. Exact retail help:
// acclient_2013_pseudo_c.txt:1030850-1030852 (data_7dbac8).
// acclient_2013_pseudo_c.txt:1030850-1030852 (data_7dbac8). CH4
// REJECT-review SHOULD-FIX 5 (2026-08-09): DoPermit joins every token
// after "add"/"remove" into the name (JoinArgsAsName) so a multi-word
// character name works — "@permit add Aunt Agatha" grants Aunt Agatha,
// not just "Aunt". The old exactly-2-tokens gate rejected that input
// outright; the shape check now only requires a mode word plus AT
// LEAST one more token, and ExecutePermit
// (ClientCommandController.cs) joins the remainder.
private static readonly Definition Permit = new(
ClientCommandId.Permit,
Usage: "/permit <add|remove> <name>",
@ -313,7 +323,7 @@ public static class RetailClientCommandCatalog
ValidateArguments: static arguments =>
{
string[] parts = arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 2
return parts.Length >= 2
&& (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase)
|| parts[0].Equals("remove", StringComparison.OrdinalIgnoreCase));
});
@ -344,36 +354,51 @@ public static class RetailClientCommandCatalog
// ClientCommunicationSystem::DoChannelIndex @ 0x0056E640. No help
// string was extracted for the bare form; the verb is admin/advocate/
// PSR gated server-side (GameActionChannelIndex.Handle).
private static readonly Definition IndexChannels = NoArguments(
// PSR gated server-side (GameActionChannelIndex.Handle). CH4
// REJECT-review nit 14 (2026-08-09): DoChannelIndex ignores its argc —
// "@index foo" sends the SAME Event_ChannelIndex() as bare "@index" —
// so acdream must accept (and discard) any arguments too, not just none.
private static readonly Definition IndexChannels = AnyArguments(
ClientCommandId.IndexChannels,
"/index",
"@index - Requests the channel index (restricted).");
// ClientCommunicationSystem::DoChannelList @ 0x0057A9B0. Exact retail
// no-arg text: acclient_2013_pseudo_c.txt:1031202 (data_7dfaf8)
// "Please specify the channel name."
// "Please specify the channel name." CH4 REJECT-review SHOULD-FIX 6
// (2026-08-09): retail's own argc check is "!= 1" — a resolved-but-
// UNKNOWN tag still reaches the handler and raises
// HandleFailureEvent(0x422) ("That channel doesn't exist.",
// WeenieErrorMessages[0x422]); only a MISSING or MULTI-WORD argument
// prints this usage line locally. Using tag resolution itself as the
// argument-shape gate (the old behavior) silently swallowed an unknown
// tag instead of raising 0x422 — see ClientCommandController's
// dispatch (ListChannel/OnChannel/OffChannel cases) for the
// ShowWeenieError(0x422) call this shape-only gate now allows through.
private static readonly Definition ListChannel = new(
ClientCommandId.ListChannel,
Usage: "/clist <channel>",
HelpText: "@clist <channel> - Requests the member list of a channel (restricted).",
ValidateArguments: static arguments => RetailChannelTagTable.TryResolve(arguments.Trim(), out _),
ValidateArguments: static arguments => IsSingleToken(arguments),
InvalidArgumentsText: "Please specify the channel name.");
private static readonly Definition OnChannel = new(
ClientCommandId.OnChannel,
Usage: "/on <channel>",
HelpText: "@on <channel> - Joins a channel (restricted).",
ValidateArguments: static arguments => RetailChannelTagTable.TryResolve(arguments.Trim(), out _),
ValidateArguments: static arguments => IsSingleToken(arguments),
InvalidArgumentsText: "Please specify the channel name.");
private static readonly Definition OffChannel = new(
ClientCommandId.OffChannel,
Usage: "/off <channel>",
HelpText: "@off <channel> - Leaves a channel (restricted).",
ValidateArguments: static arguments => RetailChannelTagTable.TryResolve(arguments.Trim(), out _),
ValidateArguments: static arguments => IsSingleToken(arguments),
InvalidArgumentsText: "Please specify the channel name.");
private static bool IsSingleToken(string arguments) =>
arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length == 1;
// GameActionRecallAllegianceHometown.Handle. Exact retail help:
// acclient_2013_pseudo_c.txt:1031230 — "@allegiance hometown -
// Recalls you to your allegiance bindstone, if your allegiance has
@ -392,6 +417,30 @@ public static class RetailClientCommandCatalog
"/allegiance info [name]",
"@allegiance info <name> - Requests information on a member of your allegiance.");
// ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0. Exact retail
// text: acclient_2013_pseudo_c.txt:1031375 (data_7e0bd0) — "Please see
// @help Allegiance for more information on how to use this command.".
// Printed at label_57da4b (0x0057DA4B) whenever NO subcommand string
// matches ANY of the 12 retail dispatches (boot/info/chat/broadcast/
// ban/officer/title/hometown/ho/motd/name/lock/house) — retail keeps
// this ENTIRELY client-side; DoAllegiance never falls through to
// DoChannelCommand or the server for an unrecognized subcommand. CH4
// REJECT-review Blocker 1 (2026-08-09): acdream previously let an
// unmatched subcommand escape TryMatchAllegiance (return false, "not
// owned"), which fell all the way through to the unregistered-tag
// channel-fallback and broadcast the raw subcommand text ("boot Bob")
// to the legacy Allegiance channel (0x02000000) — a real chat-visible
// bug. TryMatchAllegiance below now claims ownership of "allegiance"/
// "all" UNCONDITIONALLY, exactly like retail's registered-command hash
// table does, and shows this refusal for every subcommand beyond the
// 2 ported ones (info/hometown/ho — TS-68 tracks the other 10).
private static readonly Definition AllegianceUnrecognizedSubcommand = new(
ClientCommandId.AllegianceUnrecognizedSubcommand,
Usage: "/allegiance <sub>",
HelpText: "Please see @help Allegiance for more information on how to use this command.",
ValidateArguments: static _ => false,
InvalidArgumentsText: "Please see @help Allegiance for more information on how to use this command.");
private static readonly FrozenDictionary<string, Definition> ByVerb =
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
{
@ -520,14 +569,17 @@ public static class RetailClientCommandCatalog
/// Retail's real <c>DoHouse @ 0x00580860</c> handles 15 subcommands
/// (see the registry doc §2.5b) locally; acdream Campaign CH slice CH4
/// (2026-08-09) ports 4 of them (recall/re, mansion_recall/alleg_recall/
/// ma, abandon) plus the pre-existing HasValidArguments==false swallow
/// for a MISSPELLED recall variant. Every OTHER subcommand — open,
/// close, storage, remove, boot, boot_all, remove_all, guest, available,
/// hooks, on, off — is NOT yet ported (TS-68) and must reach ACE
/// (which replies "Unknown command") rather than being swallowed
/// locally with a wrong usage message — the Tier-1 #4 fix from the
/// command-registry doc. Returning <c>false</c> here lets
/// <see cref="ChatCommandRouter"/> fall through to server passthrough.
/// ma, abandon). Every OTHER subcommand — open, close, storage, remove,
/// boot, boot_all, remove_all, guest, available, hooks, on, off, and
/// any misspelling of the 4 ported ones — returns <c>false</c>
/// uniformly (there is no separate local-swallow branch; CH4
/// REJECT-review nit 10, 2026-08-09, corrected this comment, which
/// previously described a swallow path that does not exist in the code
/// below), letting <see cref="ChatCommandRouter"/> fall through to
/// server passthrough (ACE replies "Unknown command") rather than
/// being swallowed locally with a wrong usage message — the Tier-1 #4
/// fix from the command-registry doc. The 12 unported subcommands are
/// tracked by TS-68.
/// </summary>
private static bool TryMatchHouse(string arguments, out Match match)
{
@ -556,14 +608,26 @@ public static class RetailClientCommandCatalog
/// <c>@allegiance &lt;sub&gt;</c> / <c>@all &lt;sub&gt;</c> dispatcher.
/// Retail's real <c>DoAllegiance @ 0x0057D5A0</c> handles 12
/// subcommands (see the registry doc §2.5) locally; acdream Campaign CH
/// slice CH4 (2026-08-09) ports 2 of them (info, hometown/ho). Every
/// OTHER subcommand — boot, ban, officer, title, name, lock, house,
/// motd, chat, broadcast — is NOT yet ported (TS-68) and falls through
/// to server passthrough, same reasoning as <see cref="TryMatchHouse"/>.
/// slice CH4 (2026-08-09) ports 2 of them (info, hometown/ho).
/// </summary>
/// <remarks>
/// <b>CH4 REJECT-review Blocker 1 correction (2026-08-09):</b> every
/// OTHER subcommand — boot, ban, officer, title, name, lock, house,
/// motd, chat, broadcast, or garbage — is NOT yet ported (TS-68), but
/// unlike <see cref="TryMatchHouse"/> this method NEVER returns
/// <c>false</c> for the "allegiance"/"all" verb: retail's own
/// <c>DoAllegiance</c> claims the ENTIRE verb unconditionally and
/// prints its own client-local refusal
/// (<see cref="AllegianceUnrecognizedSubcommand"/>) for an unrecognized
/// subcommand — it never falls through to <c>DoChannelCommand</c> or
/// the server. The original CH4 implementation returned <c>false</c>
/// here (matching <see cref="TryMatchHouse"/>'s reasoning), which let
/// an unmatched subcommand escape all the way to the unregistered-tag
/// channel-fallback and broadcast the raw text to the Allegiance
/// channel — a real bug, not merely an incomplete port.
/// </remarks>
private static bool TryMatchAllegiance(string arguments, out Match match)
{
match = default;
int separator = IndexOfWhitespace(arguments);
string subcommand = separator < 0 ? arguments : arguments[..separator];
string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim();
@ -591,7 +655,17 @@ public static class RetailClientCommandCatalog
return true;
}
return false;
// Every other subcommand (or none at all) — claim ownership
// anyway and show retail's own refusal text. See the remarks
// above; this is what stops "allegiance"/"all" from ever reaching
// ChatCommandRouter's channel-fallback or server-passthrough path.
match = new Match(
AllegianceUnrecognizedSubcommand.Command,
arguments,
AllegianceUnrecognizedSubcommand.Usage,
HasValidArguments: false,
AllegianceUnrecognizedSubcommand.InvalidArgumentsText);
return true;
}
/// <summary>Help line generated from the same definition routing uses.</summary>

View file

@ -14,7 +14,11 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
/// see TS-68).
///
/// <para>
/// Every entry is verbatim retail text recovered from
/// The named constants above <see cref="ByVerb"/> (<see cref="Tell"/>,
/// <see cref="Reply"/>, <see cref="Retell"/>, <see cref="MonarchReply"/>,
/// <see cref="PatronReply"/>, <see cref="Day"/>, <see cref="Log"/>,
/// <see cref="Render"/>, <see cref="Motd"/>, <see cref="AllegianceOverview"/>,
/// <see cref="HouseOverview"/>) are verbatim retail text recovered from
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c> by the
/// recipe in the command-registry doc §5 (scan each <c>Help*</c>
/// function's byte extent for <c>push imm32</c> into <c>.rdata</c>). Entries
@ -23,6 +27,19 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
/// NOT fabricated — they are simply absent from this table; the lookup
/// falls through to a generic "no detailed help" line rather than guess.
/// </para>
///
/// <para>
/// <b>Corrected 2026-08-09 at the CH4 REJECT-review, SHOULD-FIX 7:</b> the
/// paragraph above previously claimed "every entry" was verbatim retail
/// text, which was FALSE and directly contradicted <see cref="ByVerb"/>'s
/// own inline comment a few dozen lines below it. The ~35 CHANNEL
/// one-liners in <see cref="ByVerb"/> ("Sends text to your Fellowship
/// channel.", etc.) are acdream-authored SUMMARIES, not individually
/// hand-extracted retail strings — retail's own per-channel help text was
/// not recovered this slice. Recovering them (or deleting the class-doc
/// overclaim) is future work; this comment now says so honestly instead
/// of leaving the contradiction standing.
/// </para>
/// </summary>
public static class RetailCommandHelpTable
{
@ -44,17 +61,27 @@ public static class RetailCommandHelpTable
"Note: You may substitute a forward slash (/) for the at symbol (@).";
// @mr/@pr are registered with a NULL function pointer in the 2013
// build (verified at 0x00583041/0x005830C1 — arg3 is 0). Retail's own
// HelpReply @0x00577A50 is shared between @reply/@r/@rp (which DO
// execute) and @mr/@pr (which do NOT — they fall through to
// DoChannelCommand, miss, and reach the server as literal text). The
// shared help text is Reply's text above; acdream additionally notes
// the non-execution here so /help mr doesn't imply it works.
// build (verified at 0x00583041/0x005830C1 — arg3 is 0), so they never
// execute locally in retail OR acdream — typing one sends the literal
// text to the server. CH4 REJECT-review SHOULD-FIX 7 (2026-08-09):
// the strings below were previously FABRICATED acdream summaries; the
// real retail-registered help function (HelpReply @0x00577A50) is
// shared across @reply/@r/@rp/@mr/@pr and IS the source of a per-verb
// detail line for each, extracted verbatim below —
// acclient_2013_pseudo_c.txt:1030734 (data_7daa08) for @mr,
// acclient_2013_pseudo_c.txt:1030738 (data_7daa80) for @pr. Retail's
// own strings have a double space before "you" — confirmed byte-level,
// not a typo. (HelpReply's full concatenation across all 5 shared
// verbs is more involved than a single-string extraction can safely
// confirm from the pseudo-C alone — a BN decomp string-temporary
// pattern reuses the output-parameter stack slot, which risks a
// misread; only the two per-verb detail lines requested by the review
// are pinned here, not a full re-derivation of HelpReply's output.)
public const string MonarchReply =
"@mr - Reply to the last person who @m'd you (monarch chat only). NOTE: this command is registered with no handler in the named retail build — it does not execute locally in retail OR acdream; typing it sends the literal text to the server.";
"@mr <text> - Sends the text to the last person who used @m to send you a message. This only works for monarchs.";
public const string PatronReply =
"@pr - Reply to the last person who @p'd you (patron chat only). NOTE: this command is registered with no handler in the named retail build — it does not execute locally in retail OR acdream; typing it sends the literal text to the server.";
"@pr <text> - Sends the text to the last vassal who used @p to send you a message.";
// acclient_2013_pseudo_c.txt:1031093 (data_7de280).
public const string Day =