feat(chat): Campaign CH slice CH4 — command registry completion

Brings acdream's / and @ command parsing to parity with the complete
retail registry (130 registered verbs + 22 unregistered GetChannelID
fallback tags = 152 client-parsed verbs), per
docs/research/2026-08-09-chat-retail-command-registry.md.

Parser semantics (retail OnChatCommand/DoCommand):
- : and ; rewrite to "@emote <rest>" before dispatch.
- Verb trailing-comma trim ("@f, hi" == "@f hi") applied at every
  verb-lookup site in the catalog and the parser.
- @tell/aliases split the target on the FIRST COMMA, not the first
  whitespace token, so multi-word names work ("@tell Aunt Agatha, hi").
- The 22 unregistered GM/faction channel tags (admin, sentinel,
  celestialhand, ...) now broadcast for real via a new
  RetailChannelTagTable + SendRawChannelCmd bypass, reusing the existing
  BuildChatChannel wire builder.

Binding corrections:
- /g, /group, /party -> Fellowship (0x800), not General.
- /rp -> reply alias (retail's own help text confirms "@r or @rp"), not
  Roleplay; /role (an acdream invention) deleted.
- /allegiance, /all -> the allegiance management command
  (RetailClientCommandCatalog), not a channel verb.
- /house no longer swallows unrecognized subcommands with a local usage
  error; they now correctly fall through to ACE.
- @mr/@pr pinned as permanently non-executable (retail registers them
  with a null function pointer).

New verbs with real local execution: endurance, speaker, title (silent,
AP-182), chat, notell, join, leave, permit, hslist, index, clist, on,
off, alh/ah (+ "@allegiance hometown"/"ho"), "@allegiance info",
"@house abandon"; a missing-alias sweep across pkl/hou/message_types/
msgtypes/msg_types/rt/send/whisper/w/vassal/covassal/co-vassals/c/
fellows/group/party/guild/gu/cg/ct/clfg/crp/soc/o; the non-retail
inventions gen/cv/lookingforgroup/tr/role/h are deleted. New Core.Net
wire builders (IndexChannels, ListChannels, AddChannel, RemoveChannel,
RecallAllegianceHometown, AllegianceInfoRequest, ListAvailableHouses,
AddPlayerPermission, RemovePlayerPermission, AbandonHouse) are all
parameterless or single-field payloads cross-checked against ACE's
GameAction readers, not guessed.

Deferred (filed as #360/#361/#362, register rows TS-68/TS-69/TS-70):
the ~22 remaining allegiance/house subcommands + standalone @motd
(largest single item, needs its own slice per the doc), the three
still-inert pure-local commands (day/log/render), and the inbound
GameEvent responses for the new outbound requests. All correctly fall
through to ACE server-passthrough rather than being silently swallowed
or faking success.

RetailCommandRegistryConformanceTests pins the complete 152-verb
registry against production: every verb resolves through exactly one
production surface if Implemented, through none if HelpOnly/
ServerPassthrough, and two reverse-direction tests fail the build if
RetailClientCommandCatalog or ChatInputParser ever claims a verb
outside this registry again. Final tally: 138 Implemented / 5
ServerPassthrough / 9 HelpOnly = 152.

Release suite: 12,190 passed / 4 skipped / 0 failed (up from CH3's
11,964/4/0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 21:10:17 +02:00
parent 9247d5d5b5
commit 090825e703
23 changed files with 2069 additions and 97 deletions

View file

@ -122,6 +122,12 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
SendIfActive(() => bindings.SendTalk(command.Text));
});
commands.Register<SendChatCmd>(command => RouteChat(bindings, command));
// Campaign CH slice CH4 (2026-08-09): the 22 unregistered
// ChannelSystem::GetChannelID fallback tags — bypasses
// ChatChannelKind/ChannelResolver entirely and sends the raw
// legacy ChatChannel (0x0147) broadcast directly.
commands.Register<SendRawChannelCmd>(
command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text)));
commands.Register<AddShortcutRuntimeCmd>(
command => SendIfActive(() => bindings.AddShortcut(command.Entry)));
commands.Register<RemoveShortcutRuntimeCmd>(
@ -464,7 +470,22 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
HasOpenVendor: () => ReadClient(static b => b.HasOpenVendor(), false),
FillComponentBuyList: (componentId, targetCount) =>
InvokeClient(b => b.FillComponentBuyList(componentId, targetCount)),
EnterPkLite: () => InvokeClient(static b => b.EnterPkLite()));
EnterPkLite: () => InvokeClient(static b => b.EnterPkLite()),
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
IsUsingTurbineChat: () => ReadClient(static b => b.IsUsingTurbineChat(), false),
SetChatTitle: title => InvokeClient(b => b.SetChatTitle(title)),
SetSingleCharacterOption: (optionId, value) =>
InvokeClient(b => b.SetSingleCharacterOption(optionId, value)),
AddPlayerPermission: name => InvokeClient(b => b.AddPlayerPermission(name)),
RemovePlayerPermission: name => InvokeClient(b => b.RemovePlayerPermission(name)),
RequestAvailableHouses: houseType => InvokeClient(b => b.RequestAvailableHouses(houseType)),
RequestChannelIndex: () => InvokeClient(static b => b.RequestChannelIndex()),
RequestChannelList: channelId => InvokeClient(b => b.RequestChannelList(channelId)),
JoinGmChannel: channelId => InvokeClient(b => b.JoinGmChannel(channelId)),
LeaveGmChannel: channelId => InvokeClient(b => b.LeaveGmChannel(channelId)),
RecallAllegianceHometown: () => InvokeClient(static b => b.RecallAllegianceHometown()),
RequestAllegianceInfo: name => InvokeClient(b => b.RequestAllegianceInfo(name)),
AbandonHouse: () => InvokeClient(static b => b.AbandonHouse()));
private bool InvokeClient(Action<ClientCommandController.Bindings> invoke)
{

View file

@ -427,7 +427,25 @@ internal sealed class LiveSessionRuntimeFactory
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { },
EnterPkLite: session.SendEnterPkLite),
EnterPkLite: session.SendEnterPkLite,
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
IsUsingTurbineChat: () => _domain.Communication.TurbineChat.Enabled,
// No chat-window title chrome exists yet (AP-182) — retail's own
// @title has no visible confirmation on success either, so a
// silent accept is exactly as faithful as a stored-but-unread
// value would be, without inventing a consumer.
SetChatTitle: _ => { },
SetSingleCharacterOption: session.SendSetSingleCharacterOption,
AddPlayerPermission: session.SendAddPlayerPermission,
RemovePlayerPermission: session.SendRemovePlayerPermission,
RequestAvailableHouses: session.SendListAvailableHouses,
RequestChannelIndex: session.SendIndexChannels,
RequestChannelList: session.SendListChannel,
JoinGmChannel: session.SendOnChannel,
LeaveGmChannel: session.SendOffChannel,
RecallAllegianceHometown: session.SendRecallAllegianceHometown,
RequestAllegianceInfo: session.SendAllegianceInfoRequest,
AbandonHouse: session.SendAbandonHouse),
_domain.Communication.Chat,
_domain.Communication.TurbineChat,
PlayerGuid: () => _player.Identity.ServerGuid,

View file

@ -2,6 +2,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Ui;
using AcDream.Core.Social;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI;
@ -58,7 +59,21 @@ public sealed class ClientCommandController
Action ClearDesiredComponents,
Func<bool> HasOpenVendor,
Action<uint?, uint> FillComponentBuyList,
Action EnterPkLite);
Action EnterPkLite,
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
Func<bool> IsUsingTurbineChat,
Action<string> SetChatTitle,
Action<uint, bool> SetSingleCharacterOption,
Action<string> AddPlayerPermission,
Action<string> RemovePlayerPermission,
Action<uint> RequestAvailableHouses,
Action RequestChannelIndex,
Action<uint> RequestChannelList,
Action<uint> JoinGmChannel,
Action<uint> LeaveGmChannel,
Action RecallAllegianceHometown,
Action<string> RequestAllegianceInfo,
Action AbandonHouse);
private readonly Bindings _bindings;
@ -133,8 +148,15 @@ public sealed class ClientCommandController
case ClientCommandId.ToggleUiLock:
_bindings.ToggleUiLock();
break;
// ClientCommunicationSystem::DoVersion @ 0x0057E1B0. Exact
// retail text: acclient_2013_pseudo_c.txt:1031450/1031454
// (data_7e0eec "Client version %s\n" / data_7e0f00 "Using
// Turbine Chat.\n"). PSR-only extra lines are not modeled —
// acdream claims no privileged PSR mode.
case ClientCommandId.ShowVersion:
_bindings.ShowSystemMessage($"Client version {_bindings.ClientVersion()}");
_bindings.ShowSystemMessage(_bindings.IsUsingTurbineChat()
? $"Client version {_bindings.ClientVersion()}\nUsing Turbine Chat."
: $"Client version {_bindings.ClientVersion()}");
break;
case ClientCommandId.ShowLocation:
Position? position = _bindings.CurrentPosition();
@ -222,6 +244,90 @@ public sealed class ClientCommandController
case ClientCommandId.FillComponents:
ExecuteFillComponents(command.Arguments);
break;
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
// ClientCommunicationSystem::DoEndurance @ 0x0057C5F0.
case ClientCommandId.Endurance:
_bindings.ShowSystemMessage(RetailEnduranceText);
break;
// ClientCommunicationSystem::DoSpeaker @ 0x0057DAB0.
case ClientCommandId.Speaker:
_bindings.ShowSystemMessage(
"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).
case ClientCommandId.SetChatTitle:
_bindings.SetChatTitle(command.Arguments.Trim());
break;
// ClientCommunicationSystem::DoChatToggle @ 0x0056FAD0 —
// "on" removes the global Speech (type 2) squelch, "off" adds it.
case ClientCommandId.ChatToggle:
_bindings.ModifyGlobalSquelch(
command.Arguments.Equals("off", StringComparison.OrdinalIgnoreCase),
2u);
break;
// ClientCommunicationSystem::DoNoTell @ 0x0056FBD0 — same
// mechanism, message type 3 (Tell). "on" (retail: "you will
// not receive any tells") ADDS the squelch.
case ClientCommandId.NoTellToggle:
_bindings.ModifyGlobalSquelch(
command.Arguments.Equals("on", StringComparison.OrdinalIgnoreCase),
3u);
break;
// ClientCommunicationSystem::DoJoinChat @ 0x0056F510.
case ClientCommandId.JoinChannel:
if (RetailClientCommandCatalog.TryResolveJoinLeaveOption(command.Arguments, out uint joinOption))
_bindings.SetSingleCharacterOption(joinOption, true);
break;
// ClientCommunicationSystem::DoLeaveChat @ 0x0056F7F0.
case ClientCommandId.LeaveChannel:
if (RetailClientCommandCatalog.TryResolveJoinLeaveOption(command.Arguments, out uint leaveOption))
_bindings.SetSingleCharacterOption(leaveOption, false);
break;
// ClientCommunicationSystem::DoPermit @ 0x005785A0.
case ClientCommandId.Permit:
ExecutePermit(command.Arguments);
break;
// ClientCommunicationSystem::DoHouseAvailableList @ 0x00570510.
case ClientCommandId.HouseAvailableList:
if (RetailClientCommandCatalog.TryResolveHouseType(command.Arguments, out uint houseType))
_bindings.RequestAvailableHouses(houseType);
break;
// ClientCommunicationSystem::DoChannelIndex @ 0x0056E640.
case ClientCommandId.IndexChannels:
_bindings.RequestChannelIndex();
break;
// ClientCommunicationSystem::DoChannelList @ 0x0057A9B0.
case ClientCommandId.ListChannel:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint listChannelId))
_bindings.RequestChannelList(listChannelId);
break;
// ClientCommunicationSystem::DoChannelOn @ 0x0057AA80.
case ClientCommandId.OnChannel:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint onChannelId))
_bindings.JoinGmChannel(onChannelId);
break;
// ClientCommunicationSystem::DoChannelOff @ 0x0057AB50.
case ClientCommandId.OffChannel:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint offChannelId))
_bindings.LeaveGmChannel(offChannelId);
break;
// GameActionRecallAllegianceHometown — @alh/@ah/"@allegiance hometown".
case ClientCommandId.AllegianceHometown:
_bindings.RecallAllegianceHometown();
break;
// GameActionAllegianceInfoRequest — "@allegiance info [name]".
case ClientCommandId.AllegianceInfo:
_bindings.RequestAllegianceInfo(command.Arguments.Trim());
break;
// GameActionHouseAbandon — "@house abandon".
case ClientCommandId.HouseAbandon:
_bindings.AbandonHouse();
break;
default:
throw new ArgumentOutOfRangeException(
nameof(command), command.Command, "Unknown retail client command.");
@ -538,6 +644,19 @@ public sealed class ClientCommandController
.Select(pair => pair.Value));
}
// ClientCommunicationSystem::DoPermit @ 0x005785A0. Argument shape
// already validated by RetailClientCommandCatalog (exactly "add <name>"
// or "remove <name>").
private void ExecutePermit(string arguments)
{
string[] parts = SplitArguments(arguments);
string name = parts[1];
if (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase))
_bindings.AddPlayerPermission(name);
else
_bindings.RemovePlayerPermission(name);
}
private void ExecuteFillComponents(string arguments)
{
string[] parts = SplitArguments(arguments);
@ -662,6 +781,18 @@ public sealed class ClientCommandController
+ "Note: These commands should be bound on either side by asterisks. (Example: *wave*)\n"
+ "ShakeFist; Beckon; BeSeeingYou; BlowKiss; BowDeep; ClapHands; Cry; Laugh; Nod; Point; Shrug; Wave; Akimbo; HeartyLaugh; Salute; TapFoot; WaveHigh; WaveLow; Yawn; Stretch; Cringe; Kneel; Plead; Shiver; Shoo; Slouch; Spit; Surrender; Woah; Winded; YMCA; Eat; Drink; Teapot; Pray; Mock; Cheer; Helper; Warm Hands; Scratch Head; Shake Head\n\n";
// ClientCommunicationSystem::DoEndurance @ 0x0057C5F0. Exact retail
// text: acclient_2013_pseudo_c.txt:1031097 (data_7de2f8), verbatim.
private const string RetailEnduranceText =
"The endurance attribute has a number of abilities tied to it.\n"
+ "First, some combination of strength and endurance (with endurance being more important) now allows one to regenerate hit points at a faster rate the higher one's endurance is. This bonus is in addition to any regeneration spells one may have placed upon themselves. This endurance regeneration bonus caps at around 110%.\n"
+ "Second, the higher a player's Endurance, the less stamina one uses while attacking. This benefit is tied to Endurance only, and it caps out at around 50% less stamina used per attack. The minimum stamina used per attack remains one.\n"
+ "Third, the higher a player's Endurance, the more likely they are not to use a point of stamina to successfully evade a missile or melee attack. A player is required to have Melee Defense for melee attacks or Missile Defense for missile attacks trained or specialized in order for this specific ability to work. This benefit is tied to Endurance only, and it caps out at around a 75% chance to avoid losing a point of stamina per successful evasion.\n"
+ "Fourth, some combination of strength and endurance (the two are roughly of equivalent importance) now allows one to partially resist drain and harm attacks, up to a maximum of roughly 50%.\n"
+ "Fifth, some combination of strength and endurance (the two are roughly of equivalent importance) now allows one to have a level of \"natural resistances\" to the 7 damage types, the same as a certain level of life protections. This caps out at a 50% resistance (the equivalent to level 5 life prots) to these damage types. This resistance is not additive to life protections: higher level life protections will overwrite these natural resistances, although life vulns will take these natural resistances into account, if the player does not have a higher level life protection cast upon him.\n"
+ "The natural resistances, drain resistances, and regeneration rate info are now visible on the Character Information Panel, in what was once the Burden panel. This panel now displays the above three Endurance benefits, the burden info, as well as information about your age, birth date, and number of deaths.\n"
+ "The 5 categories for the endurance benefits are, in order from lowest benefit to highest: Poor, Mediocre, Hardy, Resilient, and Indomitable, with each range of benefits divided up equally amongst the 5 (e.g. Poor describes having anywhere from 1-10% resistance against drain health attacks, etc.).\n";
private const string AwayHelp =
"@afk - Turns on AFK (away-from-keyboard) mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n"
+ "@afk on - Turns on AFK mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n"

View file

@ -40,6 +40,25 @@ public static class ClientCommandRequests
public const uint RemoveSpellOpcode = 0x01A8u;
public const uint LegacyFriendsOpcode = 0xF7CDu;
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
// GameActionType opcodes cross-checked against ACE
// (Source/ACE.Server/Network/GameAction/GameActionType.cs) — ACE's
// numbering is the same wire protocol retail uses, matching this
// file's existing cross-check convention (see ClientCommandRequests
// class doc). Each handler's payload shape was read directly from
// ACE's GameAction Actions/*.cs reader (cited per-method below) rather
// than guessed.
public const uint IndexChannelsOpcode = 0x0149u;
public const uint ListChannelsOpcode = 0x0148u;
public const uint AddChannelOpcode = 0x0145u;
public const uint RemoveChannelOpcode = 0x0146u;
public const uint RecallAllegianceHometownOpcode = 0x02ABu;
public const uint AllegianceInfoRequestOpcode = 0x027Bu;
public const uint ListAvailableHousesOpcode = 0x0270u;
public const uint AddPlayerPermissionOpcode = 0x0219u;
public const uint RemovePlayerPermissionOpcode = 0x021Au;
public const uint AbandonHouseOpcode = 0x021Fu;
// Named-retail anchors:
// CM_Character::Event_TeleToMarketplace @ 0x006A1C20
// CM_Character::Event_TeleToPKArena @ 0x006A1CB0
@ -216,6 +235,53 @@ public static class ClientCommandRequests
public static byte[] BuildRemoveSpell(uint sequence, uint spellId) =>
BuildUInt32(sequence, RemoveSpellOpcode, spellId);
// @index — GameActionChannelIndex.Handle: no payload read.
public static byte[] BuildIndexChannels(uint sequence) =>
BuildParameterless(sequence, IndexChannelsOpcode);
// @clist <channel> — GameActionChannelList.Handle:
// ReadUInt32() as the Channel bitflag.
public static byte[] BuildListChannel(uint sequence, uint channelId) =>
BuildUInt32(sequence, ListChannelsOpcode, channelId);
// @on <channel> — GameActionAddChannel.Handle: ReadUInt32() Channel id.
public static byte[] BuildOnChannel(uint sequence, uint channelId) =>
BuildUInt32(sequence, AddChannelOpcode, channelId);
// @off <channel> — GameActionRemoveChannel.Handle: ReadUInt32() Channel id.
public static byte[] BuildOffChannel(uint sequence, uint channelId) =>
BuildUInt32(sequence, RemoveChannelOpcode, channelId);
// @alh / @ah / "@allegiance hometown" —
// GameActionRecallAllegianceHometown.Handle: no payload read.
public static byte[] BuildRecallAllegianceHometown(uint sequence) =>
BuildParameterless(sequence, RecallAllegianceHometownOpcode);
// "@allegiance info [name]" — GameActionAllegianceInfoRequest.Handle:
// ReadString16L() player name (empty string = self).
public static byte[] BuildAllegianceInfoRequest(uint sequence, string playerName) =>
BuildString(sequence, AllegianceInfoRequestOpcode, playerName);
// @hslist <type> / "@house available" —
// GameActionHouseListAvailable.Handle: ReadUInt32() as the ACE
// HouseType enum (Undef=0, Cottage=1, Villa=2, Mansion=3, Apartment=4).
public static byte[] BuildListAvailableHouses(uint sequence, uint houseType) =>
BuildUInt32(sequence, ListAvailableHousesOpcode, houseType);
// @permit add <name> — GameActionAddPlayerPermission.Handle:
// ReadString16L() player name.
public static byte[] BuildAddPlayerPermission(uint sequence, string playerName) =>
BuildString(sequence, AddPlayerPermissionOpcode, playerName);
// @permit remove <name> — GameActionRemovePlayerPermission.Handle:
// ReadString16L() player name.
public static byte[] BuildRemovePlayerPermission(uint sequence, string playerName) =>
BuildString(sequence, RemovePlayerPermissionOpcode, playerName);
// "@house abandon" — GameActionHouseAbandon.Handle: no payload read.
public static byte[] BuildAbandonHouse(uint sequence) =>
BuildParameterless(sequence, AbandonHouseOpcode);
private static byte[] BuildParameterless(uint sequence, uint opcode)
{
byte[] body = new byte[12];

View file

@ -2245,6 +2245,77 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildModifyGlobalSquelch(seq, add, messageType));
}
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
/// <summary>Send retail @index (0x0149).</summary>
public void SendIndexChannels()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildIndexChannels(seq));
}
/// <summary>Send retail @clist &lt;channel&gt; (0x0148).</summary>
public void SendListChannel(uint channelId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildListChannel(seq, channelId));
}
/// <summary>Send retail @on &lt;channel&gt; (0x0145).</summary>
public void SendOnChannel(uint channelId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildOnChannel(seq, channelId));
}
/// <summary>Send retail @off &lt;channel&gt; (0x0146).</summary>
public void SendOffChannel(uint channelId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildOffChannel(seq, channelId));
}
/// <summary>Send retail @alh / @ah / "@allegiance hometown" (0x02AB).</summary>
public void SendRecallAllegianceHometown()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRecallAllegianceHometown(seq));
}
/// <summary>Send retail "@allegiance info [name]" (0x027B).</summary>
public void SendAllegianceInfoRequest(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAllegianceInfoRequest(seq, playerName));
}
/// <summary>Send retail @hslist &lt;type&gt; (0x0270).</summary>
public void SendListAvailableHouses(uint houseType)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildListAvailableHouses(seq, houseType));
}
/// <summary>Send retail @permit add &lt;name&gt; (0x0219).</summary>
public void SendAddPlayerPermission(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddPlayerPermission(seq, playerName));
}
/// <summary>Send retail @permit remove &lt;name&gt; (0x021A).</summary>
public void SendRemovePlayerPermission(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemovePlayerPermission(seq, playerName));
}
/// <summary>Send retail "@house abandon" (0x021F).</summary>
public void SendAbandonHouse()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAbandonHouse(seq));
}
public void SendClearConsent()
{
uint seq = NextGameActionSequence();

View file

@ -41,4 +41,38 @@ public enum ClientCommandId
Unfilter,
ListMessageTypes,
FillComponents,
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
/// <summary>@endurance — fixed help paragraph about the Endurance attribute.</summary>
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>
SetChatTitle,
/// <summary>@chat on|off — global Speech squelch toggle (message type 2).</summary>
ChatToggle,
/// <summary>@notell on|off — global Tell squelch toggle (message type 3).</summary>
NoTellToggle,
/// <summary>@join &lt;channel tag&gt; — sets the matching PlayerModule::Hear*Chat option on.</summary>
JoinChannel,
/// <summary>@leave &lt;channel tag&gt; — clears the matching PlayerModule::Hear*Chat option.</summary>
LeaveChannel,
/// <summary>@permit add|remove &lt;name&gt; — corpse-looting permission management.</summary>
Permit,
/// <summary>@hslist &lt;type&gt; / "@house available" — list houses available for purchase.</summary>
HouseAvailableList,
/// <summary>@index — request the channel index (admin/advocate/PSR only server-side).</summary>
IndexChannels,
/// <summary>@clist &lt;channel&gt; — request the member list of a channel.</summary>
ListChannel,
/// <summary>@on &lt;channel&gt; — join a GM/faction channel.</summary>
OnChannel,
/// <summary>@off &lt;channel&gt; — leave a GM/faction channel.</summary>
OffChannel,
/// <summary>@alh / @ah / "@allegiance hometown" / "@allegiance ho" — recall to the allegiance bindstone.</summary>
AllegianceHometown,
/// <summary>"@allegiance info [name]" — request allegiance member info.</summary>
AllegianceInfo,
/// <summary>"@house abandon" — abandon the character's house.</summary>
HouseAbandon,
}

View file

@ -13,8 +13,10 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped
/// and retained retail chat window route through here.
///
/// <para>
/// Flow: retail client-command catalog, local presentation command,
/// degenerate-prefix guard, explicit server command, then chat parse. Unknown
/// Flow: emote-prefix rewrite, retail client-command catalog, local
/// presentation command, degenerate-prefix guard, the retail
/// <c>ChannelSystem::GetChannelID</c> fallback (unregistered GM/faction
/// channel tags), explicit server command, then chat parse. Unknown
/// slash/at verbs publish <see cref="SendServerCommandCmd"/> in canonical
/// <c>@</c> form; the App host sends those through Talk, the only wire path
/// ACE parses commands on. Prefix text with no letter verb is refused locally
@ -32,6 +34,18 @@ public static class ChatCommandRouter
if (trimmed.Length == 0)
return SubmitOutcome.Empty;
// Retail OnChatCommand @ 0x0058144D, cases 0x0B/0x0C (':'/';'):
// the first character is replaced with a space and the whole line
// is prefixed with the literal "@emote" before dispatch —
// ":waves" == ";waves" == "@emote waves". Campaign CH slice CH4
// (2026-08-09). Both prefix characters are handled IDENTICALLY per
// the command-registry doc (a single sentence covers both cases);
// this is not a guess.
if (trimmed[0] is ':' or ';')
{
trimmed = "@emote " + trimmed[1..];
}
if (RetailClientCommandCatalog.TryMatch(trimmed, out var clientCommand))
{
if (!clientCommand.HasValidArguments)
@ -59,6 +73,23 @@ public static class ChatCommandRouter
return SubmitOutcome.UnknownCommand;
}
// Campaign CH slice CH4 (2026-08-09), doc §1/§2.3: retail's
// DoCommand falls through to DoChannelCommand @ 0x005774A0 for any
// verb absent from the registered-command hash table. That
// function tries ChannelSystem::GetChannelID on the verb and, on a
// hit, broadcasts to the channel — this is how the 22 unregistered
// GM/faction tags (@admin, @sentinel, @celestialhand, ...) work in
// retail despite never being "registered". Verbs ChatInputParser
// already knows (the registered legacy/Turbine channels) are
// excluded here so they keep riding their normal
// self-echo/Turbine-gated SendChatCmd path below — GetChannelID
// would resolve them too, but retail never reaches this fallback
// for a REGISTERED verb (it's intercepted by the main hash table
// first).
SubmitOutcome? fallbackOutcome = TryDispatchChannelFallback(trimmed, vm, bus);
if (fallbackOutcome is { } outcome)
return outcome;
if (TryBuildServerCommand(trimmed, out string serverCommand))
{
bus.Publish(new SendServerCommandCmd(serverCommand));
@ -76,6 +107,39 @@ public static class ChatCommandRouter
return SubmitOutcome.Dropped;
}
/// <summary>
/// Returns null when the verb is not one of the 22 unregistered
/// fallback channel tags (caller continues its own dispatch chain);
/// otherwise returns the outcome to return immediately.
/// </summary>
private static SubmitOutcome? TryDispatchChannelFallback(string trimmed, ChatVM vm, ICommandBus bus)
{
if (trimmed[0] is not ('/' or '@'))
return null;
string verb = ChatInputParser.GetVerbToken(trimmed);
string normalizedChatVerb = "/" + verb[1..].TrimEnd(',');
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;
int separator = trimmed.IndexOfAny([' ', '\t']);
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;
}
bus.Publish(new SendRawChannelCmd(channelId, text));
return SubmitOutcome.Sent;
}
private static bool TryBuildServerCommand(string trimmed, out string command)
{
command = string.Empty;
@ -93,15 +157,40 @@ public static class ChatCommandRouter
private static bool TryHandleLocalPresentationCommand(string trimmed, ChatVM vm)
{
if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h"))
if (EqAny(trimmed, "/help", "/?", "@help", "@?"))
{
vm.ShowSystemMessage(BuildHelpText());
return true;
}
// Campaign CH slice CH4 (2026-08-09), Tier 2 item 15: "/help <verb>"
// / "@help <verb>" — retail's DoHelp @ 0x0057F9E0 looks the verb up
// and calls its registered help callback. Non-retail "/h" alias
// deleted per the command-registry doc §4's removal list.
if (StartsWithAny(trimmed, "/help ", "@help ", "/? ", "@? "))
{
string verb = trimmed[(trimmed.IndexOf(' ') + 1)..].Trim();
vm.ShowSystemMessage(BuildVerbHelpText(verb));
return true;
}
return false;
}
private static string BuildVerbHelpText(string verb)
{
if (verb.Length == 0)
return BuildHelpText();
string normalized = verb.TrimStart('/', '@');
if (RetailClientCommandCatalog.TryGetHelpText(normalized, out string catalogText))
return catalogText;
if (RetailCommandHelpTable.TryGetHelpText(normalized, out string tableText))
return tableText;
return $"No help available for '{verb}'.";
}
private static bool EqAny(string value, params string[] options)
{
for (int i = 0; i < options.Length; i++)
@ -113,13 +202,24 @@ public static class ChatCommandRouter
return false;
}
private static bool StartsWithAny(string value, params string[] options)
{
for (int i = 0; i < options.Length; i++)
{
if (value.StartsWith(options[i], StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static string BuildHelpText() =>
"Note: / and @ are equivalent prefixes.\n" +
"Chat: /say (default), /tell <name>, /reply, /retell\n" +
"Channels: /general /trade /fellowship /allegiance\n" +
$"{RetailCommandHelpTable.HelpPrefixNote}\n" +
"Chat: /say (default), /tell <name>, <text>, /reply, /retell\n" +
"Channels: /general /trade /fellowship /a (allegiance room)\n" +
" /patron /vassals /monarch /covassals\n" +
" /lfg /roleplay /society /olthoi\n" +
"Client: /help (this) /clear /framerate /loc\n" +
"Client: /help [command] (this) /clear /framerate /loc\n" +
$" {RetailClientCommandCatalog.BuildHelpText()}\n" +
"Server: type @acehelp or @acecommands for ACE's full list.";
}

View file

@ -39,29 +39,61 @@ public static class ChatInputParser
// Alias tables. Order matters only for error messages — verb
// matching is exact-token, not prefix.
private static readonly string[] SayAliases = { "/say", "/s" };
private static readonly string[] TellAliases = { "/tell", "/t" };
private static readonly string[] ReplyAliases = { "/reply", "/r" };
// Campaign CH slice CH4 (2026-08-09): retail's DoTell @0x00577E40
// registers "tell" under FOUR verb strings — tell/t/send/whisper/w —
// per the command-registry doc §2.2.
private static readonly string[] TellAliases = { "/tell", "/t", "/send", "/whisper", "/w" };
// Campaign CH slice CH4 (2026-08-09): retail's DoReply @0x00577910
// registers "reply" under THREE verb strings — reply/r/rp — confirmed
// by retail's own help text ("You may also use @r or @rp",
// acclient_2013_pseudo_c.txt:1030742). "/rp" moved here from the
// Roleplay channel table below, where it was a Tier-1 correctness bug
// (a private reply becoming a global Roleplay broadcast).
private static readonly string[] ReplyAliases = { "/reply", "/r", "/rp" };
// Phase J Tier 2: /retell <msg> — resend to last person YOU tell'd.
// Mirrors retail's @retell. Distinct from /reply which targets the
// last person who tell'd US.
private static readonly string[] RetellAliases = { "/retell" };
// last person who tell'd US. Campaign CH slice CH4 added the "/rt"
// alias — retail's DoReTell registers both "retell" and "rt".
private static readonly string[] RetellAliases = { "/retell", "/rt" };
// Channel aliases. Each maps a single verb token to a channel kind.
// The same list drives both the verb test and the prefix-strip.
// Long-form aliases mirror retail muscle memory (e.g. "/allegiance"
// for "/a", "/patron" for "/p"). Phase J added the long forms after
// a 2026-04-25 live session showed "/patron hello" falling through
// as plain Say with the literal "/patron " prefix.
// Long-form aliases mirror retail muscle memory (e.g. "/patron" for
// "/p"). Phase J added the long forms after a 2026-04-25 live session
// showed "/patron hello" falling through as plain Say with the literal
// "/patron " prefix.
//
// Campaign CH slice CH4 (2026-08-09) reconciled this table against the
// retail command-registry doc §2.3/§2.4 (Tier 1 fixes #1-3, alias
// sweep #16):
// - "/g" moves from General to FELLOWSHIP (retail: g/group/party/
// fellow/fellows/fellowship all bind Fellowship, 0x800). Sending
// fellowship chatter to General was a live correctness bug.
// - "/allegiance" is DELETED — retail's allegiance/all is the
// allegiance MANAGEMENT COMMAND (RetailClientCommandCatalog),
// not a channel verb. The channel-send verbs are a/ab/guild/gu.
// - "/gen", "/cv", "/lookingforgroup", "/tr", "/role" are DELETED —
// none are registered retail verbs (doc §4 "candidates for
// removal").
// - Missing retail aliases added: guild, gu (Allegiance Turbine);
// co-vassals, covassal, c (CoVassals); vassal (Vassals); cg
// (General Turbine); ct (Trade Turbine); crp (Roleplay Turbine);
// clfg (LFG Turbine); soc (Society Turbine); o (Olthoi Turbine);
// fellows, group, party (Fellowship).
private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs =
{
("/g", ChatChannelKind.General),
("/general", ChatChannelKind.General),
("/gen", ChatChannelKind.General),
("/cg", ChatChannelKind.General),
("/f", ChatChannelKind.Fellowship),
("/fellow", ChatChannelKind.Fellowship),
("/fellows", ChatChannelKind.Fellowship),
("/fellowship", ChatChannelKind.Fellowship),
("/g", ChatChannelKind.Fellowship),
("/group", ChatChannelKind.Fellowship),
("/party", ChatChannelKind.Fellowship),
("/a", ChatChannelKind.Allegiance),
("/allegiance", ChatChannelKind.Allegiance),
("/guild", ChatChannelKind.Allegiance),
("/gu", ChatChannelKind.Allegiance),
// CH3 (2026-08-09): retail's @ab — DoAllegianceBroadcast, the
// legacy 0x02000000 monarch/speaker broadcast — confirmed against
// the retail command registry (§2.3/§2.5). "/allegiancebroadcast"
@ -74,18 +106,22 @@ public static class ChatInputParser
("/p", ChatChannelKind.Patron),
("/patron", ChatChannelKind.Patron),
("/v", ChatChannelKind.Vassals),
("/vassal", ChatChannelKind.Vassals),
("/vassals", ChatChannelKind.Vassals),
("/cv", ChatChannelKind.CoVassals),
("/c", ChatChannelKind.CoVassals),
("/covassal", ChatChannelKind.CoVassals),
("/covassals", ChatChannelKind.CoVassals),
("/co-vassals", ChatChannelKind.CoVassals),
("/lfg", ChatChannelKind.Lfg),
("/lookingforgroup", ChatChannelKind.Lfg),
("/clfg", ChatChannelKind.Lfg),
("/trade", ChatChannelKind.Trade),
("/tr", ChatChannelKind.Trade),
("/role", ChatChannelKind.Roleplay),
("/rp", ChatChannelKind.Roleplay),
("/ct", ChatChannelKind.Trade),
("/crp", ChatChannelKind.Roleplay),
("/roleplay", ChatChannelKind.Roleplay),
("/society", ChatChannelKind.Society),
("/soc", ChatChannelKind.Society),
("/olthoi", ChatChannelKind.Olthoi),
("/o", ChatChannelKind.Olthoi),
};
/// <summary>
@ -123,7 +159,7 @@ public static class ChatInputParser
{
string substituted = "/" + trimmed.Substring(1);
string verb = ExtractVerb(substituted);
if (AllKnownVerbs.Contains(verb))
if (IsKnownVerb(verb))
{
return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget);
}
@ -193,10 +229,17 @@ public static class ChatInputParser
// ── helpers ──────────────────────────────────────────────────────
/// <summary>
/// Match holtburger's <c>parse_targeted_chat_command</c>: split on
/// first whitespace into verb / rest, check verb against aliases,
/// then split rest into target / message. Returns false if either
/// the verb is wrong or target / message is empty.
/// Match holtburger's <c>parse_targeted_chat_command</c>, corrected for
/// retail's ACTUAL <c>@tell</c> shape (Campaign CH slice CH4,
/// 2026-08-09): split on first whitespace into verb / rest, check verb
/// against aliases, then split rest on the FIRST COMMA into target /
/// message — retail's <c>DoTell @ 0x00577E40</c> requires a comma after
/// the name ("you must put a comma after the character's name",
/// acclient_2013_pseudo_c.txt:1030771) precisely because names can be
/// multiple words ("@tell Aunt Agatha, hello" addresses "Aunt Agatha").
/// Splitting on the first WHITESPACE (the old behavior) truncated
/// multi-word names to their first token. Returns false if either the
/// verb is wrong, there's no comma, or target / message is empty.
/// </summary>
private static bool TryParseTargeted(string command, string[] aliases, out string target, out string message)
{
@ -206,25 +249,35 @@ public static class ChatInputParser
int firstWs = IndexOfWhitespace(command);
if (firstWs < 0) return false;
var verb = command.Substring(0, firstWs);
var verb = TrimVerbComma(command.Substring(0, firstWs));
if (!ContainsExact(aliases, verb)) return false;
var rest = command.Substring(firstWs + 1).TrimStart();
if (rest.Length == 0) return false;
int targetEnd = IndexOfWhitespace(rest);
if (targetEnd < 0) return false; // target only, no message
int commaIndex = rest.IndexOf(',');
if (commaIndex < 0)
{
// No comma at all: retail's help text is explicit that one is
// required. Fall back to the pre-CH4 whitespace split so a
// single-word target typed without a comma ("/t Bestie hi")
// still works — this is strictly more permissive than retail,
// not less, and every existing single-word-target test still
// passes. Pre-existing Phase I fix: strip trailing punctuation
// other than comma too (":", ".", "!", "?", ";") for the same
// "retail muscle memory" reason, now that comma itself is
// handled by the branch above.
int targetEnd = IndexOfWhitespace(rest);
if (targetEnd < 0) return false; // target only, no message
target = rest.Substring(0, targetEnd).TrimEnd(',', ';', ':', '.', '!', '?');
message = rest.Substring(targetEnd + 1).TrimStart();
}
else
{
target = rest.Substring(0, commaIndex).TrimEnd();
message = rest.Substring(commaIndex + 1).TrimStart();
}
target = rest.Substring(0, targetEnd);
message = rest.Substring(targetEnd + 1).TrimStart();
// Phase I (post-launch fix): retail muscle memory is
// "/t Name, message" — comma is the separator. Our split-on-
// whitespace pulls "Name," (with trailing comma) as the target,
// which then 0x052B-fails on the server lookup. Strip a
// trailing punctuation from the target so both forms work:
// "/t Caith hi" -> target="Caith"
// "/t Caith, hi" -> target="Caith"
target = target.TrimEnd(',', ';', ':', '.', '!', '?');
if (target.Length == 0 || message.Length == 0) return false;
return true;
}
@ -240,7 +293,7 @@ public static class ChatInputParser
int firstWs = IndexOfWhitespace(command);
if (firstWs < 0) return false;
var verb = command.Substring(0, firstWs);
var verb = TrimVerbComma(command.Substring(0, firstWs));
if (!ContainsExact(aliases, verb)) return false;
message = command.Substring(firstWs + 1).TrimStart();
@ -253,8 +306,9 @@ public static class ChatInputParser
/// </summary>
private static bool IsBareVerb(string command, string[] aliases)
{
string trimmedVerb = TrimVerbComma(command);
foreach (var alias in aliases)
if (command == alias) return true;
if (trimmedVerb == alias) return true;
return false;
}
@ -268,7 +322,7 @@ public static class ChatInputParser
{
int firstWs = IndexOfWhitespace(command);
if (firstWs < 0) return false;
var verb = command.Substring(0, firstWs);
var verb = TrimVerbComma(command.Substring(0, firstWs));
if (!ContainsExact(aliases, verb)) return false;
var rest = command.Substring(firstWs + 1).TrimStart();
@ -328,9 +382,27 @@ public static class ChatInputParser
/// need to distinguish "unknown slash command" from "known
/// verb with bad arguments" without reproducing the alias
/// tables. <c>@</c>-prefixed verbs need to be normalized to
/// <c>/</c> before passing.
/// <c>/</c> before passing. Trims a trailing comma first — retail's
/// <c>DoCommand @ 0x0057E2E0</c> right-trims <c>','</c> off the verb
/// token before ANY lookup (Campaign CH slice CH4, 2026-08-09), so
/// <c>"/f,"</c> is recognized exactly like <c>"/f"</c>.
/// </summary>
public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(verb);
public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(TrimVerbComma(verb));
/// <summary>
/// Every chat-alias / channel verb this parser recognizes (with
/// leading <c>/</c>). Used by the CH4 conformance test to enforce the
/// ownership rule in both directions.
/// </summary>
public static IReadOnlyCollection<string> KnownVerbs => AllKnownVerbs;
/// <summary>
/// Right-trim a trailing <c>','</c> from a verb token — retail's
/// <c>DoCommand</c> trim-char set (<c>0x0079452C</c>, right-trim only)
/// applied before every verb-hash-table lookup. <c>"@f, hi"</c> ≡
/// <c>"@f hi"</c>.
/// </summary>
private static string TrimVerbComma(string verb) => verb.TrimEnd(',');
/// <summary>
/// Pull the first whitespace-separated token (the command verb)

View file

@ -0,0 +1,115 @@
using System.Collections.Frozen;
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <summary>
/// Retail's <c>ChannelSystem::GetChannelID @ 0x005CF1F0</c> — every legacy
/// <c>ChatChannel</c> bitflag tag, both the ones with a registered send verb
/// (fellowship/vassals/patron/monarch/covassals/allegiance-broadcast — those
/// ride <see cref="ChatInputParser"/>'s normal channel-verb path) and the 22
/// GM/faction tags that have NO registered verb and are only reachable
/// through <c>DoChannelCommand @ 0x005774A0</c>'s fallback.
///
/// <para>
/// Two production consumers:
/// <list type="bullet">
/// <item><see cref="ChatCommandRouter"/>'s A.4 fallback dispatch — an
/// unrecognized <c>/</c>/<c>@</c> verb that is NOT already a known
/// <see cref="ChatInputParser"/> verb gets one more lookup here before
/// falling through to server passthrough. Only the 22 unregistered tags
/// are ever actually reached this way, since every registered tag is
/// intercepted earlier by <see cref="ChatInputParser.IsKnownVerb"/>.</item>
/// <item><c>@clist</c>/<c>@on</c>/<c>@off</c> argument resolution
/// (<see cref="RetailClientCommandCatalog"/>) — these three commands
/// accept ANY channel tag, registered or not, so they consult the full
/// table.</item>
/// </list>
/// </para>
///
/// <para>
/// Bit values cross-checked against
/// <c>references/ACE/Source/ACE.Entity/Enum/Channel.cs</c> (ACE's own
/// <c>[Flags] enum Channel</c>, which documents the exact retail PDB
/// S_CONSTANT dump for every id) — byte-identical to the values recovered
/// from <c>docs/research/2026-08-09-chat-retail-command-registry.md</c> §2.3.
/// <c>Help (0x400)</c> is deliberately excluded: retail's own
/// <c>DoChannelCommand</c> explicitly rejects it
/// (<c>id == 0 || id == 0x400</c> → return 0), and it can never reach this
/// table anyway since <c>/help</c>/<c>@help</c> is intercepted earlier by
/// <see cref="ChatCommandRouter"/>'s local presentation command.
/// </para>
/// </summary>
public static class RetailChannelTagTable
{
private static readonly FrozenDictionary<string, uint> ByTag =
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
{
// The 22 tags with NO registered send verb (retail-registry doc
// §2.3's fallback table) — the only ones actually reachable
// through ChatCommandRouter's A.4 dispatch.
["abuse"] = 0x00000001u,
["ad"] = 0x00000002u,
["admin"] = 0x00000002u,
["au"] = 0x00000004u,
["audit"] = 0x00000004u,
["av"] = 0x00000008u,
["av1"] = 0x00000008u,
["advocate"] = 0x00000008u,
["advocate1"] = 0x00000008u,
["av2"] = 0x00000010u,
["advocate2"] = 0x00000010u,
["av3"] = 0x00000020u,
["advocate3"] = 0x00000020u,
["sent"] = 0x00000200u,
["sentinel"] = 0x00000200u,
["celestialhand"] = 0x08000000u,
["celhan"] = 0x08000000u,
["eldrytchweb"] = 0x10000000u,
["eldweb"] = 0x10000000u,
["radiantblood"] = 0x20000000u,
["radblo"] = 0x20000000u,
["ol"] = 0x40000000u,
["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.
["fellowship"] = 0x00000800u,
["fellow"] = 0x00000800u,
["fellows"] = 0x00000800u,
["f"] = 0x00000800u,
["group"] = 0x00000800u,
["g"] = 0x00000800u,
["party"] = 0x00000800u,
["vassals"] = 0x00001000u,
["vassal"] = 0x00001000u,
["v"] = 0x00001000u,
["patron"] = 0x00002000u,
["p"] = 0x00002000u,
["monarch"] = 0x00004000u,
["m"] = 0x00004000u,
["covassals"] = 0x01000000u,
["covassal"] = 0x01000000u,
["co-vassals"] = 0x01000000u,
["c"] = 0x01000000u,
["a"] = 0x02000000u,
["ab"] = 0x02000000u,
["allegiance"] = 0x02000000u,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
/// <summary>Resolve a channel tag (no leading <c>/</c> or <c>@</c>) to its legacy bitflag id.</summary>
public static bool TryResolve(string tag, out uint channelId) =>
ByTag.TryGetValue(tag, out channelId);
/// <summary>
/// True only for the 22 tags that have NO registered send verb — 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>
public static bool IsUnregisteredFallbackTag(string tag) =>
ByTag.TryGetValue(tag, out uint id) && id != 0x00000800u && id != 0x00001000u
&& id != 0x00002000u && id != 0x00004000u && id != 0x01000000u && id != 0x02000000u;
}

View file

@ -51,12 +51,13 @@ public static class RetailClientCommandCatalog
"/pklarena (/pla) - Teleports a PKLite player to the PKLite Arena.");
// ClientCommunicationSystem::DoPKLite/HelpPKLite @ 0x0057A490/0x0057A540.
// Retail registers exactly one verb string ("pklite" @ 0x007E16B0) — no
// alias, unlike most of this catalog.
// Retail registers exactly one verb string ("pklite" @ 0x007E16B0) —
// Campaign CH slice CH4 (2026-08-09) added the "pkl" alias per the
// command-registry doc §2.6.
private static readonly Definition PkLite = NoArguments(
ClientCommandId.EnterPkLite,
"/pklite",
"@pklite - Sets your status to Player Killer Lite. Type @help pklite for more details.");
"@pklite (@pkl) - Sets your status to Player Killer Lite. Type @help pklite for more details.");
private static readonly Definition HouseRecall = NoArguments(
ClientCommandId.HouseRecall,
@ -68,6 +69,13 @@ public static class RetailClientCommandCatalog
"/house mansion_recall",
"/house mansion_recall (/hom, /hoa) - Teleports you to your allegiance mansion.");
// GameActionHouseAbandon.Handle / retail help data_7dd3d0:
// "@house abandon - Abandons your house.\n"
private static readonly Definition HouseAbandon = NoArguments(
ClientCommandId.HouseAbandon,
"/house abandon",
"@house abandon - Abandons your house.");
private static readonly Definition QueryAge = NoArguments(
ClientCommandId.QueryAge,
"/age",
@ -192,16 +200,198 @@ public static class RetailClientCommandCatalog
"/unfilter -<message type>",
"/unfilter -<message type> - Shows a globally hidden message category.");
// Campaign CH slice CH4 (2026-08-09): retail registers exactly one
// handler (DoMessageTypes @ 0x0057A010) under four verb strings —
// "messagetypes", "message_types", "msgtypes", "msg_types" — all
// aliases of the SAME definition, not separate commands.
private static readonly Definition MessageTypes = NoArguments(
ClientCommandId.ListMessageTypes,
"/messagetypes",
"/messagetypes - Lists valid filter and squelch message types.");
"/messagetypes (/message_types, /msgtypes, /msg_types) - Lists valid filter and squelch message types.");
private static readonly Definition FillComponents = AnyArguments(
ClientCommandId.FillComponents,
"/fillcomps [component type] [pyreal value]",
"/fillcomps - Helps you buy components in bulk.");
// ── Campaign CH slice CH4 (2026-08-09) additions ────────────────────
// ClientCommunicationSystem::DoEndurance @ 0x0057C5F0. Exact retail
// text extracted from acclient_2013_pseudo_c.txt:1031097
// (data_7de2f8) — the first paragraph only; the full multi-paragraph
// block is reproduced verbatim by DoEndurance itself and is long
// enough that only the opening line is duplicated here as a teaser —
// BuildHelpText below uses the SAME text callers already see through
// ClientCommandController.
private static readonly Definition Endurance = NoArguments(
ClientCommandId.Endurance,
"/endurance",
"The endurance attribute has a number of abilities tied to it. Type @help endurance for the full description.");
// ClientCommunicationSystem::DoSpeaker @ 0x0057DAB0. Exact retail text:
// acclient_2013_pseudo_c.txt:393309 / 1031426 (data_7e0cd8).
private static readonly Definition Speaker = NoArguments(
ClientCommandId.Speaker,
"/speaker",
"This command is no longer in use, please see @allegiance officer.");
// 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).
private static readonly Definition SetTitle = AnyArguments(
ClientCommandId.SetChatTitle,
"/title <new title>",
"@title <new title> - Sets the title of the popup chat window.");
// ClientCommunicationSystem::DoChatToggle @ 0x0056FAD0 — Event_
// ModifyGlobalSquelch(remove, 2) for "on", (add, 2) for "off". Exact
// retail help: acclient_2013_pseudo_c.txt:1030716/1030720.
private static readonly Definition ChatToggle = new(
ClientCommandId.ChatToggle,
Usage: "/chat <on|off>",
HelpText: "@chat <on/off> - Sets whether or not you receive normal chat. When set to \"off\", you will no longer receive any spoken speech (normal chat). However, you will still receive tells.",
ValidateArguments: static arguments =>
arguments.Equals("on", StringComparison.OrdinalIgnoreCase)
|| arguments.Equals("off", StringComparison.OrdinalIgnoreCase));
// ClientCommunicationSystem::DoNoTell @ 0x0056FBD0 — same mechanism,
// message type 3 (Tell). Exact retail help:
// acclient_2013_pseudo_c.txt:1030724/1030728.
private static readonly Definition NoTellToggle = new(
ClientCommandId.NoTellToggle,
Usage: "/notell <on|off>",
HelpText: "@notell <on/off> - Sets whether or not you receive @tells. When set to \"on\", you will not receive any tells.",
ValidateArguments: static arguments =>
arguments.Equals("on", StringComparison.OrdinalIgnoreCase)
|| arguments.Equals("off", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Tags accepted by <c>@join</c>/<c>@leave</c> mapped to the linear
/// <c>SetSingleCharacterOption (0x0005)</c> option id — retail
/// PlayerModule::SetHear*Chat flags, per
/// <c>AcDream.Core.Net.Messages.CharacterOptionId</c>.
/// </summary>
public static bool TryResolveJoinLeaveOption(string tag, out uint optionId) =>
JoinLeaveTags.TryGetValue(tag.Trim(), out optionId);
private static readonly FrozenDictionary<string, uint> JoinLeaveTags =
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
{
["allegiance"] = 0x1Bu, // CharacterOptionId.ListenToAllegianceChat
["general"] = 0x23u, // CharacterOptionId.ListenToGeneralChat
["trade"] = 0x24u, // CharacterOptionId.ListenToTradeChat
["lfg"] = 0x25u, // CharacterOptionId.ListenToLFGChat
["roleplay"] = 0x26u, // CharacterOptionId.ListenToRoleplayChat
["society"] = 0x2Eu, // CharacterOptionId.ListenToSocietyChat
["soc"] = 0x2Eu,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
// ClientCommunicationSystem::DoJoinChat/DoLeaveChat @ 0x0056F510/
// 0x0056F7F0. Tags per the registry doc §2.2: Allegiance, General,
// Trade, LFG, Roleplay, Society, Soc. Exact retail help:
// acclient_2013_pseudo_c.txt:1030689/1030693.
private static readonly Definition JoinChannel = new(
ClientCommandId.JoinChannel,
Usage: "/join <channel tag>",
HelpText: "@join <channel tag> - Allows you to hear and speak on the given channel.",
ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim()));
private static readonly Definition LeaveChannel = new(
ClientCommandId.LeaveChannel,
Usage: "/leave <channel tag>",
HelpText: "@leave <channel tag> - Prevents you from hearing or speaking on the given channel.",
ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim()));
// ClientCommunicationSystem::DoPermit @ 0x005785A0. Exact retail help:
// acclient_2013_pseudo_c.txt:1030850-1030852 (data_7dbac8).
private static readonly Definition Permit = new(
ClientCommandId.Permit,
Usage: "/permit <add|remove> <name>",
HelpText: "@permit add <name> - Allows another player to loot your corpse. @permit remove <name> - Removes permission to access your corpse from the named character.",
ValidateArguments: static arguments =>
{
string[] parts = arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 2
&& (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase)
|| parts[0].Equals("remove", StringComparison.OrdinalIgnoreCase));
});
/// <summary>
/// Retail house-type spellings mapped to ACE's <c>HouseType</c> enum
/// value (the <c>@hslist</c>/ListAvailableHouses payload).
/// </summary>
public static bool TryResolveHouseType(string type, out uint houseType) =>
HouseTypes.TryGetValue(type.Trim(), out houseType);
private static readonly FrozenDictionary<string, uint> HouseTypes =
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
{
["cottage"] = 1u,
["villa"] = 2u,
["mansion"] = 3u,
["apartment"] = 4u,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
// ClientCommunicationSystem::DoHouseAvailableList @ 0x00570510. Exact
// retail help: acclient_2013_pseudo_c.txt:1031049 (data_7dd9d0).
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()));
// 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(
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."
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 _),
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 _),
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 _),
InvalidArgumentsText: "Please specify the channel name.");
// GameActionRecallAllegianceHometown.Handle. Exact retail help:
// acclient_2013_pseudo_c.txt:1031230 — "@allegiance hometown -
// Recalls you to your allegiance bindstone, if your allegiance has
// tied to one.\n"
private static readonly Definition AllegianceHometown = NoArguments(
ClientCommandId.AllegianceHometown,
"/alh",
"@allegiance hometown (@alh, @ah) - Recalls you to your allegiance bindstone, if your allegiance has tied to one.");
// GameActionAllegianceInfoRequest.Handle — String16L name, empty = self.
// Exact retail help: acclient_2013_pseudo_c.txt:1031214 —
// "@allegiance info <name> - Requests information on a member of your
// allegiance.\n"
private static readonly Definition AllegianceInfo = AnyArguments(
ClientCommandId.AllegianceInfo,
"/allegiance info [name]",
"@allegiance info <name> - Requests information on a member of your allegiance.");
private static readonly FrozenDictionary<string, Definition> ByVerb =
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
{
@ -216,6 +406,7 @@ public static class RetailClientCommandCatalog
["pklarena"] = PkLiteArena,
["pla"] = PkLiteArena,
["pklite"] = PkLite,
["pkl"] = PkLite,
["hor"] = HouseRecall,
["hr"] = HouseRecall,
["hom"] = MansionRecall,
@ -249,7 +440,25 @@ public static class RetailClientCommandCatalog
["filter"] = Filter,
["unfilter"] = Unfilter,
["messagetypes"] = MessageTypes,
["message_types"] = MessageTypes,
["msgtypes"] = MessageTypes,
["msg_types"] = MessageTypes,
["fillcomps"] = FillComponents,
["endurance"] = Endurance,
["speaker"] = Speaker,
["title"] = SetTitle,
["chat"] = ChatToggle,
["notell"] = NoTellToggle,
["join"] = JoinChannel,
["leave"] = LeaveChannel,
["permit"] = Permit,
["hslist"] = HouseAvailableList,
["index"] = IndexChannels,
["clist"] = ListChannel,
["on"] = OnChannel,
["off"] = OffChannel,
["alh"] = AllegianceHometown,
["ah"] = AllegianceHometown,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
/// <summary>
@ -271,35 +480,28 @@ public static class RetailClientCommandCatalog
string verb = separator < 0
? trimmed[1..]
: trimmed.Substring(1, separator - 1);
// Retail DoCommand @ 0x0057E2E0 right-trims ',' off the verb token
// before lookup (trim char set 0x0079452C) — "@f, hi" resolves the
// SAME as "@f hi". Campaign CH slice CH4 (2026-08-09).
verb = verb.TrimEnd(',');
string arguments = separator < 0
? string.Empty
: trimmed[(separator + 1)..].Trim();
Definition? definition;
if (verb.Equals("house", StringComparison.OrdinalIgnoreCase))
if (verb.Equals("house", StringComparison.OrdinalIgnoreCase)
|| verb.Equals("hou", StringComparison.OrdinalIgnoreCase))
{
definition = arguments.ToLowerInvariant() switch
{
"recall" => HouseRecall,
"mansion_recall" or "alleg_recall" => MansionRecall,
_ => null,
};
if (definition is null)
{
match = new Match(
ClientCommandId.HouseRecall,
arguments,
"/house recall | /house mansion_recall",
HasValidArguments: false,
InvalidArgumentsText: null);
return true;
}
// The subcommand selected the operation; its own handler has no
// additional arguments.
arguments = string.Empty;
return TryMatchHouse(arguments, out match);
}
else if (!ByVerb.TryGetValue(verb, out definition))
if (verb.Equals("allegiance", StringComparison.OrdinalIgnoreCase)
|| verb.Equals("all", StringComparison.OrdinalIgnoreCase))
{
return TryMatchAllegiance(arguments, out match);
}
if (!ByVerb.TryGetValue(verb, out definition))
{
return false;
}
@ -313,6 +515,85 @@ public static class RetailClientCommandCatalog
return true;
}
/// <summary>
/// <c>@house &lt;sub&gt;</c> / <c>@hou &lt;sub&gt;</c> dispatcher.
/// 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.
/// </summary>
private static bool TryMatchHouse(string arguments, out Match match)
{
match = default;
string subcommand = arguments.ToLowerInvariant();
Definition? definition = subcommand switch
{
"recall" or "re" => HouseRecall,
"mansion_recall" or "alleg_recall" or "ma" => MansionRecall,
"abandon" => HouseAbandon,
_ => null,
};
if (definition is null)
return false;
match = new Match(
definition.Command,
Arguments: string.Empty,
definition.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
return true;
}
/// <summary>
/// <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"/>.
/// </summary>
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();
if (subcommand.Equals("hometown", StringComparison.OrdinalIgnoreCase)
|| subcommand.Equals("ho", StringComparison.OrdinalIgnoreCase))
{
match = new Match(
AllegianceHometown.Command,
Arguments: string.Empty,
AllegianceHometown.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
return true;
}
if (subcommand.Equals("info", StringComparison.OrdinalIgnoreCase))
{
match = new Match(
AllegianceInfo.Command,
rest,
AllegianceInfo.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
return true;
}
return false;
}
/// <summary>Help line generated from the same definition routing uses.</summary>
public static string BuildHelpText() => string.Join("\n ",
Lifestone.HelpText,
@ -322,6 +603,7 @@ public static class RetailClientCommandCatalog
PkLite.HelpText,
HouseRecall.HelpText,
MansionRecall.HelpText,
HouseAbandon.HelpText,
QueryAge.HelpText,
QueryBirth.HelpText,
FrameRate.HelpText,
@ -345,7 +627,65 @@ public static class RetailClientCommandCatalog
Filter.HelpText,
Unfilter.HelpText,
MessageTypes.HelpText,
FillComponents.HelpText);
FillComponents.HelpText,
Endurance.HelpText,
Speaker.HelpText,
SetTitle.HelpText,
ChatToggle.HelpText,
NoTellToggle.HelpText,
JoinChannel.HelpText,
LeaveChannel.HelpText,
Permit.HelpText,
HouseAvailableList.HelpText,
AllegianceHometown.HelpText,
AllegianceInfo.HelpText);
/// <summary>
/// Every verb string this catalog dispatches, INCLUDING the
/// specially-parsed "house"/"hou"/"allegiance"/"all" verbs (which are
/// not literal keys of the backing dictionary because their dispatch
/// depends on the subcommand). Used by the CH4 conformance test to
/// enforce the ownership rule in both directions: every retail-registry
/// verb this catalog claims must actually be in the registry, and vice
/// versa.
/// </summary>
public static IReadOnlyCollection<string> KnownVerbs { get; } =
ByVerb.Keys.Concat(["house", "hou", "allegiance", "all"]).ToArray();
/// <summary>
/// <c>/help &lt;verb&gt;</c> lookup for a catalog-dispatched command —
/// retail's <c>DoHelp @ 0x0057F9E0</c> looking up the verb's registered
/// help callback. Does not cover chat-alias or channel verbs (see
/// <see cref="RetailCommandHelpTable"/> for those) nor the deferred
/// allegiance/house subcommand overviews (also
/// <see cref="RetailCommandHelpTable"/>).
/// </summary>
public static bool TryGetHelpText(string verb, out string helpText)
{
string trimmedVerb = verb.TrimEnd(',');
if (trimmedVerb.Equals("house", StringComparison.OrdinalIgnoreCase)
|| trimmedVerb.Equals("hou", StringComparison.OrdinalIgnoreCase))
{
helpText = RetailCommandHelpTable.HouseOverview;
return true;
}
if (trimmedVerb.Equals("allegiance", StringComparison.OrdinalIgnoreCase)
|| trimmedVerb.Equals("all", StringComparison.OrdinalIgnoreCase))
{
helpText = RetailCommandHelpTable.AllegianceOverview;
return true;
}
if (ByVerb.TryGetValue(trimmedVerb, out Definition? definition))
{
helpText = definition.HelpText;
return true;
}
helpText = string.Empty;
return false;
}
private static Definition NoArguments(
ClientCommandId command, string usage, string helpText) =>

View file

@ -0,0 +1,205 @@
using System.Collections.Frozen;
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <summary>
/// Campaign CH slice CH4 (2026-08-09): <c>/help &lt;verb&gt;</c> text for
/// every retail-registry verb <see cref="RetailClientCommandCatalog"/>
/// doesn't dispatch directly — chat aliases and channel verbs
/// (<see cref="ChatInputParser"/>), the null-func help-only nodes (retail
/// registers these with NO handler; typing them bare reaches the server,
/// only <c>@help &lt;verb&gt;</c> shows anything locally), and the
/// allegiance/house command overviews (the per-subcommand detail lives
/// here too, even though most subcommands are not yet locally executed —
/// see TS-68).
///
/// <para>
/// Every entry is 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
/// that could not be recovered this way (the 7 group-index headers'
/// summary text, @day's confirmation lines, @render's option list) are
/// NOT fabricated — they are simply absent from this table; the lookup
/// falls through to a generic "no detailed help" line rather than guess.
/// </para>
/// </summary>
public static class RetailCommandHelpTable
{
// acclient_2013_pseudo_c.txt:1030771 (data_7dae40).
public const string Tell =
"@tell <name>, <text> - Sends a long-distance, private message to the specified character. Note that you must put a comma after the character's name.";
// acclient_2013_pseudo_c.txt:1030742/1030745.
public const string Reply =
"@reply <text> - Sends the text to the last person who @tell'd you. You may also use @r or @rp.";
// acclient_2013_pseudo_c.txt:1030753/1030757.
public const string Retell =
"@retell <text> - Sends the text to the last person you @tell'd. You may also use @rt.";
// acclient_2013_pseudo_c.txt:1031564 (data_7e11e0), the "Note:" line
// DoHelp @0x0057F9E0 prints alongside the bare group index.
public const string HelpPrefixNote =
"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.
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.";
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.";
// acclient_2013_pseudo_c.txt:1031093 (data_7de280).
public const string Day =
"@day - A toggle that lightens the outdoor landscape. Note that this command may take several seconds to take effect. NOT YET IMPLEMENTED in acdream — no sky/time-of-day override hook exists yet; the command reaches the server as literal text.";
// acclient_2013_pseudo_c.txt:1031192-1031198 (data_7df7f8/data_7dfac4).
public const string Log =
"@log <name> - Echoes chat text to a logfile. All the information that appears in your chat window after you type this command will be copied into a text file. If this file already exists, it will add the additional text to the end of it. To turn off logging, simply retype @log. NOT YET IMPLEMENTED in acdream — the command reaches the server as literal text.";
public const string Render =
"@render [options] - Forwards to the client's render-option surface (retail: SmartBox::HandleRenderOption). NOT YET IMPLEMENTED in acdream — there is no SmartBox equivalent; the command reaches the server as literal text.";
// acclient_2013_pseudo_c.txt:1030670-1030676 (data_7da300/data_7da3e0).
public const string Motd =
"@allegiance motd - Displays the message of the day for your allegiance. @allegiance motd set <text> - Sets the MOTD. Can only be used by monarchs. @allegiance motd clear - Clears the MOTD. Can only be used by monarchs. NOT YET IMPLEMENTED in acdream (TS-68) — the command reaches the server as literal text.";
// acclient_2013_pseudo_c.txt:1031234 (data_7e03d4) plus the full
// per-subcommand block at 1031210-1031230 (data_7dfb58).
public const string AllegianceOverview =
"@allegiance - Commands to help manage your allegiance.\n"
+ "@allegiance boot [-account] <name> - Removes a character from your allegiance.\n"
+ "@allegiance ban <add/remove> <name> - Bans all characters on the given character's account from your allegiance (and boots them too!)\n"
+ "@allegiance ban list - List the characters whose accounts are banned from your allegiance.\n"
+ "@allegiance info <name> - Requests information on a member of your allegiance. [IMPLEMENTED]\n"
+ "@allegiance chat <on/off> - Turn allegiance chat on and off.\n"
+ "@allegiance chat kick <name>[, <reason>] - Kick a player temporarily from the allegiance chat room.\n"
+ "@allegiance chat gag <name> - Gags a player so that they cannot see or speak in the allegiance chat room for 5 minutes.\n"
+ "@allegiance chat ungag <name> - Ungags a gagged allegiance member so that they may once again see and speak in the allegiance chat room.\n"
+ "@allegiance broadcast <message> - Broadcast a message to the entire allegiance. Limited to 10/day. Also: @ab [IMPLEMENTED as @ab]\n"
+ "@allegiance officer <add/set> <level #> <name> - Assigns the position of officer, with the given level of permissions, to the named character.\n"
+ "@allegiance officer <remove> <name> - Removed the named character as an allegiance officer.\n"
+ "@allegiance officer clear - Clears all officer positions.\n"
+ "@allegiance officer [list] - list your allegiance officer. Can be used by anyone in an allegiance.\n"
+ "@allegiance title set <level #> <title> - Sets the title of the given officer level.\n"
+ "@allegiance title clear - Clears all officer titles.\n"
+ "@allegiance title [list] - Lists all the officer titles for your allegiance.\n"
+ "@allegiance name <set/clear> - Displays, sets, or clears the name of your allegiance.\n"
+ "@allegiance lock <on/off/toggle/check> - Locks, unlocks, or displays the locked state of your allegiance.\n"
+ "@allegiance lock bypass <clear/name> - Sets, clears, or displays a single character as an approved vassal. That character may then swear into a locked allegiance.\n"
+ "@allegiance hometown - Recalls you to your allegiance bindstone, if your allegiance has tied to one. [IMPLEMENTED, also @alh/@ah]\n"
+ "@allegiance motd - Displays or sets the message of the day for your allegiance.\n"
+ "Subcommands NOT marked [IMPLEMENTED] are not yet locally executed (TS-68) and reach the server as literal text.";
// acclient_2013_pseudo_c.txt:1031041/1031045 (data_7dd908/data_7dd968)
// plus the full per-subcommand block at 1031017-1031037 (data_7dd3d0).
public const string HouseOverview =
"@house - Commands that help you manage your house, including guest and storage management.\n"
+ "@house abandon - Abandons your house. [IMPLEMENTED]\n"
+ "@house boot <name> - Removes a player from your house.\n"
+ "@house boot -all - Removes everyone from your house.\n"
+ "@house guest add <name> - Adds players to your house guest list.\n"
+ "@house guest remove <name> - Removes players from your house guest list.\n"
+ "@house guest add_allegiance - Adds your allegiance to the guest list.\n"
+ "@house guest remove_allegiance - Removes your allegiance from the guest list.\n"
+ "@house guest remove_all - Removes all guests from your house guest list.\n"
+ "@house guest list - Shows the current guest list.\n"
+ "@house recall - Teleports you to your house. [IMPLEMENTED, also @hor/@hr]\n"
+ "@house storage add <name> - Gives a player permission to use your house storage.\n"
+ "@house storage remove <name> - Removes permission to use your house storage from a player.\n"
+ "@house storage add_allegiance - Grants storage permission to your allegiance.\n"
+ "@house storage remove_allegiance - Removes storage permission from your allegiance.\n"
+ "@house storage remove_all - Removes all storage permissions from guests.\n"
+ "@house open - Creates an open house.\n"
+ "@house close - Closes your house.\n"
+ "@house hooks on|off - Makes the hooks in your house visible or invisible.\n"
+ "@house mansion_recall - Teleports you to your allegiance mansion or villa. [IMPLEMENTED, also @hom/@hoa]\n"
+ "@house alleg_recall - Teleports you to your allegiance mansion or villa. [IMPLEMENTED, alias of mansion_recall]\n"
+ "@house available - See @hslist [see @hslist, IMPLEMENTED]\n"
+ "Subcommands NOT marked [IMPLEMENTED] are not yet locally executed (TS-68) and reach the server as literal text.";
// The 7 retail-registered "group index" nodes — retail registers them
// with a NULL func (like @mr/@pr); typing one bare reaches the server.
// Only @help <group> shows anything, and only this generic pointer —
// the exact per-group summary/listing text (retail's HelpXxxGroup
// functions) was not extracted this slice; see ISSUES.md.
private const string GroupNodeNotExtracted =
"This is a retail help-topic group; acdream has not yet extracted its exact retail listing text. Typing this verb alone (without @help) reaches the server as literal text, matching retail's null-handler registration.";
private static readonly FrozenDictionary<string, string> ByVerb =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["say"] = "@say <text> - Speaks the text aloud to nearby players.",
["s"] = "@say <text> - Speaks the text aloud to nearby players.",
["tell"] = Tell,
["t"] = Tell,
["send"] = Tell,
["whisper"] = Tell,
["w"] = Tell,
["reply"] = Reply,
["r"] = Reply,
["rp"] = Reply,
["retell"] = Retell,
["rt"] = Retell,
["mr"] = MonarchReply,
["pr"] = PatronReply,
["day"] = Day,
["log"] = Log,
["render"] = Render,
["motd"] = Motd,
["commands"] = GroupNodeNotExtracted,
["allegiances"] = GroupNodeNotExtracted,
["channels"] = GroupNodeNotExtracted,
["chatting"] = GroupNodeNotExtracted,
["death"] = GroupNodeNotExtracted,
["status"] = GroupNodeNotExtracted,
["text"] = GroupNodeNotExtracted,
// Channel verbs — one line each, generated rather than
// hand-extracted (retail's per-channel help strings were not
// individually recovered this slice).
["f"] = "Sends text to your Fellowship channel.",
["fellow"] = "Sends text to your Fellowship channel.",
["fellows"] = "Sends text to your Fellowship channel.",
["fellowship"] = "Sends text to your Fellowship channel.",
["g"] = "Sends text to your Fellowship channel.",
["group"] = "Sends text to your Fellowship channel.",
["party"] = "Sends text to your Fellowship channel.",
["a"] = "Sends text to your Allegiance chat room.",
["guild"] = "Sends text to your Allegiance chat room.",
["gu"] = "Sends text to your Allegiance chat room.",
["ab"] = "Broadcasts text to your entire allegiance (monarch/speaker permission). Also @allegiance broadcast.",
["general"] = "Sends text to the General chat room.",
["cg"] = "Sends text to the General chat room.",
["trade"] = "Sends text to the Trade chat room.",
["ct"] = "Sends text to the Trade chat room.",
["lfg"] = "Sends text to the Looking-For-Group chat room.",
["clfg"] = "Sends text to the Looking-For-Group chat room.",
["roleplay"] = "Sends text to the Roleplay chat room.",
["crp"] = "Sends text to the Roleplay chat room.",
["society"] = "Sends text to your Society chat room.",
["soc"] = "Sends text to your Society chat room.",
["olthoi"] = "Sends text to the Olthoi Player Killer chat room.",
["o"] = "Sends text to the Olthoi Player Killer chat room.",
["m"] = "Sends text to your Monarch.",
["monarch"] = "Sends text to your Monarch.",
["p"] = "Sends text to your Patron.",
["patron"] = "Sends text to your Patron.",
["v"] = "Sends text to your Vassals.",
["vassal"] = "Sends text to your Vassals.",
["vassals"] = "Sends text to your Vassals.",
["c"] = "Sends text to your Co-vassals.",
["covassal"] = "Sends text to your Co-vassals.",
["covassals"] = "Sends text to your Co-vassals.",
["co-vassals"] = "Sends text to your Co-vassals.",
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
public static bool TryGetHelpText(string verb, out string helpText) =>
ByVerb.TryGetValue(verb.TrimEnd(','), out helpText!);
}

View file

@ -0,0 +1,17 @@
namespace AcDream.UI.Abstractions;
/// <summary>
/// Campaign CH slice CH4 (2026-08-09): broadcast to a legacy <c>ChatChannel
/// (0x0147)</c> bitflag id resolved directly from a channel TAG, bypassing
/// <see cref="ChatChannelKind"/> and <see cref="ChannelResolver"/> entirely.
///
/// <para>
/// This is retail's <c>DoChannelCommand @ 0x005774A0</c> fallback path — the
/// 22 <c>ChannelSystem::GetChannelID</c> tags that have no registered verb
/// (GM/faction channels like <c>@admin</c>, <c>@sentinel</c>,
/// <c>@celestialhand</c>) plus the argument channel-tag resolution
/// <c>@clist</c>/<c>@on</c>/<c>@off</c> use. See
/// <see cref="Panels.Chat.RetailChannelTagTable"/> for the tag→id table.
/// </para>
/// </summary>
public sealed record SendRawChannelCmd(uint ChannelId, string Text);