fix: complete retail parity stability pass
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s

This commit is contained in:
Erik 2026-08-28 20:01:39 +02:00
parent d3df4cb20a
commit f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions

View file

@ -18,6 +18,10 @@ public enum ClientCommandId
QueryAge,
QueryBirth,
ToggleFrameRate,
/// <summary>@day — toggle retail's persistent noon landscape lighting.</summary>
TogglePersistentDaylight,
/// <summary>@render — modify retail's landscape radius or field of view.</summary>
RenderOption,
ToggleUiLock,
ShowVersion,
ShowLocation,
@ -84,18 +88,52 @@ public enum ClientCommandId
OffChannel,
/// <summary>@alh / @ah / "@allegiance hometown" / "@allegiance ho" — recall to the allegiance bindstone.</summary>
AllegianceHometown,
/// <summary>"@allegiance info [name]" — request allegiance member info.</summary>
/// <summary>"@allegiance info &lt;name&gt;" — request allegiance member info.</summary>
AllegianceInfo,
/// <summary>"@allegiance boot [-account] &lt;name&gt;".</summary>
AllegianceBoot,
/// <summary>"@allegiance ban ..." administration dispatcher.</summary>
AllegianceBan,
/// <summary>"@allegiance chat ..." administration dispatcher.</summary>
AllegianceChat,
/// <summary>"@allegiance broadcast &lt;text&gt;".</summary>
AllegianceBroadcast,
/// <summary>"@allegiance officer ..." administration dispatcher.</summary>
AllegianceOfficer,
/// <summary>"@allegiance title ..." officer-title dispatcher.</summary>
AllegianceOfficerTitle,
/// <summary>"@allegiance name ..." dispatcher.</summary>
AllegianceName,
/// <summary>"@allegiance lock ..." dispatcher.</summary>
AllegianceLock,
/// <summary>"@allegiance house ..." allegiance-house dispatcher.</summary>
AllegianceHouse,
/// <summary>"@allegiance motd ..." and standalone "@motd ...".</summary>
AllegianceMotd,
/// <summary>"@house abandon" — abandon the character's house.</summary>
HouseAbandon,
/// <summary>"@house open|close".</summary>
HouseOpenStatus,
/// <summary>"@house storage ...".</summary>
HouseStorage,
/// <summary>"@house remove|boot ...".</summary>
HouseBoot,
/// <summary>"@house boot_all|remove_all".</summary>
HouseBootAll,
/// <summary>"@house guest ...".</summary>
HouseGuests,
/// <summary>"@house hooks on|off".</summary>
HouseHooks,
/// <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="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>.
/// A genuinely unrecognized or incomplete <c>@allegiance</c> subcommand.
/// The catalog claims it locally and emits retail's Allegiance help
/// refusal rather than allowing channel fallback or server passthrough.
/// </summary>
AllegianceUnrecognizedSubcommand,
/// <summary>
/// An unrecognized or incomplete <c>@house</c> subcommand. Like retail,
/// this is claimed locally and prints the House help refusal.
/// </summary>
HouseUnrecognizedSubcommand,
}

View file

@ -0,0 +1,597 @@
using AcDream.Core.Net.Messages;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Presentation-independent port of retail's nested allegiance and house
/// command handlers. Both graphical and headless hosts use this one parser so
/// their argument grammar, local refusals, and wire effects cannot drift.
/// </summary>
/// <remarks>
/// Ported from <c>ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0</c>,
/// <c>DoHouse @ 0x00580860</c>, and their named helper functions in the
/// September 2013 retail decompile. The action bindings name ACE's matching
/// GameAction readers; no raw opcode is exposed at this layer.
/// </remarks>
public sealed class RetailAdministrationCommandDispatcher
{
public sealed record FeedbackBindings(
Action<string> ShowSystemMessage,
Action<string> ShowClientLocalMessage,
Action<uint, bool> SetSingleCharacterOption,
Action<string> RequestAllegianceInfo);
public sealed record ActionBindings(
Action<string, bool> BreakAllegianceBoot,
Action<string, string> AllegianceChatBoot,
Action<string, bool> AllegianceChatGag,
Action<string> AllegianceBroadcast,
Action ListAllegianceBans,
Action<string> AddAllegianceBan,
Action<string> RemoveAllegianceBan,
Action ListAllegianceOfficers,
Action ClearAllegianceOfficers,
Action<string, uint> SetAllegianceOfficer,
Action<string> RemoveAllegianceOfficer,
Action ListAllegianceOfficerTitles,
Action ClearAllegianceOfficerTitles,
Action<uint, string> SetAllegianceOfficerTitle,
Action QueryAllegianceName,
Action<string> SetAllegianceName,
Action ClearAllegianceName,
Action<uint> AllegianceLockAction,
Action<string> SetAllegianceApprovedVassal,
Action<uint> AllegianceHouseAction,
Action QueryMotd,
Action<string> SetMotd,
Action ClearMotd,
Action<bool> SetOpenHouseStatus,
Action<string> AddPermanentGuest,
Action<string> RemovePermanentGuest,
Action RemoveAllPermanentGuests,
Action<string, bool> ChangeStoragePermission,
Action AddAllStoragePermission,
Action RemoveAllStoragePermission,
Action RequestFullGuestList,
Action<string> BootSpecificHouseGuest,
Action BootEveryone,
Action<bool> SetHooksVisibility,
Action<bool> ModifyAllegianceGuestPermission,
Action<bool> ModifyAllegianceStoragePermission);
private readonly FeedbackBindings _feedback;
private readonly ActionBindings _actions;
public RetailAdministrationCommandDispatcher(
FeedbackBindings feedback,
ActionBindings actions)
{
_feedback = feedback ?? throw new ArgumentNullException(nameof(feedback));
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
}
/// <summary>
/// Executes one typed management command. Returns false only when the id
/// is outside this dispatcher's family.
/// </summary>
public bool TryExecute(ClientCommandId command, string arguments)
{
ArgumentNullException.ThrowIfNull(arguments);
switch (command)
{
case ClientCommandId.AllegianceInfo:
ExecuteAllegianceInfo(arguments);
return true;
case ClientCommandId.AllegianceBoot:
ExecuteAllegianceBoot(arguments);
return true;
case ClientCommandId.AllegianceBan:
ExecuteAllegianceBan(arguments);
return true;
case ClientCommandId.AllegianceChat:
ExecuteAllegianceChat(arguments);
return true;
case ClientCommandId.AllegianceBroadcast:
ExecuteAllegianceBroadcast(arguments);
return true;
case ClientCommandId.AllegianceOfficer:
ExecuteAllegianceOfficer(arguments);
return true;
case ClientCommandId.AllegianceOfficerTitle:
ExecuteAllegianceOfficerTitle(arguments);
return true;
case ClientCommandId.AllegianceName:
ExecuteAllegianceName(arguments);
return true;
case ClientCommandId.AllegianceLock:
ExecuteAllegianceLock(arguments);
return true;
case ClientCommandId.AllegianceHouse:
ExecuteAllegianceHouse(arguments);
return true;
case ClientCommandId.AllegianceMotd:
ExecuteAllegianceMotd(arguments);
return true;
case ClientCommandId.AllegianceUnrecognizedSubcommand:
ShowAllegianceHelpRefusal();
return true;
case ClientCommandId.HouseOpenStatus:
_actions.SetOpenHouseStatus(
arguments.Equals("open", StringComparison.OrdinalIgnoreCase));
return true;
case ClientCommandId.HouseStorage:
ExecuteHouseStorage(arguments);
return true;
case ClientCommandId.HouseBoot:
ExecuteHouseBoot(arguments);
return true;
case ClientCommandId.HouseBootAll:
_actions.BootEveryone();
return true;
case ClientCommandId.HouseGuests:
ExecuteHouseGuests(arguments);
return true;
case ClientCommandId.HouseHooks:
ExecuteHouseHooks(arguments);
return true;
case ClientCommandId.HouseUnrecognizedSubcommand:
ShowHouseHelpRefusal();
return true;
default:
return false;
}
}
private void ExecuteAllegianceInfo(string arguments)
{
string name = arguments.Trim();
if (name.Length == 0)
ShowClientLocal("Please specify an actual name.");
else
_feedback.RequestAllegianceInfo(name);
}
private void ExecuteAllegianceBoot(string arguments)
{
string name = arguments.Trim();
if (name.Length == 0)
{
ShowClientLocal("Please specify an actual name.");
return;
}
int accountFlag = name.IndexOf("-account", StringComparison.OrdinalIgnoreCase);
bool accountBoot = accountFlag >= 0;
if (accountBoot)
name = name.Remove(accountFlag, "-account".Length).Trim();
_feedback.ShowSystemMessage(
$"Attempting to boot {name}{(accountBoot ? " (Account)" : string.Empty)}...");
_actions.BreakAllegianceBoot(name, accountBoot);
}
private void ExecuteAllegianceBan(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Equals("list", StringComparison.OrdinalIgnoreCase))
{
_actions.ListAllegianceBans();
return;
}
string name = RemainderAfterFirstArgument(arguments).Trim();
if (name.Length == 0)
{
ShowClientLocal("Please specify an actual name.");
return;
}
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase))
_actions.AddAllegianceBan(name);
else if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
_actions.RemoveAllegianceBan(name);
else
ShowAllegianceHelpRefusal();
}
private void ExecuteAllegianceChat(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Equals("on", StringComparison.OrdinalIgnoreCase)
|| operation.Equals("off", StringComparison.OrdinalIgnoreCase))
{
_feedback.SetSingleCharacterOption(
(uint)CharacterOptionId.ListenToAllegianceChat,
operation.Equals("on", StringComparison.OrdinalIgnoreCase));
return;
}
string remainder = RemainderAfterFirstArgument(arguments).Trim();
if (operation.Equals("kick", StringComparison.OrdinalIgnoreCase))
{
int comma = remainder.IndexOf(',');
string name = comma < 0 ? remainder : remainder[..comma].Trim();
string reason = comma < 0
? "No reason given."
: remainder[(comma + 1)..].Trim();
_actions.AllegianceChatBoot(name, reason);
return;
}
bool gag = operation.Equals("gag", StringComparison.OrdinalIgnoreCase);
bool ungag = operation.Equals("ungag", StringComparison.OrdinalIgnoreCase);
if (!gag && !ungag)
{
ShowAllegianceHelpRefusal();
return;
}
if (remainder.Length == 0)
{
ShowClientLocal("Please specify an actual name.");
return;
}
_actions.AllegianceChatGag(remainder, gag);
}
private void ExecuteAllegianceBroadcast(string arguments)
{
string message = arguments.Trim();
if (message.Length == 0)
ShowAllegianceHelpRefusal();
else
_actions.AllegianceBroadcast(message);
}
private void ExecuteAllegianceOfficer(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Length == 0
|| operation.Equals("list", StringComparison.OrdinalIgnoreCase))
{
_actions.ListAllegianceOfficers();
return;
}
if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
{
_actions.ClearAllegianceOfficers();
return;
}
string remainder = RemainderAfterFirstArgument(arguments);
if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
{
string name = remainder.Trim();
if (name.Length == 0)
ShowClientLocal("Please specify the name of an allegiance member.");
else
_actions.RemoveAllegianceOfficer(name);
return;
}
if (!operation.Equals("add", StringComparison.OrdinalIgnoreCase)
&& !operation.Equals("set", StringComparison.OrdinalIgnoreCase))
{
ShowAllegianceHelpRefusal();
return;
}
string levelText = FirstArgument(remainder);
int level = RetailStrtolBaseZero(levelText);
if (level is < 1 or > 3)
{
ShowClientLocal(
"Please specify a valid officer level as a number between 1 and 3. "
+ "Check the game help files for more information on officer levels.");
return;
}
string officerName = RemainderAfterFirstArgument(remainder).Trim();
if (officerName.Length == 0)
{
ShowClientLocal("Please specify the name of an allegiance member.");
return;
}
_actions.SetAllegianceOfficer(officerName, (uint)level);
}
private void ExecuteAllegianceOfficerTitle(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Length == 0
|| operation.Equals("list", StringComparison.OrdinalIgnoreCase))
{
_actions.ListAllegianceOfficerTitles();
return;
}
if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
{
_actions.ClearAllegianceOfficerTitles();
return;
}
if (!operation.Equals("set", StringComparison.OrdinalIgnoreCase))
{
ShowAllegianceHelpRefusal();
return;
}
string remainder = RemainderAfterFirstArgument(arguments);
string levelText = FirstArgument(remainder);
int level = RetailStrtolBaseZero(levelText);
if (level is < 1 or > 3)
{
ShowClientLocal(
"Please specify a valid officer level as a number between 1 and 3.");
return;
}
string title = RemainderAfterFirstArgument(remainder).Trim();
_actions.SetAllegianceOfficerTitle((uint)level, title);
}
private void ExecuteAllegianceName(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Length == 0)
_actions.QueryAllegianceName();
else if (operation.Equals("set", StringComparison.OrdinalIgnoreCase))
_actions.SetAllegianceName(RemainderAfterFirstArgument(arguments).Trim());
else if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
_actions.ClearAllegianceName();
else
ShowAllegianceHelpRefusal();
}
private void ExecuteAllegianceLock(string arguments)
{
string operation = FirstArgument(arguments);
uint? action = operation.ToLowerInvariant() switch
{
"" or "check" => 4u,
"off" => 1u,
"on" => 2u,
"toggle" => 3u,
_ => null,
};
if (action is not null)
{
_actions.AllegianceLockAction(action.Value);
return;
}
if (!operation.Equals("bypass", StringComparison.OrdinalIgnoreCase))
{
ShowAllegianceHelpRefusal();
return;
}
string approved = RemainderAfterFirstArgument(arguments).Trim();
if (approved.Length == 0)
_actions.AllegianceLockAction(5u);
else if (approved.Equals("clear", StringComparison.OrdinalIgnoreCase))
_actions.AllegianceLockAction(6u);
else
_actions.SetAllegianceApprovedVassal(approved);
}
private void ExecuteAllegianceHouse(string arguments)
{
string category = FirstArgument(arguments);
if (category.Length == 0)
{
_actions.AllegianceHouseAction(1u);
return;
}
string state = FirstArgument(RemainderAfterFirstArgument(arguments));
uint action = (category.ToLowerInvariant(), state.ToLowerInvariant()) switch
{
("guest", "open") => 2u,
("guest", "close") => 3u,
("storage", "open") => 4u,
("storage", "close") => 5u,
_ => 0u,
};
if (action == 0u)
ShowAllegianceHelpRefusal();
else
_actions.AllegianceHouseAction(action);
}
private void ExecuteAllegianceMotd(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Length == 0)
_actions.QueryMotd();
else if (operation.Equals("set", StringComparison.OrdinalIgnoreCase))
_actions.SetMotd(RemainderAfterFirstArgument(arguments).Trim());
else if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
_actions.ClearMotd();
else
ShowAllegianceHelpRefusal();
}
private void ExecuteHouseGuests(string arguments)
{
string operation = FirstArgument(arguments);
string name = RemainderAfterFirstArgument(arguments).Trim();
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase)
|| operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
{
if (name.Length == 0)
{
ShowClientLocal("Please specify the guest's name.");
return;
}
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase))
_actions.AddPermanentGuest(name);
else
_actions.RemovePermanentGuest(name);
return;
}
if (operation.Equals("remove_all", StringComparison.OrdinalIgnoreCase))
_actions.RemoveAllPermanentGuests();
else if (operation.Equals("list", StringComparison.OrdinalIgnoreCase)
|| operation.Equals("show", StringComparison.OrdinalIgnoreCase))
_actions.RequestFullGuestList();
else if (operation.Equals("add_allegiance", StringComparison.OrdinalIgnoreCase))
_actions.ModifyAllegianceGuestPermission(true);
else if (operation.Equals("remove_allegiance", StringComparison.OrdinalIgnoreCase))
_actions.ModifyAllegianceGuestPermission(false);
else
ShowHouseHelpRefusal();
}
private void ExecuteHouseStorage(string arguments)
{
string operation = FirstArgument(arguments);
string name = RemainderAfterFirstArgument(arguments).Trim();
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase)
|| operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
{
if (name.Length == 0)
{
ShowClientLocal("Please specify an actual name.");
return;
}
bool enabled = operation.Equals("add", StringComparison.OrdinalIgnoreCase);
if (name.Equals("-all", StringComparison.OrdinalIgnoreCase))
{
if (enabled)
_actions.AddAllStoragePermission();
else
_actions.RemoveAllStoragePermission();
}
else
{
_actions.ChangeStoragePermission(name, enabled);
}
return;
}
if (operation.Equals("remove_all", StringComparison.OrdinalIgnoreCase))
_actions.RemoveAllStoragePermission();
else if (operation.Equals("list", StringComparison.OrdinalIgnoreCase)
|| operation.Equals("show", StringComparison.OrdinalIgnoreCase))
_actions.RequestFullGuestList();
else if (operation.Equals("add_allegiance", StringComparison.OrdinalIgnoreCase))
_actions.ModifyAllegianceStoragePermission(true);
else if (operation.Equals("remove_allegiance", StringComparison.OrdinalIgnoreCase))
_actions.ModifyAllegianceStoragePermission(false);
else
ShowHouseHelpRefusal();
}
private void ExecuteHouseBoot(string arguments)
{
string name = arguments.Trim();
if (name.Length == 0)
{
ShowHouseHelpRefusal();
return;
}
if (name.Equals("-all", StringComparison.OrdinalIgnoreCase))
_actions.BootEveryone();
else
_actions.BootSpecificHouseGuest(name);
}
private void ExecuteHouseHooks(string arguments)
{
string state = FirstArgument(arguments);
if (state.Equals("on", StringComparison.OrdinalIgnoreCase))
_actions.SetHooksVisibility(true);
else if (state.Equals("off", StringComparison.OrdinalIgnoreCase))
_actions.SetHooksVisibility(false);
else
ShowHouseHelpRefusal();
}
private void ShowAllegianceHelpRefusal() => ShowClientLocal(
"Please see @help Allegiance for more information on how to use this command.");
private void ShowHouseHelpRefusal() => ShowClientLocal(
"Please see @help House for more information on how to use this command.");
private void ShowClientLocal(string text) =>
_feedback.ShowClientLocalMessage(text);
private static int RetailStrtolBaseZero(string value)
{
if (string.IsNullOrEmpty(value))
return 0;
int index = 0;
int sign = 1;
if (value[index] is '+' or '-')
{
if (value[index] == '-')
sign = -1;
if (++index == value.Length)
return 0;
}
int numberBase = 10;
if (value[index] == '0')
{
numberBase = 8;
if (index + 2 < value.Length
&& value[index + 1] is 'x' or 'X'
&& HexDigit(value[index + 2]) >= 0)
{
numberBase = 16;
index += 2;
}
}
long result = 0;
bool sawDigit = false;
while (index < value.Length)
{
int digit = HexDigit(value[index]);
if (digit < 0 || digit >= numberBase)
break;
sawDigit = true;
result = Math.Min(
(long)int.MaxValue + (sign < 0 ? 1L : 0L),
result * numberBase + digit);
index++;
}
if (!sawDigit)
return 0;
long signed = sign < 0 ? -result : result;
return (int)Math.Clamp(signed, int.MinValue, int.MaxValue);
static int HexDigit(char c) => c switch
{
>= '0' and <= '9' => c - '0',
>= 'a' and <= 'f' => c - 'a' + 10,
>= 'A' and <= 'F' => c - 'A' + 10,
_ => -1,
};
}
private static string FirstArgument(string arguments)
{
string trimmed = arguments.Trim();
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
return separator < 0 ? trimmed : trimmed[..separator];
}
private static string RemainderAfterFirstArgument(string arguments)
{
string trimmed = arguments.Trim();
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
return separator < 0 ? string.Empty : trimmed[(separator + 1)..].TrimStart();
}
}

View file

@ -111,6 +111,23 @@ public static class RetailClientCommandCatalog
"/framerate",
"/framerate - Toggles the framerate display.");
// ClientCommunicationSystem::DoDay @0x005706F0 ignores argc and toggles
// LScape::m_fAlwaysDaylight plus PlayerModule::PersistentAtDay.
private static readonly Definition Day = AnyArguments(
ClientCommandId.TogglePersistentDaylight,
"/day",
RetailCommandHelpTable.Day);
// ClientCommunicationSystem::DoRenderOption @0x0057E120 forwards the
// complete argv to GraphicsOptions::HandleRenderOption @0x00455C30.
// That handler owns its usage/error reporting, so every argument shape
// must reach the application executor rather than the generic catalog
// validation refusal.
private static readonly Definition Render = AnyArguments(
ClientCommandId.RenderOption,
"/render <option> <value>",
RetailCommandHelpTable.Render);
private static readonly Definition LockUi = NoArguments(
ClientCommandId.ToggleUiLock,
"/lockui",
@ -444,15 +461,96 @@ public static class RetailClientCommandCatalog
"/alh",
"@allegiance hometown (@alh, @ah) - Recalls you to your allegiance bindstone, if your allegiance has tied to one.");
// GameActionAllegianceInfoRequest.Handle — String16L name, empty = self.
// GameActionAllegianceInfoRequest.Handle — String16L member name. Retail
// rejects an empty argument locally before it constructs this action.
// 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>",
"@allegiance info <name> - Requests information on a member of your allegiance.");
private static readonly Definition AllegianceBoot = AnyArguments(
ClientCommandId.AllegianceBoot,
"/allegiance boot [-account] <name>",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceBan = AnyArguments(
ClientCommandId.AllegianceBan,
"/allegiance ban <add|remove|list> [name]",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceChat = AnyArguments(
ClientCommandId.AllegianceChat,
"/allegiance chat <on|off|kick|gag|ungag> ...",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceBroadcast = AnyArguments(
ClientCommandId.AllegianceBroadcast,
"/allegiance broadcast <message>",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceOfficer = AnyArguments(
ClientCommandId.AllegianceOfficer,
"/allegiance officer [add|set|remove|clear|list] ...",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceOfficerTitle = AnyArguments(
ClientCommandId.AllegianceOfficerTitle,
"/allegiance title [set|clear|list] ...",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceName = AnyArguments(
ClientCommandId.AllegianceName,
"/allegiance name [set|clear] ...",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceLock = AnyArguments(
ClientCommandId.AllegianceLock,
"/allegiance lock [on|off|toggle|check|bypass] ...",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceHouse = AnyArguments(
ClientCommandId.AllegianceHouse,
"/allegiance house [guest|storage] [open|close]",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceMotd = AnyArguments(
ClientCommandId.AllegianceMotd,
"/motd [set <text>|clear]",
RetailCommandHelpTable.Motd);
private static readonly Definition HouseOpenStatus = AnyArguments(
ClientCommandId.HouseOpenStatus,
"/house <open|close>",
RetailCommandHelpTable.HouseOverview);
private static readonly Definition HouseStorage = AnyArguments(
ClientCommandId.HouseStorage,
"/house storage <subcommand>",
RetailCommandHelpTable.HouseOverview);
private static readonly Definition HouseBoot = AnyArguments(
ClientCommandId.HouseBoot,
"/house boot <name|-all>",
RetailCommandHelpTable.HouseOverview);
private static readonly Definition HouseBootAll = AnyArguments(
ClientCommandId.HouseBootAll,
"/house boot_all",
RetailCommandHelpTable.HouseOverview);
private static readonly Definition HouseGuests = AnyArguments(
ClientCommandId.HouseGuests,
"/house guest <subcommand>",
RetailCommandHelpTable.HouseOverview);
private static readonly Definition HouseHooks = AnyArguments(
ClientCommandId.HouseHooks,
"/house hooks <on|off>",
RetailCommandHelpTable.HouseOverview);
// 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.".
@ -468,8 +566,9 @@ public static class RetailClientCommandCatalog
// 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).
// table does. The recognized subcommands below now route to their
// complete retail handlers; only genuinely unknown forms use this
// refusal.
private static readonly Definition AllegianceUnrecognizedSubcommand = new(
ClientCommandId.AllegianceUnrecognizedSubcommand,
Usage: "/allegiance <sub>",
@ -477,6 +576,13 @@ public static class RetailClientCommandCatalog
ValidateArguments: static _ => false,
InvalidArgumentsText: "Please see @help Allegiance for more information on how to use this command.");
private static readonly Definition HouseUnrecognizedSubcommand = new(
ClientCommandId.HouseUnrecognizedSubcommand,
Usage: "/house <sub>",
HelpText: "Please see @help House for more information on how to use this command.",
ValidateArguments: static _ => false,
InvalidArgumentsText: "Please see @help House for more information on how to use this command.");
private static readonly FrozenDictionary<string, Definition> ByVerb =
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
{
@ -499,6 +605,8 @@ public static class RetailClientCommandCatalog
["age"] = QueryAge,
["birth"] = QueryBirth,
["framerate"] = FrameRate,
["day"] = Day,
["render"] = Render,
["lockui"] = LockUi,
["version"] = Version,
["loc"] = Location,
@ -545,6 +653,7 @@ public static class RetailClientCommandCatalog
["off"] = OffChannel,
["alh"] = AllegianceHometown,
["ah"] = AllegianceHometown,
["motd"] = AllegianceMotd,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
/// <summary>
@ -603,38 +712,69 @@ public static class RetailClientCommandCatalog
/// <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). 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.
/// Retail's real <c>DoHouse @ 0x00580860</c> owns the complete verb and
/// dispatches its nested commands locally. Unknown/incomplete forms print
/// the exact House refusal rather than escaping to server chat.
/// </summary>
private static bool TryMatchHouse(string arguments, out Match match)
{
match = default;
string subcommand = arguments.ToLowerInvariant();
Definition? definition = subcommand switch
int separator = IndexOfWhitespace(arguments);
string subcommand = separator < 0 ? arguments : arguments[..separator];
string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim();
Definition? definition = subcommand.ToLowerInvariant() switch
{
"recall" or "re" => HouseRecall,
"mansion_recall" or "alleg_recall" or "ma" => MansionRecall,
"abandon" => HouseAbandon,
"open" or "close" => HouseOpenStatus,
"storage" => HouseStorage,
"remove" or "boot" => HouseBoot,
"boot_all" or "remove_all" => HouseBootAll,
"guest" => HouseGuests,
"available" => HouseAvailableList,
"hooks" => HouseHooks,
_ => null,
};
if (definition is null)
return false;
{
match = new Match(
HouseUnrecognizedSubcommand.Command,
arguments,
HouseUnrecognizedSubcommand.Usage,
HasValidArguments: false,
HouseUnrecognizedSubcommand.InvalidArgumentsText);
return true;
}
if (definition == HouseRecall || definition == MansionRecall)
{
match = new Match(
definition.Command,
rest,
definition.Usage,
HasValidArguments: rest.Length == 0,
InvalidArgumentsText: "Please see @help House for more information on how to use this command.");
return true;
}
if (definition == HouseAvailableList)
{
match = new Match(
definition.Command,
rest,
definition.Usage,
definition.ValidateArguments(rest),
definition.InvalidArgumentsText);
return true;
}
string nestedArguments = definition == HouseOpenStatus
? subcommand
: rest;
match = new Match(
definition.Command,
Arguments: string.Empty,
Arguments: nestedArguments,
definition.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
@ -643,25 +783,15 @@ public static class RetailClientCommandCatalog
/// <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).
/// Retail's real <c>DoAllegiance @ 0x0057D5A0</c> handles all twelve
/// subcommands locally. Unknown forms remain locally owned and print its
/// exact Allegiance refusal.
/// </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.
/// Retail claims the entire "allegiance"/"all" verb. Recognized forms
/// execute locally; unknown forms produce
/// <see cref="AllegianceUnrecognizedSubcommand"/> and never escape to
/// channel fallback or server passthrough.
/// </remarks>
private static bool TryMatchAllegiance(string arguments, out Match match)
{
@ -669,24 +799,29 @@ public static class RetailClientCommandCatalog
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))
Definition? definition = subcommand.ToLowerInvariant() switch
{
match = new Match(
AllegianceHometown.Command,
Arguments: string.Empty,
AllegianceHometown.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
return true;
}
"boot" => AllegianceBoot,
"info" => AllegianceInfo,
"chat" or "ch" => AllegianceChat,
"broadcast" or "br" => AllegianceBroadcast,
"ban" => AllegianceBan,
"officer" => AllegianceOfficer,
"title" => AllegianceOfficerTitle,
"hometown" or "ho" => AllegianceHometown,
"motd" => AllegianceMotd,
"name" => AllegianceName,
"lock" => AllegianceLock,
"house" => AllegianceHouse,
_ => null,
};
if (subcommand.Equals("info", StringComparison.OrdinalIgnoreCase))
if (definition is not null)
{
match = new Match(
AllegianceInfo.Command,
rest,
AllegianceInfo.Usage,
definition.Command,
definition == AllegianceHometown ? string.Empty : rest,
definition.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
return true;

View file

@ -10,8 +10,7 @@ namespace AcDream.Runtime.Chat;
/// 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). <b>Corrected at the consolidated-review round
/// here too). <b>Corrected at the consolidated-review round
/// (2026-08-10), SHOULD-FIX 1:</b> the sentence above previously claimed
/// this table covers only verbs <see cref="RetailClientCommandCatalog"/>
/// "doesn't dispatch directly" — that framing is now FALSE and was itself
@ -379,9 +378,8 @@ public static class RetailCommandHelpTable
// BN already fully decodes with no vtable-slot artifact to work around.
// The pristine dump's "broadcast" line ends "...Also: @ab\n" (no
// bracket) and the "hometown" line ends "...tied to one.\n" (no
// bracket, no alias mention at all) — both removed here. TS-68's
// implemented-vs-not-yet-implemented tracking lives in ISSUES.md and
// the divergence register now, not in this user-visible string.
// bracket, no alias mention at all) — both removed here. Implementation
// status never belongs in this verbatim user-visible string.
public const string AllegianceOverview =
"@allegiance - Commands to help manage your allegiance.\n"
+ "@allegiance boot [-account] <name> - Removes a character from your allegiance.\n"
@ -427,8 +425,7 @@ public static class RetailCommandHelpTable
// counterpart. The "@house available" line's retail literal is exactly
// "@house available - See @hslist\n" — no trailing period, and none of
// the bracketed "[see @hslist, IMPLEMENTED]" text the old version
// appended. TS-68's implemented-vs-not-yet-implemented tracking lives
// in ISSUES.md and the divergence register now, not in this
// appended. Implementation status never belongs in this verbatim
// user-visible string.
public const string HouseOverview =
HouseOneLiner
@ -955,6 +952,9 @@ public static class RetailCommandHelpTable
// that in place of retail's text is exactly what this table exists
// to prevent.
["log"] = Log,
["day"] = Day,
["render"] = Render,
["motd"] = Motd,
["lifestone"] = LifestoneDetail,
["lif"] = LifestoneDetail,
["ls"] = LifestoneDetail,

View file

@ -1,6 +1,7 @@
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Entities;
@ -597,8 +598,8 @@ public sealed class InboundPhysicsStateController
/// PRE-PLACEMENT writes unconditionally. Retail runs both of them BEFORE
/// <c>MoveOrTeleport</c> is consulted, so their gates are a pure function
/// of the timestamp disposition and the static HasAnims proxy - which is
/// why classifying them here needs no route, no player distance and no
/// signature change (see the truth table at the call below). The
/// why deriving them here needs no route, no player distance and no
/// signature change. The
/// near/far/teleport routing decision proper is still downstream of this
/// merge and still belongs to
/// <c>RuntimeAuthoritativePositionRouteClassifier</c>, which the
@ -606,15 +607,9 @@ public sealed class InboundPhysicsStateController
/// which the App-layer OnPosition tail runs post-merge for this caller;
/// contact still comes solely from the retained wire packet's own
/// <c>IsGrounded</c> bit on both. That remaining structural difference
/// (two callers computing the same two flags from the same two inputs
/// rather than sharing one code path) is internal refactor debt - it is
/// NOT a retail divergence and does not belong in
/// docs/architecture/retail-divergence-register.md. It is tracked as
/// docs/ISSUES.md issue <b>#322</b>, filed 2026-08-05 at the C5b review
/// (finding S1): #275 is CLOSED and this comment's former "tracked for
/// the eventual cutover unification / see docs/ISSUES.md" wording pointed
/// at nothing once it was. #322 also records why widening this method's
/// signature to take a whole route would be the wrong unification.
/// shared <c>DerivePrePlacementFlags</c> function now owns the truth table
/// for both this merge and the downstream route classifier (#322); the
/// full route remains downstream because these writes do not depend on it.
/// </summary>
public bool TryApplyPosition(
WorldSession.EntityPositionUpdate update,
@ -680,14 +675,6 @@ public sealed class InboundPhysicsStateController
// ForcePosition | false | false
// Apply | !hasAnimations | true
//
// That is exactly RuntimeAuthoritativePositionRouteClassifier's own
// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting rows
// (ClassifyAcceptedPosition: false/false on the force row, and
// `!request.HasAnimations`/true on EVERY accepted non-force route).
// The classifier stays the oracle; the equality of the two small pure
// computations is pinned by test, not by a shared code path, so each
// remains separately sabotage-verifiable.
bool force = disposition is PositionTimestampDisposition.ForcePosition;
// AP-130's static proxy, computed from the PRE-merge snapshot `old`
// with the identical expression RuntimeAcceptedPositionRouteRequests
// uses. Deliberately NOT a live animation-queue read.
@ -695,6 +682,10 @@ public sealed class InboundPhysicsStateController
(old.MotionTableId ?? old.Physics?.MotionTableId)
is { } motionTableId
&& motionTableId != 0u;
RuntimeAcceptedPositionPrePlacementFlags prePlacement =
RuntimeAuthoritativePositionRouteClassifier.DerivePrePlacementFlags(
disposition,
hasAnimations);
accepted = ApplyAcceptedPosition(
old,
update,
@ -703,8 +694,8 @@ public sealed class InboundPhysicsStateController
isLocalPlayer,
forcePositionRotation,
currentLocalVelocity,
installPlacementFrame: !force && !hasAnimations,
clearParent: !force);
installPlacementFrame: prePlacement.ApplyPlacementFrameBeforeRouting,
clearParent: prePlacement.UnparentBeforeRouting);
_snapshots[update.Guid] = accepted;
return true;
}
@ -713,10 +704,8 @@ public sealed class InboundPhysicsStateController
/// the remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>. The
/// executor passes its classified route's own
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>
/// flags; since C5b (#275) the steady-state
/// <see cref="TryApplyPosition"/> caller passes the same two values,
/// derived pre-merge from (disposition, hasAnimations) rather than read
/// off a route.</summary>
/// flags; the steady-state <see cref="TryApplyPosition"/> caller gets the
/// same values from the shared pre-placement derivation.</summary>
internal bool ApplyAcceptedPositionSnapshot(
uint guid,
WorldSession.EntityPositionUpdate update,
@ -817,13 +806,9 @@ public sealed class InboundPhysicsStateController
/// <see cref="AcceptedPhysicsTimestamps"/> captured at admission time.
///
/// <paramref name="installPlacementFrame"/>/<paramref name="clearParent"/>
/// (Round 3 B6) carry retail's two PRE-PLACEMENT gates. Since C5b (#275)
/// BOTH callers supply the same classified values, from the same two
/// inputs: the continuation executor reads its classified route's
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>,
/// and the steady-state <see cref="TryApplyPosition"/> merge derives them
/// pre-merge from (disposition, hasAnimations) - see the truth table
/// there. Both are false only for the FORCE_POSITION branch, which
/// (Round 3 B6) carry retail's two PRE-PLACEMENT gates. Both callers now
/// consume the single <c>DerivePrePlacementFlags</c> truth table. Both
/// flags are false only for the FORCE_POSITION branch, which
/// retail's HandleReceivedPosition Gate A returns from immediately,
/// BEFORE either call; <paramref name="installPlacementFrame"/> is
/// additionally false whenever HasAnims is true.

View file

@ -1,14 +1,15 @@
using System.Globalization;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Ui;
using AcDream.Core.Properties;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Canonical presentation-independent owner for the House tab of retail's
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately MINIMAL
/// "houseless-status only" per ISSUES #413's own sizing note: a full
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately lightweight
/// a full
/// <c>RuntimeTradeState</c>-weight owner (construction-transaction
/// <c>Fault()</c> injection point, disposal ordering, convergence tracking)
/// is disproportionate for what this slice needs, since (unlike Trade) this
@ -28,8 +29,8 @@ namespace AcDream.Runtime.Gameplay;
/// (<c>DisplayRentPayment</c>, <c>DisplayBuyTime</c>,
/// <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
/// <c>DisplayWarningText</c>) open with <c>if (this-&gt;m_pHouseData != 0)</c>
/// and emit NOTHING when houseless — those remain unported, ISSUES #413
/// item 3.
/// and emit NOTHING when houseless. The owned-house path retains the exact
/// 0x0225 snapshot and ports all seven builders in retail's fixed order.
/// </para>
/// <para>
/// <b>CORRECTED at the 2026-08-17 morning gate round (user finding 2: the
@ -63,7 +64,9 @@ namespace AcDream.Runtime.Gameplay;
/// <c>m_pHouseData == 0</c> (still houseless) and emits the literal at
/// <c>data_7ab7f0</c>: <b>"You may buy another house immediately."</b>
/// (owns-a-house sibling at <c>data_7ab818</c>, byte-re-verified this
/// round). So a queried houseless character's House tab shows exactly TWO
/// round). The owned branch now composes the retained buy list through the
/// retail <c>HousePaymentList</c> rules. So a queried houseless character's
/// House tab shows exactly TWO
/// lines, in builder order: "You do not currently own a house." then the
/// purchase-time line — which this class now renders.
/// </para>
@ -101,7 +104,9 @@ public sealed class RuntimeHouseState
private readonly object _gate = new();
private bool _hasReceivedNotice;
private bool _ownsHouse;
private GameEvents.HouseData? _houseData;
private IReadOnlyList<string> _lines = Array.Empty<string>();
private IReadOnlyList<HousePanelLine> _panelLines = Array.Empty<HousePanelLine>();
/// <summary>Borrows the canonical object table (optional for bare
/// fixtures) to read the local player's own
@ -123,6 +128,28 @@ public sealed class RuntimeHouseState
get { lock (_gate) return _lines; }
}
/// <summary>The same rows with retail's <c>HousePanelTextColor</c>
/// index preserved for the authored row template.</summary>
public IReadOnlyList<HousePanelLine> PanelLines
{
get { lock (_gate) return _panelLines; }
}
/// <summary>The owned-house position used by retail's Map-page house
/// marker. Apartments intentionally expose no landscape position.</summary>
public CreateObject.ServerPosition? Position
{
get
{
lock (_gate)
{
return _houseData is { Type: not 4u, Position: { LandblockId: not 0u } } data
? data.Position
: null;
}
}
}
/// <summary>Whether any of the four House notices (0x0225-0x0228) has
/// arrived this session.</summary>
public bool HasReceivedNotice
@ -131,15 +158,48 @@ public sealed class RuntimeHouseState
}
/// <summary>0x0225 HouseData — <c>RecvNotice_UpdateHouseData</c>
/// (owned-house case). Only <see cref="_ownsHouse"/> is consumed today;
/// the owned-house payload itself (buy/rent payments, times, location)
/// feeds ISSUES #413's remaining six builders, not yet ported.</summary>
/// (owned-house case). The snapshot is retained defensively because its
/// payment lists are later replaced by 0x0227/0x0228 notices.</summary>
public void ApplyHouseData(GameEvents.HouseData data, uint selfGuid)
{
lock (_gate)
{
_hasReceivedNotice = true;
_ownsHouse = true;
_houseData = Copy(data);
Recompute(selfGuid);
}
}
/// <summary>0x0227 UpdateRentTime. Retail installs the new period start,
/// clears every paid count, then rebuilds the complete panel.</summary>
public void ApplyRentTime(uint rentTime, uint selfGuid)
{
lock (_gate)
{
if (_houseData is not { } data)
return;
GameEvents.HousePayment[] rent = data.Rent
.Select(static payment => payment with { Paid = 0 })
.ToArray();
_houseData = data with { RentTime = rentTime, Rent = rent };
Recompute(selfGuid);
}
}
/// <summary>0x0228 UpdateRentPayment. Retail replaces the complete rent
/// list, then rebuilds the complete panel.</summary>
public void ApplyRentPayment(
IReadOnlyList<GameEvents.HousePayment> rent, uint selfGuid)
{
ArgumentNullException.ThrowIfNull(rent);
lock (_gate)
{
if (_houseData is not { } data)
return;
_houseData = data with { Rent = rent.ToArray() };
Recompute(selfGuid);
}
}
@ -158,6 +218,7 @@ public sealed class RuntimeHouseState
{
_hasReceivedNotice = true;
_ownsHouse = false;
_houseData = null;
Recompute(selfGuid);
}
}
@ -171,29 +232,67 @@ public sealed class RuntimeHouseState
{
_hasReceivedNotice = false;
_ownsHouse = false;
_houseData = null;
_lines = Array.Empty<string>();
_panelLines = Array.Empty<HousePanelLine>();
}
}
/// <summary>The ported share of <c>gmHouseUI::Update</c>'s fixed
/// seven-builder order: <c>DisplayBuyPayment @0x004a2b30</c>'s
/// houseless branch (first) and <c>DisplayPurchaseTimeText
/// @0x004a3110</c> (last), both branches. The five houseless-silent
/// builders between them, and DisplayBuyPayment's owned branch, are
/// ISSUES #413 item 3. Must hold <see cref="_gate"/>.</summary>
/// <summary>Port of <c>gmHouseUI::DisplayHouseData @0x004a3380</c>'s
/// fixed seven-builder order. <c>DisplayRentTimes</c> emits two rows, so
/// an owned outdoor house produces eight rows total. Must hold
/// <see cref="_gate"/>.</summary>
private void Recompute(uint selfGuid)
{
var lines = new List<string>(2);
var lines = new List<HousePanelLine>(_ownsHouse ? 8 : 2);
// gmHouseUI::DisplayBuyPayment @0x004a2b30 — NOT houseless-silent
// (2026-08-17 morning gate correction; see the class doc): the
// m_pHouseData gate only selects WHICH text, and the emit runs in
// both branches. Houseless (@0x004a2b57, byte-decoded data_7ab688):
// this exact literal. Owned (@0x004a2b63, data_7ab65c "The purchase
// price for this dwelling is:\n" + HousePaymentList::ComposeText):
// unported, #413 item 3 — the owned case adds nothing here yet.
if (!_ownsHouse)
lines.Add("You do not currently own a house.");
// both branches. The owned prefix and every remaining literal were
// recovered from the PDB-paired binary for issue #413.
if (_houseData is { } data)
{
lines.Add(Normal(
"The purchase price for this dwelling is:\n"
+ ComposePayments(data.Buy, includePaid: false)));
lines.Add(Normal(
"Rent:\n" + ComposePayments(data.Rent, includePaid: true)));
lines.Add(Normal("Bought: " + ConvertTime(data.BuyTime)));
long period = GetRentPeriodSeconds(data.Type);
bool paid = data.MaintenanceFree || IsPaidInFull(data.Rent);
lines.Add(Normal(
"This maintenance period ends: "
+ ConvertTime((long)data.RentTime + period)));
lines.Add(Normal(
"Maintenance is next due: "
+ ConvertTime((long)data.RentTime + (paid ? 2L : 1L) * period)));
if (data.Type != 4u
&& RadarCoordinates.TryFromCell(
data.Position.LandblockId, out RadarCoordinates coordinates))
{
lines.Add(Normal(
$"Location: {coordinates.YText}, {coordinates.XText}"));
}
lines.Add(paid
? new HousePanelLine(
"The maintenance has already been paid for this period. "
+ "You may not prepay next period's maintenance.",
HousePanelTextColor.RentPaid)
: new HousePanelLine(
"Warning! You have not paid your maintenance costs for the last "
+ (period / 86_400L).ToString(CultureInfo.InvariantCulture)
+ " day maintenance period. Please pay these costs by this deadline"
+ " or you will lose your house, and all your items within it.",
HousePanelTextColor.RentNotPaid));
}
else
{
lines.Add(Normal("You do not currently own a house."));
}
// gmHouseUI::DisplayPurchaseTimeText @0x004a3110, both branches.
int timestamp = _objects?.Get(selfGuid)?.Properties
@ -221,18 +320,95 @@ public sealed class RuntimeHouseState
timestamp + PurchaseWaitPeriodSeconds);
DateTime expiryLocal = TimeZoneInfo.ConvertTime(
expiryUtc, _timeProvider.LocalTimeZone).DateTime;
lines.Add(
lines.Add(Normal(
"You may buy another landscape house at "
+ expiryLocal.ToString(CultureInfo.CurrentCulture)
+ ". This restriction does not apply to apartments.");
+ ". This restriction does not apply to apartments."));
}
else
{
lines.Add(_ownsHouse
lines.Add(Normal(_ownsHouse
? "You may buy another house immediately after you abandon this one."
: "You may buy another house immediately.");
: "You may buy another house immediately."));
}
_lines = lines;
_panelLines = lines;
_lines = lines.Select(static line => line.Text).ToArray();
}
private HousePanelLine Normal(string text) =>
new(text, HousePanelTextColor.Normal);
private static GameEvents.HouseData Copy(GameEvents.HouseData data) =>
data with
{
Buy = (data.Buy ?? Array.Empty<GameEvents.HousePayment>()).ToArray(),
Rent = (data.Rent ?? Array.Empty<GameEvents.HousePayment>()).ToArray(),
};
private static bool IsPaidInFull(IReadOnlyList<GameEvents.HousePayment> payments)
{
for (int i = 0; i < payments.Count; i++)
{
if (payments[i].Paid < payments[i].Num)
return false;
}
return true;
}
private static string ComposePayments(
IReadOnlyList<GameEvents.HousePayment> payments, bool includePaid)
{
if (payments.Count == 0)
return string.Empty;
var parts = new string[payments.Count];
for (int i = 0; i < payments.Count; i++)
{
GameEvents.HousePayment payment = payments[i];
string quantity = includePaid
? $"{payment.Paid.ToString(CultureInfo.InvariantCulture)}/{payment.Num.ToString(CultureInfo.InvariantCulture)}"
: payment.Num.ToString(CultureInfo.InvariantCulture);
parts[i] = quantity + " " + PaymentName(payment);
}
return string.Join(", ", parts);
}
private static string PaymentName(GameEvents.HousePayment payment)
{
if (payment.Num == 1)
return payment.Name;
if (!string.IsNullOrEmpty(payment.PluralName))
return payment.PluralName;
return payment.Name.EndsWith('s') || payment.Name.EndsWith('x')
? payment.Name + "es"
: payment.Name + "s";
}
private static long GetRentPeriodSeconds(uint houseType) =>
houseType == 4u ? 7_776_000L : 2_592_000L;
private string ConvertTime(long epochSeconds)
{
if (epochSeconds == 0L)
return "N/A";
DateTimeOffset utc = DateTimeOffset.FromUnixTimeSeconds(epochSeconds);
DateTime local = TimeZoneInfo.ConvertTime(
utc, _timeProvider.LocalTimeZone).DateTime;
return local.ToString(CultureInfo.CurrentCulture);
}
}
/// <summary>Retail <c>HousePanelTextColor</c>; the numeric value is the
/// authored row template's font-color palette index.</summary>
public enum HousePanelTextColor
{
Normal = 0,
RentPaid = 1,
RentNotPaid = 2,
}
public readonly record struct HousePanelLine(
string Text, HousePanelTextColor Color);

View file

@ -131,6 +131,16 @@ internal readonly record struct RuntimeAcceptedPositionRouteRequest(
bool HasAnimations,
RuntimePositionPlacementFacts PlacementFacts);
/// <summary>
/// Retail's two writes before <c>MoveOrTeleport</c>: the force-position
/// self-echo returns before both; every ordinary accepted Position unparents,
/// and only an object without animations receives the wire placement frame.
/// These facts depend solely on timestamp disposition and HasAnims.
/// </summary>
internal readonly record struct RuntimeAcceptedPositionPrePlacementFlags(
bool UnparentBeforeRouting,
bool ApplyPlacementFrameBeforeRouting);
/// <summary>
/// Immutable action plan for retail HandleReceivedPosition/MoveOrTeleport.
/// It deliberately contains no renderer, world entity, UI, or host callback.
@ -189,6 +199,20 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
internal static bool IsValidCreateWirePosition(
in CreateObject.ServerPosition position) => ValidPosition(position);
internal static RuntimeAcceptedPositionPrePlacementFlags DerivePrePlacementFlags(
PositionTimestampDisposition disposition,
bool hasAnimations) => disposition switch
{
PositionTimestampDisposition.Apply => new(
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !hasAnimations),
PositionTimestampDisposition.ForcePosition => default,
// Rejected packets use the timestamp-only merge and never inspect
// either flag. Returning default keeps that branch explicit.
PositionTimestampDisposition.Rejected => default,
_ => throw new ArgumentOutOfRangeException(nameof(disposition), disposition, null),
};
internal static RuntimeAuthoritativePositionRoute ClassifyCreate(
in RuntimeCreatePositionRouteRequest request)
{
@ -308,6 +332,10 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
if (!ValidPosition(request.AcceptedWirePosition))
return RejectedData(request.Authority, operation, reporting);
RuntimeAcceptedPositionPrePlacementFlags prePlacement =
DerivePrePlacementFlags(
request.Authority.TimestampDisposition,
request.HasAnimations);
bool force = request.Authority.TimestampDisposition
is PositionTimestampDisposition.ForcePosition;
if (force)
@ -320,8 +348,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
request.PlacementFrame ?? 0u,
UnparentBeforeRouting: false,
ApplyPlacementFrameBeforeRouting: false,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -343,8 +371,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.AfterPositionOperation,
StopInterpolating: false,
@ -365,8 +393,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
PhysicsSetPositionFlags.None,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -396,8 +424,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.BeforePositionOperation,
StopInterpolating: false,
@ -422,8 +450,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
PhysicsSetPositionFlags.None,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -452,8 +480,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
nearby ? PhysicsSetPositionFlags.None : AuthoritativeTeleportFlags,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: !nearby,
@ -480,9 +508,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
{
PositionTimestampDisposition.Apply => true,
PositionTimestampDisposition.ForcePosition =>
kind is RuntimePositionEntityKind.LocalPlayer
&& authority.PreviousTeleportSequence
== authority.AcceptedTeleportSequence,
kind is RuntimePositionEntityKind.LocalPlayer,
_ => false,
};
}

View file

@ -597,6 +597,14 @@ internal sealed class RuntimeSetPositionState : IDisposable
_deferredByCellGeneration = [];
private SortedDictionary<ulong, RuntimePlacementProjectionSnapshot>
_pendingProjection = [];
// Issue #311: retry is a per-tick pump while a host receipt remains
// unacknowledged. Retain one snapshot list per synchronous call depth
// instead of allocating Values.ToArray() every tick. PublishPlacement can
// invoke arbitrary observers, so the depth-indexed shape preserves the
// old snapshot semantics even if an observer re-enters this method.
private readonly List<List<RuntimePlacementProjectionSnapshot>>
_pendingProjectionRetryScratchByDepth = [new()];
private int _pendingProjectionRetryDepth;
private readonly List<CellGenerationKey> _deferredBucketOrder = [];
private readonly Dictionary<UnboundCellKey, List<RuntimeEntityKey>>
_unboundDeferredByCell = [];
@ -934,6 +942,14 @@ internal sealed class RuntimeSetPositionState : IDisposable
if (HasPendingProjectionThrough(current.ProjectionBarrierSequence))
return false;
// #310: collision retirement is the stronger authority for an
// authored mover that is still waiting for its first preparation.
// Such an operation has no canonical placement result or projection
// to preserve; waiting for an asset that may never resolve used to
// pin this prefix forever. Cancel the exact unprepared operation
// before evaluating ordinary placement debt, then let the resident
// enter the retirement park below.
CancelUnpreparedPrefixPlacementDebt(current);
if (HasOldPrefixPlacementDebt(current))
return false;
@ -1117,18 +1133,42 @@ internal sealed class RuntimeSetPositionState : IDisposable
internal void RetryPendingProjections()
{
EnsureNotDisposed();
RuntimePlacementProjectionSnapshot[] snapshot =
_pendingProjection.Values.ToArray();
for (int index = 0; index < snapshot.Length; index++)
int depth = _pendingProjectionRetryDepth;
if (depth == _pendingProjectionRetryScratchByDepth.Count)
{
RuntimePlacementProjectionSnapshot projection = snapshot[index];
if (_pendingProjection.TryGetValue(
projection.Token.Sequence,
out RuntimePlacementProjectionSnapshot current)
&& current == projection)
_pendingProjectionRetryScratchByDepth.Add([]);
}
List<RuntimePlacementProjectionSnapshot> snapshot =
_pendingProjectionRetryScratchByDepth[depth];
_pendingProjectionRetryDepth = depth + 1;
try
{
snapshot.Clear();
// Enumerate the dictionary itself. SortedDictionary.Values exposes
// its enumerator through an interface and boxes it (~72 B/call),
// which would retain a smaller version of the allocation this
// issue removes.
foreach (KeyValuePair<ulong, RuntimePlacementProjectionSnapshot>
entry in _pendingProjection)
{
PublishPlacement(projection);
snapshot.Add(entry.Value);
}
for (int index = 0; index < snapshot.Count; index++)
{
RuntimePlacementProjectionSnapshot projection = snapshot[index];
if (_pendingProjection.TryGetValue(
projection.Token.Sequence,
out RuntimePlacementProjectionSnapshot current)
&& current == projection)
{
PublishPlacement(projection);
}
}
}
finally
{
snapshot.Clear();
_pendingProjectionRetryDepth = depth;
}
}
@ -4101,6 +4141,50 @@ internal sealed class RuntimeSetPositionState : IDisposable
return false;
}
private void CancelUnpreparedPrefixPlacementDebt(
CollisionPrefixQuiescence state)
{
uint prefix = state.Token.LandblockPrefix;
List<RuntimeEntityKey>? cancelled = null;
foreach (Operation operation in _operations.Values)
{
if (operation.WakeableLostCell
|| operation.DormantLocalActivation
|| operation.Stage is not RuntimeEntityPlacementStage
.AwaitingPreparation
|| !_moverPreparationAuthorities.TryGetValue(
operation.Key,
out MoverPreparationAuthority preparation)
|| preparation.OperationId != operation.Token.OperationId
|| preparation.Prepared
|| !((preparation.AcceptedPosition.LandblockId
& 0xFFFF0000u) == prefix
|| IsAffectedCollisionResident(
operation.Record,
prefix,
state.IncludeOutdoorCells)))
{
continue;
}
(cancelled ??= []).Add(operation.Key);
}
if (cancelled is null)
return;
for (int index = 0; index < cancelled.Count; index++)
{
_ = CancelCoreDeferred(
cancelled[index],
cancelLostFamily: false,
preserveLostFamily: false,
out RuntimePlacementProjectionSnapshot? discard);
if (discard is { } projection)
PublishPlacement(projection);
}
}
private static bool PlacementTouchesPrefix(
in PhysicsSetPositionRequest request,
uint prefix) =>

View file

@ -95,7 +95,11 @@ public sealed record LiveSocialSessionBindings(
RuntimeHouseState? House = null,
// Campaign QT (2026-08-21): the fourth sibling J-owner, same
// trailing/optional compatibility convention.
RuntimeContractState? Contracts = null);
RuntimeContractState? Contracts = null,
// Issue #359: retail suppresses 0x019E PlayerKilled for the victim and
// killer. Optional for compatibility with state-only tests; production
// hosts always supply their canonical identity owner.
Func<uint>? PlayerGuid = null);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
@ -338,6 +342,12 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
onHouseStatus: social.House is { } houseStatus
? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid())
: null,
onHouseUpdateRentTime: social.House is { } houseRentTime
? rentTime => houseRentTime.ApplyRentTime(rentTime, inventory.PlayerGuid())
: null,
onHouseUpdateRentPayment: social.House is { } houseRentPayment
? rent => houseRentPayment.ApplyRentPayment(rent, inventory.PlayerGuid())
: null,
// Campaign QT (2026-08-21): same conditional delegate-hole
// discipline as house above.
onContractTable: social.Contracts is { } contractTable
@ -451,7 +461,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
killed => social.Chat.OnPlayerKilled(
killed.DeathMessage,
killed.VictimGuid,
killed.KillerGuid));
killed.KillerGuid,
social.PlayerGuid?.Invoke() ?? 0u));
Subscribe<TurbineChat.Parsed>(
h => session.TurbineChatReceived += h,
h => session.TurbineChatReceived -= h,