feat(chat): port retail client command families

Expand the typed client-command boundary across travel, character queries, local UI and layout controls, AFK and consent, emotes, friends, squelch and filters, and fill-components. Preserve retail packet layouts and queue ownership, import the confirmation dialog, and keep authoritative social state in Core.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 14:50:15 +02:00
parent 5a45a7ac7f
commit 9ea579bdd0
47 changed files with 6659 additions and 99 deletions

View file

@ -136,6 +136,7 @@ public sealed class PlayerMovementController
public Vector3 Position => _body.Position;
public Vector3 RenderPosition => ComputeRenderPosition();
public uint CellId { get; private set; }
public AcDream.Core.Physics.Position CellPosition => _body.CellPosition;
/// <summary>
/// Local-player entity id used to skip self-collision in the

View file

@ -730,6 +730,10 @@ public sealed class GameWindow : IDisposable
public readonly AcDream.Core.Chat.TurbineChatState TurbineChat = new();
public readonly AcDream.Core.Combat.CombatState Combat = new();
public readonly AcDream.Core.Items.ItemManaState ItemMana = new();
public readonly AcDream.Core.Social.FriendsState Friends = new();
public readonly AcDream.Core.Social.SquelchState Squelch = new();
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents { get; private set; }
= System.Array.Empty<(uint Id, uint Amount)>();
// Issue #11 — load static spell metadata from data/spells.csv at startup.
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
// for the future Spellbook panel. The CSV is copied to bin/<config>/net10.0/data/
@ -757,6 +761,7 @@ public sealed class GameWindow : IDisposable
// when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md
private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
@ -2114,6 +2119,7 @@ public sealed class GameWindow : IDisposable
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI);
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
_retailChatVm = retailChatVm;
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
? null
: new AcDream.App.UI.RetailUiPersistenceBindings(
@ -2642,7 +2648,17 @@ public sealed class GameWindow : IDisposable
onShortcuts: list => Shortcuts = list,
playerGuid: () => _playerServerGuid,
onUseDone: error => _itemInteractionController?.CompleteUse(error),
itemMana: ItemMana);
itemMana: ItemMana,
onConfirmationRequest: request =>
_retailUiRuntime?.ConfirmationDialogs?.Show(
request.Message,
accepted => session.SendConfirmationResponse(
request.Type,
request.ContextId,
accepted)),
friends: Friends,
squelch: Squelch,
onDesiredComponents: components => DesiredComponents = components);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into
@ -2717,7 +2733,62 @@ public sealed class GameWindow : IDisposable
var turbineChat = TurbineChat;
uint playerGuid = _playerServerGuid;
var clientCommandController = new AcDream.App.UI.ClientCommandController(
liveSession.SendTeleportToLifestone);
new AcDream.App.UI.ClientCommandController.Bindings(
TeleportToLifestone: liveSession.SendTeleportToLifestone,
TeleportToMarketplace: liveSession.SendTeleportToMarketplace,
TeleportToPkArena: liveSession.SendTeleportToPkArena,
TeleportToPkLiteArena: liveSession.SendTeleportToPkLiteArena,
TeleportToHouse: liveSession.SendTeleportToHouse,
TeleportToMansion: liveSession.SendTeleportToMansion,
QueryAge: liveSession.SendQueryAge,
QueryBirth: liveSession.SendQueryBirth,
ToggleFrameRate: ToggleRetailFrameRate,
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
ShowSystemMessage: text => chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
ClientVersion: () =>
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
?? "unknown",
CurrentPosition: () => _playerController?.CellPosition,
LastOutsideCorpsePosition: () =>
LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
_retailUiRuntime?.ConfirmationDialogs?.Show(message, completed),
Suicide: liveSession.SendSuicide,
ClearChat: _ => chat.Clear(),
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
IsAway: () =>
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
SetAway: away => SetRetailAway(liveSession, away),
SetAwayMessage: liveSession.SendSetAfkMessage,
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
SetAcceptLootPermits: SetRetailAcceptLootPermits,
DisplayConsent: liveSession.SendDisplayConsent,
ClearConsent: liveSession.SendClearConsent,
RemoveConsent: liveSession.SendRemoveConsent,
SendEmote: liveSession.SendEmote,
Friends: Friends,
AddFriend: liveSession.SendAddFriend,
RemoveFriend: liveSession.SendRemoveFriend,
ClearFriends: liveSession.SendClearFriends,
RequestLegacyFriends: liveSession.SendLegacyFriendsListRequest,
Squelch: Squelch,
ModifyCharacterSquelch: liveSession.SendModifyCharacterSquelch,
ModifyAccountSquelch: liveSession.SendModifyAccountSquelch,
ModifyGlobalSquelch: liveSession.SendModifyGlobalSquelch,
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
ClearDesiredComponents: () =>
{
liveSession.SendClearDesiredComponents();
DesiredComponents = System.Array.Empty<(uint Id, uint Amount)>();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }));
_commandBus.Register<AcDream.UI.Abstractions.ExecuteClientCommandCmd>(
clientCommandController.Execute);
_commandBus.Register<AcDream.UI.Abstractions.SendServerCommandCmd>(cmd =>
@ -11386,6 +11457,46 @@ public sealed class GameWindow : IDisposable
}
}
private void SetRetailAway(AcDream.Core.Net.WorldSession session, bool away)
{
session.SendSetAfkMode(away);
}
private void SetRetailAcceptLootPermits(bool enabled)
{
_persistedGameplay = _persistedGameplay with { AcceptLootPermits = enabled };
_settingsVm?.SetGameplay(
_settingsVm.GameplayDraft with { AcceptLootPermits = enabled });
_settingsStore?.SaveGameplay(_persistedGameplay);
}
/// <summary>
/// Retail <c>ClientCommunicationSystem::DoFrameRate @ 0x005707D0</c>:
/// flip the live display flag and persist it through the same settings
/// path used by the Display panel.
/// </summary>
private void ToggleRetailFrameRate()
{
_persistedDisplay = _persistedDisplay with
{
ShowFps = !_persistedDisplay.ShowFps,
};
if (_settingsVm is not null)
_settingsVm.SetDisplay(_settingsVm.DisplayDraft with
{
ShowFps = _persistedDisplay.ShowFps,
});
try
{
_settingsStore?.SaveDisplay(_persistedDisplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: framerate display save failed: {ex.Message}");
}
}
private void SetRetailCombatGameplay(
AcDream.UI.Abstractions.Panels.Settings.GameplaySettings gameplay)
{

View file

@ -1,20 +1,69 @@
using AcDream.Core.Physics;
using AcDream.Core.Ui;
using AcDream.Core.Social;
using AcDream.UI.Abstractions;
namespace AcDream.App.UI;
/// <summary>
/// Application-layer executor for typed retail client commands. Chat panels
/// remain backend- and network-agnostic; this controller translates their
/// intent to the live-session action supplied by the composition root.
/// remain backend- and network-agnostic; this controller owns the boundary
/// between verified command behavior and live session/UI services.
/// </summary>
public sealed class ClientCommandController
{
private readonly Action _teleportToLifestone;
public sealed record Bindings(
Action TeleportToLifestone,
Action TeleportToMarketplace,
Action TeleportToPkArena,
Action TeleportToPkLiteArena,
Action TeleportToHouse,
Action TeleportToMansion,
Action QueryAge,
Action QueryBirth,
Action ToggleFrameRate,
Action ToggleUiLock,
Action<string> ShowSystemMessage,
Action<uint> ShowWeenieError,
Func<uint?> PlayerPublicWeenieBitfield,
Func<string> ClientVersion,
Func<Position?> CurrentPosition,
Func<Position?> LastOutsideCorpsePosition,
Action<string, Action<bool>> ShowConfirmation,
Action Suicide,
Action<bool> ClearChat,
Action<string> SaveUi,
Action<string> LoadUi,
Action SaveAutoUi,
Action LoadAutoUi,
Func<bool> IsAway,
Action<bool> SetAway,
Action<string> SetAwayMessage,
Func<bool> AcceptLootPermits,
Action<bool> SetAcceptLootPermits,
Action DisplayConsent,
Action ClearConsent,
Action<string> RemoveConsent,
Action<string> SendEmote,
FriendsState Friends,
Action<string> AddFriend,
Action<uint> RemoveFriend,
Action ClearFriends,
Action RequestLegacyFriends,
SquelchState Squelch,
Action<bool, uint, string, uint> ModifyCharacterSquelch,
Action<bool, string> ModifyAccountSquelch,
Action<bool, uint> ModifyGlobalSquelch,
Func<string?> LastTeller,
Action ClearDesiredComponents,
Func<bool> HasOpenVendor,
Action<uint?, uint> FillComponentBuyList);
public ClientCommandController(Action teleportToLifestone)
private readonly Bindings _bindings;
public ClientCommandController(Bindings bindings)
{
_teleportToLifestone = teleportToLifestone
?? throw new ArgumentNullException(nameof(teleportToLifestone));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
}
public void Execute(ExecuteClientCommandCmd command)
@ -23,14 +72,595 @@ public sealed class ClientCommandController
switch (command.Command)
{
// Retail: ClientCommunicationSystem::DoLifestone @ 0x0056FC70
// -> CM_Character::Event_TeleToLifestone @ 0x006A1B90.
// ClientCommunicationSystem::DoLifestone @ 0x0056FC70.
case ClientCommandId.LifestoneRecall:
_teleportToLifestone();
_bindings.TeleportToLifestone();
break;
// ClientCommunicationSystem::DoMarketplace @ 0x0056FCE0.
case ClientCommandId.MarketplaceRecall:
_bindings.TeleportToMarketplace();
break;
// DoPKArena @ 0x005788D0 gates on ACCWeenieObject::IsPK.
case ClientCommandId.PkArenaRecall:
if (HasPlayerFlag(EntityCollisionFlags.IsPK) == false)
_bindings.ShowWeenieError(0x055Fu);
else
_bindings.TeleportToPkArena();
break;
// DoPKLArena @ 0x005789D0 gates on ACCWeenieObject::IsPKLite.
case ClientCommandId.PkLiteArenaRecall:
if (HasPlayerFlag(EntityCollisionFlags.IsPKLite) == false)
_bindings.ShowWeenieError(0x0560u);
else
_bindings.TeleportToPkLiteArena();
break;
case ClientCommandId.HouseRecall:
_bindings.TeleportToHouse();
break;
case ClientCommandId.MansionRecall:
_bindings.TeleportToMansion();
break;
case ClientCommandId.QueryAge:
_bindings.QueryAge();
break;
case ClientCommandId.QueryBirth:
_bindings.QueryBirth();
break;
// DoFrameRate @ 0x005707D0 toggles the display flag; it does
// not print a one-shot FPS sample into chat.
case ClientCommandId.ToggleFrameRate:
_bindings.ToggleFrameRate();
break;
// DoLockUI @ 0x005703B0 toggles PlayerModule::LockUI and
// broadcasts the new state to every UI element.
case ClientCommandId.ToggleUiLock:
_bindings.ToggleUiLock();
break;
case ClientCommandId.ShowVersion:
_bindings.ShowSystemMessage($"Client version {_bindings.ClientVersion()}");
break;
case ClientCommandId.ShowLocation:
Position? position = _bindings.CurrentPosition();
_bindings.ShowSystemMessage(position is { ObjCellId: not 0u }
? $"Your location is: {RetailPositionFormatter.Format(position.Value)}"
: "Not in valid cell!");
break;
case ClientCommandId.ShowLastCorpseLocation:
Position? corpse = _bindings.LastOutsideCorpsePosition();
string? coordinates = corpse is null
? null
: RetailPositionFormatter.FormatOutdoorCell(corpse.Value.ObjCellId);
_bindings.ShowSystemMessage(coordinates is null
? "We're sorry, but we have no record of your last outside corpse location."
: $"The last time you died outside, your corpse was located at ({coordinates}).");
break;
// ClientCommunicationSystem::DoDie @ 0x00580050 and
// DieDialogCallback @ 0x0057BA70.
case ClientCommandId.Die:
_bindings.ShowConfirmation(
"Do you really want to kill your character? You may drop items and accrue a vitae penalty.",
accepted =>
{
if (accepted)
_bindings.Suicide();
});
break;
case ClientCommandId.ClearChat:
_bindings.ClearChat(FirstArgument(command.Arguments)
.Equals("all", StringComparison.OrdinalIgnoreCase));
break;
case ClientCommandId.SaveUi:
ExecuteUiProfile(command.Arguments, save: true);
break;
case ClientCommandId.LoadUi:
ExecuteUiProfile(command.Arguments, save: false);
break;
case ClientCommandId.SaveAutoUi:
if (RequireNoArguments(command.Arguments, "/saveautoui"))
_bindings.SaveAutoUi();
break;
case ClientCommandId.LoadAutoUi:
if (RequireNoArguments(command.Arguments, "/loadautoui"))
_bindings.LoadAutoUi();
break;
case ClientCommandId.Away:
ExecuteAway(command.Arguments);
break;
case ClientCommandId.Consent:
ExecuteConsent(command.Arguments);
break;
case ClientCommandId.Emote:
if (!string.IsNullOrWhiteSpace(command.Arguments))
_bindings.SendEmote(command.Arguments.Trim());
break;
case ClientCommandId.ListEmotes:
_bindings.ShowSystemMessage(StandardEmotes);
break;
case ClientCommandId.Friends:
ExecuteFriends(command.Arguments);
break;
case ClientCommandId.FriendsAdd:
AddFriend(command.Arguments);
break;
case ClientCommandId.FriendsRemove:
RemoveFriend(command.Arguments);
break;
case ClientCommandId.Squelch:
ExecuteSquelch(command.Arguments, add: true);
break;
case ClientCommandId.Unsquelch:
ExecuteSquelch(command.Arguments, add: false);
break;
case ClientCommandId.Filter:
ExecuteGlobalFilter(command.Arguments, add: true);
break;
case ClientCommandId.Unfilter:
ExecuteGlobalFilter(command.Arguments, add: false);
break;
case ClientCommandId.ListMessageTypes:
_bindings.ShowSystemMessage(
"Squelch channels are as follows:\n "
+ string.Join(", ", MessageTypes.Values));
break;
case ClientCommandId.FillComponents:
ExecuteFillComponents(command.Arguments);
break;
default:
throw new ArgumentOutOfRangeException(
nameof(command), command.Command, "Unknown retail client command.");
}
}
private void ExecuteUiProfile(string arguments, bool save)
{
string[] parts = SplitArguments(arguments);
string command = save ? "saveui" : "loadui";
if (parts.Length > 1)
{
_bindings.ShowSystemMessage($"Please use @help {command} for proper usage.");
return;
}
string name = parts.Length == 0 ? string.Empty : parts[0];
if (name.Length > 16)
{
_bindings.ShowSystemMessage("The file name must be 16 characters or less.");
return;
}
if (save) _bindings.SaveUi(name);
else _bindings.LoadUi(name);
}
private bool RequireNoArguments(string arguments, string usage)
{
if (string.IsNullOrWhiteSpace(arguments)) return true;
_bindings.ShowSystemMessage($"Usage: {usage}");
return false;
}
private void ExecuteAway(string arguments)
{
string first = FirstArgument(arguments);
if (first.Length == 0 || first.Equals("on", StringComparison.OrdinalIgnoreCase))
{
if (!_bindings.IsAway()) _bindings.SetAway(true);
return;
}
if (first.Equals("off", StringComparison.OrdinalIgnoreCase))
{
if (_bindings.IsAway()) _bindings.SetAway(false);
return;
}
if (first.Equals("msg", StringComparison.OrdinalIgnoreCase))
{
string message = RemainderAfterFirstArgument(arguments).Trim(' ');
if (message.Length > 191) message = message[..191];
if (message.Length > 0 && !message.Contains('\n')) message += "\n";
_bindings.SetAwayMessage(message);
_bindings.ShowSystemMessage(message.Length == 0
? "New AFK message set: I am currently away from the keyboard."
: $"New AFK message set: {message}");
return;
}
_bindings.ShowSystemMessage(AwayHelp);
}
private void ExecuteConsent(string arguments)
{
string first = FirstArgument(arguments);
if (first.Equals("on", StringComparison.OrdinalIgnoreCase))
{
if (!_bindings.AcceptLootPermits()) _bindings.SetAcceptLootPermits(true);
_bindings.ShowSystemMessage(
"You can now accept corpse looting permissions from other players.");
}
else if (first.Equals("off", StringComparison.OrdinalIgnoreCase))
{
if (_bindings.AcceptLootPermits()) _bindings.SetAcceptLootPermits(false);
_bindings.ShowSystemMessage(
"You are no longer accepting corpse looting permissions from other players.");
}
else if (first.Equals("who", StringComparison.OrdinalIgnoreCase))
{
_bindings.DisplayConsent();
}
else if (first.Equals("clear", StringComparison.OrdinalIgnoreCase))
{
_bindings.ClearConsent();
}
else if (first.Equals("remove", StringComparison.OrdinalIgnoreCase))
{
string name = RemainderAfterFirstArgument(arguments).Trim();
if (name.Length == 0)
_bindings.ShowSystemMessage(
"Please specify a person to remove from your consent list.");
else
_bindings.RemoveConsent(name);
}
else
{
_bindings.ShowSystemMessage("Please specify a valid consent command.");
}
}
private void ExecuteFriends(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Length == 0)
{
DisplayFriends(onlineOnly: false);
return;
}
if (operation.Equals("online", StringComparison.OrdinalIgnoreCase))
{
DisplayFriends(onlineOnly: true);
return;
}
string remainder = RemainderAfterFirstArgument(arguments);
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase))
AddFriend(remainder);
else if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
RemoveFriend(remainder);
else if (operation.Equals("old", StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrWhiteSpace(remainder))
_bindings.RequestLegacyFriends();
else
_bindings.ShowSystemMessage("Invalid friends command specified.");
}
private void AddFriend(string arguments)
{
string name = arguments.Trim();
if (name.Length == 0)
{
_bindings.ShowSystemMessage(
"You must specify the name of the friend you wish to add.");
return;
}
if (_bindings.Friends.Snapshot().Count >= 50)
{
_bindings.ShowWeenieError(0x0561u);
return;
}
_bindings.AddFriend(name);
}
private void RemoveFriend(string arguments)
{
string name = arguments.Trim();
if (name.Length == 0)
{
_bindings.ShowSystemMessage(
"You must specify the name of the friend you wish to remove.");
return;
}
if (name.Equals("-all", StringComparison.OrdinalIgnoreCase))
{
_bindings.ClearFriends();
_bindings.Friends.Clear();
_bindings.ShowSystemMessage("Your friends list has been cleared.\n");
return;
}
FriendEntry? friend = _bindings.Friends.Snapshot().FirstOrDefault(
entry => entry.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (friend is null)
_bindings.ShowWeenieError(0x0563u);
else
_bindings.RemoveFriend(friend.Id);
}
private void DisplayFriends(bool onlineOnly)
{
IReadOnlyList<FriendEntry> entries = _bindings.Friends.Snapshot();
if (entries.Count == 0)
{
_bindings.ShowSystemMessage("Your friends list is empty!\n");
return;
}
var lines = entries
.Where(entry => !onlineOnly || entry.Online)
.Select(entry => $" {entry.Name}{(entry.Online ? " (Online)" : string.Empty)}")
.ToArray();
_bindings.ShowSystemMessage(lines.Length == 0
? "Your friends:\n You have no friends that are online.\n"
: "Your friends:\n" + string.Join("\n", lines) + "\n");
}
private void ExecuteSquelch(string arguments, bool add)
{
if (string.IsNullOrWhiteSpace(arguments))
{
DisplayCharacterSquelches();
return;
}
if (!TryParseSquelch(arguments, out SquelchArguments parsed, out string error))
{
_bindings.ShowSystemMessage(error);
return;
}
if (parsed.AccountWide)
_bindings.ModifyAccountSquelch(add, parsed.Name);
else
_bindings.ModifyCharacterSquelch(add, 0u, parsed.Name, parsed.MessageType);
}
private void ExecuteGlobalFilter(string arguments, bool add)
{
if (string.IsNullOrWhiteSpace(arguments))
{
DisplayGlobalFilters();
return;
}
string[] parts = SplitArguments(arguments);
if (parts.Length != 1 || !parts[0].StartsWith('-'))
{
_bindings.ShowSystemMessage("Incorrect usage, use @help for proper usage.");
return;
}
if (!TryGetMessageType(parts[0][1..], out uint type) || type == 1u)
{
_bindings.ShowSystemMessage("You must specify a valid message type.");
return;
}
_bindings.ModifyGlobalSquelch(add, type);
}
private bool TryParseSquelch(
string arguments,
out SquelchArguments parsed,
out string error)
{
parsed = default;
error = string.Empty;
string[] parts = SplitArguments(arguments);
bool account = false;
uint messageType = 1u;
string? replyName = null;
int index = 0;
for (; index < parts.Length && parts[index].StartsWith('-'); index++)
{
string option = parts[index][1..];
if (option.Equals("account", StringComparison.OrdinalIgnoreCase))
account = true;
else if (option.Equals("reply", StringComparison.OrdinalIgnoreCase))
{
replyName = _bindings.LastTeller();
if (string.IsNullOrWhiteSpace(replyName))
{
error = "A player must @tell you before you can use the -reply option.";
return false;
}
}
else if (!TryGetMessageType(option, out messageType))
{
error = $"\"{option}\" is not a valid squelch category.";
return false;
}
}
string name = replyName ?? string.Join(' ', parts.Skip(index));
if (name.Length == 0)
{
error = "You have not specified a squelch target.";
return false;
}
parsed = new SquelchArguments(account, messageType, name);
return true;
}
private void DisplayCharacterSquelches()
{
SquelchDatabase database = _bindings.Squelch.Snapshot();
var lines = database.Characters.Values
.Where(info => info.MessageTypes.Count > 0)
.Select(FormatSquelchInfo)
.ToArray();
_bindings.ShowSystemMessage(
"(account) denotes a character whose account has also been squelched.\n"
+ "Format: Name : List of squelched message types.\n--------\n"
+ (lines.Length == 0 ? "none\n" : string.Join("\n", lines) + "\n"));
}
private void DisplayGlobalFilters()
{
SquelchInfo global = _bindings.Squelch.Snapshot().Global;
string list = global.MessageTypes.Count == 0
? "none"
: FormatMessageTypes(global.MessageTypes);
_bindings.ShowSystemMessage(
"The following types of messages are currently being filtered globally:\n"
+ list + "\n(For a list of filter options, type @help filter)\n");
}
private static string FormatSquelchInfo(SquelchInfo info) =>
$"Name: {info.Name}{(info.AccountWide ? " (account) " : " ")}"
+ FormatMessageTypes(info.MessageTypes);
private static string FormatMessageTypes(IReadOnlySet<uint> types)
{
if (types.Contains(1u)) return "All message types";
return string.Join(", ", MessageTypes
.Where(pair => pair.Key != 1u && types.Contains(pair.Key))
.Select(pair => pair.Value));
}
private void ExecuteFillComponents(string arguments)
{
string[] parts = SplitArguments(arguments);
if (parts.Length > 2)
{
_bindings.ShowSystemMessage("Please use @help fillcomps for proper usage.");
return;
}
if (parts.Length > 0 && parts[0].Equals("clear", StringComparison.OrdinalIgnoreCase))
{
_bindings.ClearDesiredComponents();
_bindings.ShowSystemMessage("Component list cleared.");
return;
}
uint? category = null;
uint maximumPrice = 0u;
foreach (string part in parts)
{
if (uint.TryParse(part, out uint price))
{
if (price == 0)
{
_bindings.ShowSystemMessage("Please specify a value greater than zero.");
return;
}
maximumPrice = price;
}
else if (TryGetComponentCategory(part, out uint parsedCategory))
{
category = parsedCategory;
}
else
{
_bindings.ShowSystemMessage("Invalid component type specified.");
return;
}
}
if (!_bindings.HasOpenVendor())
{
_bindings.ShowSystemMessage("You need an open vendor.");
return;
}
_bindings.FillComponentBuyList(category, maximumPrice);
}
private static bool TryGetComponentCategory(string value, out uint category)
{
category = value.ToLowerInvariant() switch
{
"scarab" or "scarabs" => 0u,
"herb" or "herbs" => 1u,
"powderedgem" or "powderedgems" or "powder" or "powders" => 2u,
"alchemicalsubstance" or "alchemicalsubstances" or "potion" or "potions" => 3u,
"talisman" or "talismans" => 4u,
"taper" or "tapers" => 5u,
"pea" or "peas" => 6u,
_ => uint.MaxValue,
};
return category != uint.MaxValue;
}
private static bool TryGetMessageType(string value, out uint type)
{
foreach ((uint key, string name) in MessageTypes)
{
if (name.Equals(value, StringComparison.OrdinalIgnoreCase))
{
type = key;
return true;
}
}
type = 0u;
return false;
}
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();
}
private static string[] SplitArguments(string arguments) =>
arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
private readonly record struct SquelchArguments(
bool AccountWide, uint MessageType, string Name);
private static readonly IReadOnlyDictionary<uint, string> MessageTypes =
new Dictionary<uint, string>
{
[1] = "All",
[2] = "Speech",
[3] = "Tell",
[6] = "Combat",
[7] = "Magic",
[12] = "Emote",
[16] = "Appraisal",
[17] = "Spellcasting",
[18] = "Allegiance",
[19] = "Fellowship",
[21] = "Combat_Enemy",
[22] = "Combat_Self",
[23] = "Recall",
[24] = "Craft",
[25] = "Salvaging",
};
private const string StandardEmotes =
"Standard Emotes:\n"
+ "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";
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"
+ "@afk off - Turn off AFK mode.\n"
+ "@afk msg <message> - Set the message that will be sent to players that send you directed chat while you are in AFK mode. Issuing \"@afk msg\" with no message will set your AFK message back to the default. Your custom AFK message is limited to 192 characters.\n";
/// <summary>
/// Null means the local PublicWeenieDesc has not arrived yet. Retail only
/// rejects when it has a player object and the required bit is absent, so
/// unknown follows the same send-and-let-the-server-decide path.
/// </summary>
private bool? HasPlayerFlag(EntityCollisionFlags flag)
{
uint? bitfield = _bindings.PlayerPublicWeenieBitfield();
return bitfield is null
? null
: (EntityCollisionFlagsExt.FromPwdBitfield(bitfield.Value) & flag) != 0;
}
}

View file

@ -82,6 +82,7 @@ public static class DatWidgetFactory
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
0x13 => new UiDialogRoot(), // ConfirmationDialog
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
0x10000035u => BuildCheckbox(info, resolve, elementFont, stringResolve), // UIOption_Checkbox
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)

View file

@ -221,6 +221,24 @@ public static class LayoutImporter
};
}
/// <summary>
/// Retail <c>UIElementManager::CreateRootElementByDataID</c> counterpart: resolve one
/// authored root from a catalog-style LayoutDesc instead of instantiating every
/// top-level template. DialogFactory uses this path for the shared dialog catalog.
/// </summary>
public static ElementInfo? ImportInfos(
DatCollection dats,
uint layoutId,
uint rootElementId)
{
var ld = dats.Get<LayoutDesc>(layoutId);
if (ld is null) return null;
ElementDesc? root = FindDesc(ld, rootElementId);
return root is null
? null
: Resolve(dats, root, new HashSet<(uint, uint)>());
}
/// <summary>
/// Dat shell: load the LayoutDesc, resolve inheritance for every top-level
/// element, and build the widget tree. Returns null if the layout is absent
@ -260,6 +278,21 @@ public static class LayoutImporter
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve);
}
/// <summary>Import one selected root from a catalog-style LayoutDesc.</summary>
public static ImportedLayout? Import(
DatCollection dats,
uint layoutId,
uint rootElementId,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{
var rootInfo = ImportInfos(dats, layoutId, rootElementId);
if (rootInfo is null) return null;
var strings = new DatStringResolver(dats);
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve);
}
// ── Inheritance resolution ────────────────────────────────────────────────
/// <summary>True when a pure-container leaf should inherit its base's subtree (the

View file

@ -0,0 +1,122 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained implementation of retail <c>DialogFactory</c>'s confirmation queue.
/// The visual tree is element <c>0x15</c> from the shared dialog catalog resolved by
/// <c>GetDIDByEnum(2, 5)</c>; callbacks mirror property <c>0x92</c> from
/// <c>ConfirmationDialog::ListenToElementMessage @ 0x00476670</c>.
/// </summary>
public sealed class RetailConfirmationDialogService
{
public const uint RootElementId = 0x15u;
public const uint PopupElementId = 0x3Du;
public const uint MessageElementId = 0x3Eu;
public const uint AcceptButtonId = 0x17u;
public const uint RejectButtonId = 0x19u;
private sealed record Request(string Message, Action<bool> Completed);
private readonly UiRoot _host;
private readonly UiDialogRoot _dialog;
private readonly UiElement _popup;
private readonly UiText _message;
private readonly UiButton _accept;
private readonly UiButton _reject;
private readonly Queue<Request> _queue = new();
private readonly float _basePopupHeight;
private readonly float _baseMessageHeight;
private Request? _current;
public RetailConfirmationDialogService(UiRoot host, ImportedLayout layout)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
ArgumentNullException.ThrowIfNull(layout);
_dialog = layout.Root as UiDialogRoot
?? throw new ArgumentException("Confirmation layout root is not a UiDialogRoot.", nameof(layout));
_popup = layout.FindElement(PopupElementId)
?? throw new ArgumentException("Confirmation layout is missing popup element 0x3D.", nameof(layout));
_message = layout.FindElement(MessageElementId) as UiText
?? throw new ArgumentException("Confirmation layout is missing text element 0x3E.", nameof(layout));
_accept = layout.FindElement(AcceptButtonId) as UiButton
?? throw new ArgumentException("Confirmation layout is missing accept button 0x17.", nameof(layout));
_reject = layout.FindElement(RejectButtonId) as UiButton
?? throw new ArgumentException("Confirmation layout is missing reject button 0x19.", nameof(layout));
_basePopupHeight = _popup.Height;
_baseMessageHeight = _message.Height;
_popup.LayoutPolicy = null;
_popup.Anchors = AnchorEdges.None;
_message.LayoutPolicy = null;
_message.Anchors = AnchorEdges.None;
_message.Padding = 0f;
_message.Selectable = false;
_dialog.Cancel = () => Complete(false);
_accept.OnClick = () => Complete(true);
_reject.OnClick = () => Complete(false);
}
public bool IsOpen => _current is not null;
public int PendingCount => _queue.Count;
public void Show(string message, Action<bool> completed)
{
ArgumentNullException.ThrowIfNull(message);
ArgumentNullException.ThrowIfNull(completed);
_queue.Enqueue(new Request(message, completed));
if (_current is null)
ShowNext();
}
public void Tick()
{
if (_current is null) return;
SizeAndCenter();
}
private void ShowNext()
{
if (_queue.Count == 0) return;
_current = _queue.Dequeue();
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
Func<string, float> measure = _message.DatFont is { } font
? font.MeasureWidth
: static text => text.Length * 8f;
IReadOnlyList<string> wrapped = UiText.WrapWords(
_current.Message,
measure,
maximumWidth);
var lines = new UiText.Line[wrapped.Count];
for (int i = 0; i < wrapped.Count; i++)
lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
_message.LinesProvider = () => lines;
float lineHeight = _message.DatFont?.LineHeight ?? 16f;
_message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
_popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
SizeAndCenter();
_host.AddChild(_dialog);
_host.Modal = _dialog;
}
private void SizeAndCenter()
{
_dialog.Left = 0f;
_dialog.Top = 0f;
_dialog.Width = _host.Width;
_dialog.Height = _host.Height;
_popup.Left = MathF.Round((_dialog.Width - _popup.Width) * 0.5f);
_popup.Top = MathF.Round((_dialog.Height - _popup.Height) * 0.5f);
}
private void Complete(bool accepted)
{
Request? completed = _current;
if (completed is null) return;
_current = null;
_host.RemoveChild(_dialog);
completed.Completed(accepted);
ShowNext();
}
}

View file

@ -0,0 +1,28 @@
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.UI;
/// <summary>
/// Ports <c>DBCache::GetDIDFromEnumStatic @ 0x00413940</c>: the portal master map
/// selects an enum category, then that category map resolves the client enum value.
/// </summary>
public static class RetailDataIdResolver
{
public static uint Resolve(DatCollection dats, uint enumValue, uint enumCategory)
{
ArgumentNullException.ThrowIfNull(dats);
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
if (masterDid == 0
|| !dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)
|| master is null
|| !master.ClientEnumToID.TryGetValue(enumCategory, out uint subMapDid)
|| !dats.Portal.TryGet<EnumIDMap>(subMapDid, out var subMap)
|| subMap is null
|| !subMap.ClientEnumToID.TryGetValue(enumValue, out uint did))
return 0u;
return did;
}
}

View file

@ -141,6 +141,7 @@ public sealed class RetailUiRuntime : IDisposable
MountToolbar();
MountCombat();
MountJumpPowerbar();
MountConfirmationDialogs();
MountCharacter();
MountPlugins();
MountInventory();
@ -182,6 +183,7 @@ public sealed class RetailUiRuntime : IDisposable
public SelectedObjectController? SelectedObjectController { get; private set; }
public UiViewport? PaperdollViewportWidget { get; private set; }
public UiNineSlicePanel? InventoryFrame { get; private set; }
public RetailConfirmationDialogService? ConfirmationDialogs { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
@ -201,6 +203,7 @@ public sealed class RetailUiRuntime : IDisposable
{
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
ConfirmationDialogs?.Tick();
Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds);
}
@ -218,6 +221,12 @@ public sealed class RetailUiRuntime : IDisposable
public void RestoreLayout() => _persistence?.RestoreAll();
public void SaveLayout() => _persistence?.SaveAll();
public void SaveNamedLayout(string profileName) => _persistence?.SaveNamed(profileName);
public void RestoreNamedLayout(string profileName) => _persistence?.RestoreNamed(profileName);
public bool ToggleWindow(string name)
=> Host.ToggleWindow(name);
@ -586,6 +595,36 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[D.6] retail jump bar from gmFloatyPowerBarUI LayoutDesc 0x21000072.");
}
private void MountConfirmationDialogs()
{
ImportedLayout? layout;
uint layoutId;
lock (_bindings.Assets.DatLock)
{
// DialogFactory::CreateDialog_ @ 0x00477AD0 calls
// GetDIDByEnum(2, 5), then creates root element 0x15.
layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u);
layout = layoutId == 0u
? null
: LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
RetailConfirmationDialogService.RootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine("[UI] confirmation dialog catalog could not be resolved.");
return;
}
ConfirmationDialogs = new RetailConfirmationDialogService(Host.Root, layout);
Console.WriteLine($"[UI] retail confirmation dialogs from LayoutDesc 0x{layoutId:X8} element 0x15.");
}
private (uint[] Regular, uint[] Ghosted, uint[]? Empty) LoadToolbarDigits()
{
uint[]? regular = null, ghosted = null, empty = null;

View file

@ -74,6 +74,55 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
}
}
/// <summary>Force-save every attached window to the current automatic profile.</summary>
public void SaveAll()
{
ObjectDisposedException.ThrowIf(_disposed, this);
string character = _characterKey();
if (!CanPersist(character)) return;
var screen = ValidScreenSize();
string resolution = ResolutionKey(screen);
foreach (RetailWindowHandle handle in _attached)
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
}
/// <summary>Save every attached window to a portable named retail UI profile.</summary>
public void SaveNamed(string profileName)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(profileName);
foreach (RetailWindowHandle handle in _attached)
_store.SaveNamedWindowLayout(profileName, handle.Name, Capture(handle));
}
/// <summary>Restore every window found in a portable named retail UI profile.</summary>
public void RestoreNamed(string profileName)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(profileName);
var screen = ValidScreenSize();
_restoring = true;
try
{
foreach (RetailWindowHandle handle in _attached)
{
UiWindowLayout? saved = _store.LoadNamedWindowLayout(
profileName, handle.Name, Capture(handle));
if (saved is not { } layout) continue;
Apply(
handle,
layout,
screen,
restoreVisibility: !_stateManagedVisibilityWindows.Contains(handle.Name));
}
}
finally
{
_restoring = false;
}
}
private void Attach(RetailWindowHandle handle)
{
_attached.Add(handle);

View file

@ -0,0 +1,32 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Full-device modal root for retail dialog element classes. The authored child with id
/// <c>0x3D</c> supplies the visible popup art; this root only owns modality and input capture.
/// </summary>
public sealed class UiDialogRoot : UiPanel
{
public Action? Cancel { get; set; }
public UiDialogRoot()
{
BackgroundColor = Vector4.Zero;
BorderColor = Vector4.Zero;
ClickThrough = false;
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.KeyDown
&& e.Data0 == (int)Silk.NET.Input.Key.Escape)
{
Cancel?.Invoke();
}
// A dialog root is full-screen and modal. Unhandled clicks/keys must not
// fall through into movement, combat, or the default chat input.
return true;
}
}

View file

@ -311,6 +311,7 @@ public sealed class UiText : UiElement
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
string text = lines[i].Text;
float lineX = HorizontalOffset(text, datFont, bitmapFont);
// Selection highlight behind this line's selected character span.
if (hasSel && i >= selStart.Line && i <= selEnd.Line)
@ -324,12 +325,12 @@ public sealed class UiText : UiElement
float hx, hw;
if (datFont is not null)
{
hx = Padding + datFont.MeasureWidth(text.Substring(0, c0));
hx = lineX + datFont.MeasureWidth(text.Substring(0, c0));
hw = datFont.MeasureWidth(text.Substring(c0, c1 - c0));
}
else
{
hx = Padding + bitmapFont!.MeasureWidth(text.Substring(0, c0));
hx = lineX + bitmapFont!.MeasureWidth(text.Substring(0, c0));
hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0));
}
// Highlight sits BEHIND the line's text → sprite bucket, submitted
@ -339,12 +340,24 @@ public sealed class UiText : UiElement
}
if (datFont is not null)
ctx.DrawStringDat(datFont, text, Padding, y, lines[i].Color);
ctx.DrawStringDat(datFont, text, lineX, y, lines[i].Color);
else
ctx.DrawString(text, Padding, y, lines[i].Color, bitmapFont);
ctx.DrawString(text, lineX, y, lines[i].Color, bitmapFont);
}
}
private float HorizontalOffset(string text, UiDatFont? datFont, BitmapFont? bitmapFont)
{
float width = datFont is not null
? datFont.MeasureWidth(text)
: bitmapFont?.MeasureWidth(text) ?? 0f;
if (Centered)
return Math.Max(Padding, (Width - width) * 0.5f);
if (RightAligned)
return Math.Max(Padding, Width - Padding - width);
return Padding;
}
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
@ -545,16 +558,80 @@ public sealed class UiText : UiElement
line = Math.Clamp(line, 0, lines.Count - 1);
string text = lines[line].Text;
float lineX = HorizontalOffset(text, _lastDatFont, _lastFont);
int col = _lastDatFont is { } df
? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f,
localX - _lastPadding)
localX - lineX)
: (_lastFont is { } bf
? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f,
localX - _lastPadding)
localX - lineX)
: 0);
return new Pos(line, col);
}
/// <summary>Word-wrap text to a measured pixel width, preserving explicit newlines.</summary>
public static IReadOnlyList<string> WrapWords(
string text,
Func<string, float> measureWidth,
float maximumWidth)
{
ArgumentNullException.ThrowIfNull(text);
ArgumentNullException.ThrowIfNull(measureWidth);
if (maximumWidth <= 0f) throw new ArgumentOutOfRangeException(nameof(maximumWidth));
var result = new List<string>();
string[] paragraphs = text.Replace("\r", string.Empty).Split('\n');
foreach (string paragraph in paragraphs)
{
if (paragraph.Length == 0)
{
result.Add(string.Empty);
continue;
}
string[] words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var line = new StringBuilder();
foreach (string word in words)
{
string candidate = line.Length == 0 ? word : $"{line} {word}";
if (measureWidth(candidate) <= maximumWidth)
{
if (line.Length != 0) line.Append(' ');
line.Append(word);
continue;
}
if (line.Length != 0 && measureWidth(word) <= maximumWidth)
{
result.Add(line.ToString());
line.Clear();
line.Append(word);
continue;
}
// Retail GlyphList wrapping can split an over-width glyph run.
// Pack as much of the long token as possible onto the current
// line, then continue at character boundaries without hyphens.
for (int i = 0; i < word.Length; i++)
{
string prefix = i == 0 && line.Length != 0 ? " " : string.Empty;
if (line.Length != 0
&& measureWidth(line + prefix + word[i]) > maximumWidth)
{
result.Add(line.ToString());
line.Clear();
prefix = string.Empty;
}
line.Append(prefix).Append(word[i]);
}
}
result.Add(line.ToString());
}
return result;
}
/// <summary>
/// The caret column for a horizontal position <paramref name="x"/> (already
/// adjusted for the left padding, so x=0 is the start of the text). Walks the

View file

@ -5,6 +5,7 @@ using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Player;
using AcDream.Core.Spells;
using AcDream.Core.Social;
namespace AcDream.Core.Net;
@ -70,7 +71,11 @@ public static class GameEventWiring
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
Func<uint>? playerGuid = null,
Action<uint /*weenieError*/>? onUseDone = null,
ItemManaState? itemMana = null)
ItemManaState? itemMana = null,
Action<GameEvents.CharacterConfirmationRequest>? onConfirmationRequest = null,
FriendsState? friends = null,
SquelchState? squelch = null,
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -99,6 +104,42 @@ public static class GameEventWiring
var s = GameEvents.ParsePopupString(e.Payload.Span);
if (s is not null) chat.OnPopup(s);
});
dispatcher.Register(GameEventType.QueryAgeResponse, e =>
{
var p = GameEvents.ParseQueryAgeResponse(e.Payload.Span);
if (p is null) return;
string text = string.IsNullOrEmpty(p.Value.Name)
? $"You have played for {p.Value.Age}."
: $"{p.Value.Name} has played for {p.Value.Age}.";
chat.OnSystemMessage(text, chatType: 0u);
});
if (onConfirmationRequest is not null)
{
dispatcher.Register(GameEventType.CharacterConfirmationRequest, e =>
{
var request = GameEvents.ParseCharacterConfirmationRequest(e.Payload.Span);
if (request is not null)
onConfirmationRequest(request.Value);
});
}
if (friends is not null)
{
dispatcher.Register(GameEventType.FriendsListUpdate, e =>
{
FriendsUpdate? update = SocialStateMessages.ParseFriendsUpdate(e.Payload.Span);
if (update is not null) friends.Apply(update);
});
}
if (squelch is not null)
{
dispatcher.Register(GameEventType.SetSquelchDB, e =>
{
SquelchDatabase? database = SocialStateMessages.ParseSquelchDatabase(e.Payload.Span);
if (database is not null) squelch.Replace(database);
});
}
// ── TurbineChat channel list (0x0295 SetTurbineChatChannels) ─────
// Phase I.6: arrives once at login (and after chat-server reconnect)
@ -251,6 +292,7 @@ public static class GameEventWiring
{
var p = GameEvents.ParseWieldObject(e.Payload.Span);
if (p is null) return;
uint wielderGuid = p.Value.WielderGuid != 0u
? p.Value.WielderGuid
: (playerGuid?.Invoke() ?? 0u);
@ -264,6 +306,7 @@ public static class GameEventWiring
{
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
if (p is null) return;
items.MoveItem(
p.Value.ItemGuid,
p.Value.ContainerGuid,
@ -385,6 +428,8 @@ public static class GameEventWiring
Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}");
if (p is null) return;
onDesiredComponents?.Invoke(p.Value.DesiredComps);
// B-Wire: deliver the player's OWN properties to the player ClientObject.
// (PD's "membership manifest" rule is about ITEMS, whose data comes from
// CreateObject; the player's own stats legitimately come from PD.) Upsert
@ -412,6 +457,15 @@ public static class GameEventWiring
if (localPlayer is not null)
{
localPlayer.OnProperties(p.Value.Properties);
localPlayer.OnPositions(p.Value.Positions.ToDictionary(
static pair => pair.Key,
static pair => new AcDream.Core.Physics.Position(
pair.Value.LandblockId,
new System.Numerics.Vector3(
pair.Value.X, pair.Value.Y, pair.Value.Z),
new System.Numerics.Quaternion(
pair.Value.Qx, pair.Value.Qy,
pair.Value.Qz, pair.Value.Qw))));
foreach (var attr in p.Value.Attributes)
{

View file

@ -0,0 +1,235 @@
using System.Buffers.Binary;
using System.Text;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Wire builders for game actions initiated by retail client-owned chat
/// commands. Keeping this family separate from generic object interaction
/// makes the command boundary explicit while reusing the normal
/// <c>0xF7B1</c> game-action envelope.
/// </summary>
public static class ClientCommandRequests
{
public const uint MarketplaceOpcode = 0x028Du;
public const uint PkArenaOpcode = 0x0027u;
public const uint PkLiteArenaOpcode = 0x0026u;
public const uint HouseRecallOpcode = 0x0262u;
public const uint MansionRecallOpcode = 0x0278u;
public const uint QueryAgeOpcode = 0x01C2u;
public const uint QueryBirthOpcode = 0x01C4u;
public const uint ConfirmationResponseOpcode = 0x0275u;
public const uint SuicideOpcode = 0x0279u;
public const uint SetAfkModeOpcode = 0x000Fu;
public const uint SetAfkMessageOpcode = 0x0010u;
public const uint EmoteOpcode = 0x01DFu;
public const uint AddFriendOpcode = 0x0018u;
public const uint RemoveFriendOpcode = 0x0017u;
public const uint ClearFriendsOpcode = 0x0025u;
public const uint ModifyCharacterSquelchOpcode = 0x0058u;
public const uint ModifyAccountSquelchOpcode = 0x0059u;
public const uint ModifyGlobalSquelchOpcode = 0x005Bu;
public const uint ClearConsentOpcode = 0x0216u;
public const uint DisplayConsentOpcode = 0x0217u;
public const uint RemoveConsentOpcode = 0x0218u;
public const uint SetDesiredComponentLevelOpcode = 0x0224u;
public const uint LegacyFriendsOpcode = 0xF7CDu;
// Named-retail anchors:
// CM_Character::Event_TeleToMarketplace @ 0x006A1C20
// CM_Character::Event_TeleToPKArena @ 0x006A1CB0
// CM_Character::Event_TeleToPKLArena @ 0x006A1D40
// CM_House::Event_TeleToHouse_Event @ 0x006AAFA0
// CM_House::Event_TeleToMansion_Event @ 0x006AB030
public static byte[] BuildMarketplace(uint sequence) =>
BuildParameterless(sequence, MarketplaceOpcode);
public static byte[] BuildPkArena(uint sequence) =>
BuildParameterless(sequence, PkArenaOpcode);
public static byte[] BuildPkLiteArena(uint sequence) =>
BuildParameterless(sequence, PkLiteArenaOpcode);
public static byte[] BuildHouseRecall(uint sequence) =>
BuildParameterless(sequence, HouseRecallOpcode);
public static byte[] BuildMansionRecall(uint sequence) =>
BuildParameterless(sequence, MansionRecallOpcode);
// Named-retail anchors:
// CM_Character::Event_QueryAge @ 0x006A1620
// CM_Character::Event_QueryBirth @ 0x006A16F0
public static byte[] BuildQueryAge(uint sequence, uint objectId = 0u) =>
BuildObjectQuery(sequence, QueryAgeOpcode, objectId);
public static byte[] BuildQueryBirth(uint sequence, uint objectId = 0u) =>
BuildObjectQuery(sequence, QueryBirthOpcode, objectId);
// CM_Character::Event_ConfirmationResponse @ 0x006A1210.
public static byte[] BuildConfirmationResponse(
uint sequence,
uint confirmationType,
uint contextId,
bool accepted)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), ConfirmationResponseOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), confirmationType);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), contextId);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), accepted ? 1u : 0u);
return body;
}
// CM_Character::Event_Suicide @ 0x006A1B00.
public static byte[] BuildSuicide(uint sequence) =>
BuildParameterless(sequence, SuicideOpcode);
// CM_Communication::Event_SetAFKMode @ 0x006A3EE0.
public static byte[] BuildSetAfkMode(uint sequence, bool away) =>
BuildUInt32(sequence, SetAfkModeOpcode, away ? 1u : 0u);
// CM_Communication::Event_SetAFKMessage @ 0x006A4440.
public static byte[] BuildSetAfkMessage(uint sequence, string message) =>
BuildString(sequence, SetAfkMessageOpcode, message);
// CM_Communication::Event_Emote @ 0x006A4120.
public static byte[] BuildEmote(uint sequence, string message) =>
BuildString(sequence, EmoteOpcode, message);
// CM_Social::Event_AddFriend/RemoveFriend/ClearFriends
// @ 0x006A5C10 / 0x006A5650 / 0x006A55C0.
public static byte[] BuildAddFriend(uint sequence, string name) =>
BuildString(sequence, AddFriendOpcode, name);
public static byte[] BuildRemoveFriend(uint sequence, uint friendId) =>
BuildUInt32(sequence, RemoveFriendOpcode, friendId);
public static byte[] BuildClearFriends(uint sequence) =>
BuildParameterless(sequence, ClearFriendsOpcode);
// Proto_UI::SendFriendsCommand @ 0x00546C50 is a control message,
// not a 0xF7B1 GameAction. Command zero requests the legacy listing.
public static byte[] BuildLegacyFriendsCommand(uint command, string name)
{
byte[] packedName = PackString16L(name);
byte[] body = new byte[8 + packedName.Length];
BinaryPrimitives.WriteUInt32LittleEndian(body, LegacyFriendsOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), command);
packedName.CopyTo(body, 8);
return body;
}
// CM_Communication::Event_ModifyCharacterSquelch @ 0x006A42D0.
public static byte[] BuildModifyCharacterSquelch(
uint sequence,
bool add,
uint characterId,
string name,
uint messageType)
{
byte[] packedName = PackString16L(name);
byte[] body = CreateBody(sequence, ModifyCharacterSquelchOpcode, 12 + packedName.Length);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), add ? 1u : 0u);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), characterId);
packedName.CopyTo(body, 20);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20 + packedName.Length), messageType);
return body;
}
// CM_Communication::Event_ModifyAccountSquelch @ 0x006A41E0.
public static byte[] BuildModifyAccountSquelch(uint sequence, bool add, string name)
{
byte[] packedName = PackString16L(name);
byte[] body = CreateBody(sequence, ModifyAccountSquelchOpcode, 4 + packedName.Length);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), add ? 1u : 0u);
packedName.CopyTo(body, 16);
return body;
}
// CM_Communication::Event_ModifyGlobalSquelch @ 0x006A3D00.
public static byte[] BuildModifyGlobalSquelch(uint sequence, bool add, uint messageType)
{
byte[] body = CreateBody(sequence, ModifyGlobalSquelchOpcode, 8);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), add ? 1u : 0u);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), messageType);
return body;
}
// CM_Character consent events @ 0x006A1180 / 0x006A1360 / 0x006A2C10.
public static byte[] BuildClearConsent(uint sequence) =>
BuildParameterless(sequence, ClearConsentOpcode);
public static byte[] BuildDisplayConsent(uint sequence) =>
BuildParameterless(sequence, DisplayConsentOpcode);
public static byte[] BuildRemoveConsent(uint sequence, string name) =>
BuildString(sequence, RemoveConsentOpcode, name);
// CM_Character::Event_SetDesiredComponentLevel @ 0x006A2350.
public static byte[] BuildSetDesiredComponentLevel(
uint sequence, uint componentId, uint amount)
{
byte[] body = CreateBody(sequence, SetDesiredComponentLevelOpcode, 8);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), componentId);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), amount);
return body;
}
private static byte[] BuildParameterless(uint sequence, uint opcode)
{
byte[] body = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
return body;
}
private static byte[] BuildUInt32(uint sequence, uint opcode, uint value)
{
byte[] body = CreateBody(sequence, opcode, 4);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), value);
return body;
}
private static byte[] BuildString(uint sequence, uint opcode, string value)
{
byte[] packed = PackString16L(value);
byte[] body = CreateBody(sequence, opcode, packed.Length);
packed.CopyTo(body, 12);
return body;
}
private static byte[] CreateBody(uint sequence, uint opcode, int payloadLength)
{
byte[] body = new byte[12 + payloadLength];
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
return body;
}
private static byte[] PackString16L(string value)
{
ArgumentNullException.ThrowIfNull(value);
byte[] data = Encoding.GetEncoding(1252).GetBytes(value);
if (data.Length > ushort.MaxValue)
throw new ArgumentException("String too long for String16L.", nameof(value));
int unpadded = 2 + data.Length;
byte[] packed = new byte[(unpadded + 3) & ~3];
BinaryPrimitives.WriteUInt16LittleEndian(packed, (ushort)data.Length);
data.CopyTo(packed, 2);
return packed;
}
private static byte[] BuildObjectQuery(uint sequence, uint opcode, uint objectId)
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), objectId);
return body;
}
}

View file

@ -89,6 +89,25 @@ public static class GameEvents
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// <summary>
/// 0x01C3 QueryAgeResponse: target name (empty for self), then the
/// server-formatted played duration. Retail source:
/// <c>CM_Character::DispatchUI_QueryAgeResponse @ 0x006A2E40</c>.
/// </summary>
public readonly record struct QueryAgeResponse(string Name, string Age);
public static QueryAgeResponse? ParseQueryAgeResponse(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string name = ReadString16L(payload, ref pos);
string age = ReadString16L(payload, ref pos);
return new QueryAgeResponse(name, age);
}
catch { return null; }
}
// ── Errors ──────────────────────────────────────────────────────────────
/// <summary>0x028A WeenieError: generic game-logic failure code.</summary>
@ -470,20 +489,18 @@ public static class GameEvents
public readonly record struct CharacterConfirmationRequest(
uint Type,
uint ContextId,
uint OtherGuid,
string Message);
public static CharacterConfirmationRequest? ParseCharacterConfirmationRequest(ReadOnlySpan<byte> payload)
{
if (payload.Length < 12) return null;
if (payload.Length < 8) return null;
int pos = 0;
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload); pos += 4;
uint contextId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint otherGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
try
{
string msg = ReadString16L(payload, ref pos);
return new CharacterConfirmationRequest(type, contextId, otherGuid, msg);
return new CharacterConfirmationRequest(type, contextId, msg);
}
catch { return null; }
}

View file

@ -0,0 +1,138 @@
using System.Buffers.Binary;
using AcDream.Core.Social;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Retail social-state parsers. Sources: <c>CM_Social::DispatchUI_FriendsUpdate
/// @ 0x006A5DD0</c>, <c>FriendData::UnPack @ 0x005B9D20</c>, and
/// <c>SquelchDB::UnPack @ 0x006B1900</c>.
/// </summary>
public static class SocialStateMessages
{
public static FriendsUpdate? ParseFriendsUpdate(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
uint count = ReadU32(payload, ref pos);
// PList<FriendData>::UnPack @ 0x0048CD30 stores a 32-bit count
// and bounds it only by the remaining packet. Keep a generous
// allocation guard without inventing the UI's 50-friend policy
// at the wire layer.
if (count > 65_536) return null;
var entries = new List<FriendEntry>((int)count);
for (uint i = 0; i < count; i++)
{
uint id = ReadU32(payload, ref pos);
bool online = ReadU32(payload, ref pos) != 0;
bool appearOffline = ReadU32(payload, ref pos) != 0;
string name = StringReader.ReadString16L(payload, ref pos);
IReadOnlyList<uint> friends = ReadUInt32List(payload, ref pos);
IReadOnlyList<uint> friendOf = ReadUInt32List(payload, ref pos);
entries.Add(new FriendEntry(id, name, online, appearOffline, friends, friendOf));
}
FriendsUpdateType type = (FriendsUpdateType)ReadU32(payload, ref pos);
return Enum.IsDefined(type) ? new FriendsUpdate(type, entries) : null;
}
catch (FormatException)
{
return null;
}
}
public static SquelchDatabase? ParseSquelchDatabase(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
IReadOnlyDictionary<string, uint> accounts = ReadAccountTable(payload, ref pos);
IReadOnlyDictionary<uint, SquelchInfo> characters = ReadCharacterTable(payload, ref pos);
SquelchInfo global = ReadSquelchInfo(payload, ref pos);
return pos <= payload.Length
? new SquelchDatabase(accounts, characters, global)
: null;
}
catch (FormatException)
{
return null;
}
}
private static IReadOnlyDictionary<string, uint> ReadAccountTable(
ReadOnlySpan<byte> source, ref int pos)
{
(ushort count, ushort buckets) = ReadHashHeader(source, ref pos);
// A zero-sized retail table is valid only when it is empty (see the
// early return in PackableHashTable::UnPack @ 0x006B1A86).
if (buckets == 0 && count != 0)
throw new FormatException("invalid account squelch table");
var result = new Dictionary<string, uint>(count, StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < count; i++)
{
string name = StringReader.ReadString16L(source, ref pos);
result[name] = ReadU32(source, ref pos);
}
return result;
}
private static IReadOnlyDictionary<uint, SquelchInfo> ReadCharacterTable(
ReadOnlySpan<byte> source, ref int pos)
{
(ushort count, ushort buckets) = ReadHashHeader(source, ref pos);
if (buckets == 0 && count != 0)
throw new FormatException("invalid character squelch table");
var result = new Dictionary<uint, SquelchInfo>(count);
for (int i = 0; i < count; i++)
{
uint id = ReadU32(source, ref pos);
result[id] = ReadSquelchInfo(source, ref pos);
}
return result;
}
private static SquelchInfo ReadSquelchInfo(ReadOnlySpan<byte> source, ref int pos)
{
uint wordCount = ReadU32(source, ref pos);
if (wordCount > 4) throw new FormatException("invalid vlong word count");
var messageTypes = new HashSet<uint>();
for (uint word = 0; word < wordCount; word++)
{
uint bits = ReadU32(source, ref pos);
for (uint bit = 0; bit < 32; bit++)
{
if ((bits & (1u << (int)bit)) != 0)
messageTypes.Add(word * 32 + bit);
}
}
string name = StringReader.ReadString16L(source, ref pos);
bool accountWide = ReadU32(source, ref pos) != 0;
return new SquelchInfo(name, accountWide, messageTypes);
}
private static (ushort Count, ushort Buckets) ReadHashHeader(
ReadOnlySpan<byte> source, ref int pos)
{
uint header = ReadU32(source, ref pos);
return ((ushort)(header & 0xFFFFu), (ushort)(header >> 16));
}
private static IReadOnlyList<uint> ReadUInt32List(ReadOnlySpan<byte> source, ref int pos)
{
uint count = ReadU32(source, ref pos);
if (count > 10_000) throw new FormatException("invalid packed list count");
var result = new uint[count];
for (int i = 0; i < result.Length; i++) result[i] = ReadU32(source, ref pos);
return result;
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 4) throw new FormatException("truncated u32");
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos, 4));
pos += 4;
return value;
}
}

View file

@ -1272,6 +1272,153 @@ public sealed class WorldSession : IDisposable
SendGameAction(InteractRequests.BuildTeleToLifestone(seq));
}
/// <summary>Send retail marketplace recall (0x028D).</summary>
public void SendTeleportToMarketplace()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildMarketplace(seq));
}
/// <summary>Send retail full-PK arena recall (0x0027).</summary>
public void SendTeleportToPkArena()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildPkArena(seq));
}
/// <summary>Send retail PKLite arena recall (0x0026).</summary>
public void SendTeleportToPkLiteArena()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildPkLiteArena(seq));
}
/// <summary>Send retail personal-house recall (0x0262).</summary>
public void SendTeleportToHouse()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildHouseRecall(seq));
}
/// <summary>Send retail allegiance-mansion recall (0x0278).</summary>
public void SendTeleportToMansion()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildMansionRecall(seq));
}
/// <summary>Query the local character's played time (0x01C2).</summary>
public void SendQueryAge()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildQueryAge(seq));
}
/// <summary>Query the local character's creation date (0x01C4).</summary>
public void SendQueryBirth()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildQueryBirth(seq));
}
/// <summary>Reply to a server confirmation request (0x0275).</summary>
public void SendConfirmationResponse(uint confirmationType, uint contextId, bool accepted)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildConfirmationResponse(
seq, confirmationType, contextId, accepted));
}
/// <summary>Send the confirmed retail suicide action (0x0279).</summary>
public void SendSuicide()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSuicide(seq));
}
public void SendSetAfkMode(bool away)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetAfkMode(seq, away));
}
public void SendSetAfkMessage(string message)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetAfkMessage(seq, message));
}
public void SendEmote(string message)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildEmote(seq, message));
}
public void SendAddFriend(string name)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddFriend(seq, name));
}
public void SendRemoveFriend(uint friendId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveFriend(seq, friendId));
}
public void SendClearFriends()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildClearFriends(seq));
}
public void SendLegacyFriendsListRequest() =>
SendControlMessage(ClientCommandRequests.BuildLegacyFriendsCommand(0u, string.Empty));
public void SendModifyCharacterSquelch(bool add, uint characterId, string name, uint messageType)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildModifyCharacterSquelch(
seq, add, characterId, name, messageType));
}
public void SendModifyAccountSquelch(bool add, string name)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildModifyAccountSquelch(seq, add, name));
}
public void SendModifyGlobalSquelch(bool add, uint messageType)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildModifyGlobalSquelch(seq, add, messageType));
}
public void SendClearConsent()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildClearConsent(seq));
}
public void SendDisplayConsent()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildDisplayConsent(seq));
}
public void SendRemoveConsent(string name)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveConsent(seq, name));
}
public void SendClearDesiredComponents()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
seq, componentId: 0u, amount: uint.MaxValue));
}
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
public void SendChangeCombatMode(CombatMode mode)
{
@ -1517,10 +1664,20 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
private void SendGameMessage(byte[] gameMessageBody)
private void SendGameMessage(byte[] gameMessageBody) =>
SendGameMessage(gameMessageBody, GameMessageGroup.UIQueue);
/// <summary>
/// Retail <c>Proto_UI::SendToControl</c> path used by standalone control
/// messages such as the legacy <c>0xF7CD</c> friends request.
/// </summary>
private void SendControlMessage(byte[] gameMessageBody) =>
SendGameMessage(gameMessageBody, GameMessageGroup.ControlQueue);
private void SendGameMessage(byte[] gameMessageBody, GameMessageGroup queue)
{
var fragment = GameMessageFragment.BuildSingleFragment(
_fragmentSequence++, GameMessageGroup.UIQueue, gameMessageBody);
_fragmentSequence++, queue, gameMessageBody);
byte[] packetBody = GameMessageFragment.Serialize(fragment);
var header = new PacketHeader
{

View file

@ -70,6 +70,8 @@ public static class WeenieErrorMessages
{
// Command parser
[0x0026] = "That is not a valid command.", // ThatIsNotAValidCommand
[0x055F] = "Only Player Killer characters may use this command!",
[0x0560] = "Only Player Killer Lite characters may use this command!",
// Tell-related
[0x052B] = "That person is not available now.", // CharacterNotAvailable

View file

@ -1,5 +1,6 @@
using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Spells;
namespace AcDream.Core.Player;
@ -108,6 +109,7 @@ public sealed class LocalPlayerState
private VitalSnapshot? _mana;
private readonly Dictionary<AttributeKind, AttributeSnapshot> _attrs = new();
private readonly Dictionary<uint, SkillSnapshot> _skills = new();
private readonly Dictionary<uint, Position> _positions = new();
private PropertyBundle _properties = new();
private readonly Spellbook? _spellbook;
@ -185,6 +187,20 @@ public sealed class LocalPlayerState
/// <summary>All known skill snapshots, keyed by SkillId.</summary>
public IReadOnlyDictionary<uint, SkillSnapshot> Skills => _skills;
/// <summary>Player Position qualities keyed by retail PositionType.</summary>
public IReadOnlyDictionary<uint, Position> Positions => _positions;
public Position? GetPosition(uint positionType) =>
_positions.TryGetValue(positionType, out var value) ? value : null;
public void OnPositions(IReadOnlyDictionary<uint, Position> positions)
{
_positions.Clear();
foreach (var pair in positions)
_positions[pair.Key] = pair.Value;
CharacterChanged?.Invoke();
}
/// <summary>Snapshot for one skill, or <c>null</c> if it has not arrived yet.</summary>
public SkillSnapshot? GetSkill(uint skillId) =>
_skills.TryGetValue(skillId, out var s) ? s : null;

View file

@ -0,0 +1,74 @@
namespace AcDream.Core.Social;
/// <summary>Authoritative client-side copy of retail's <c>FriendDataList</c>.</summary>
public sealed class FriendsState
{
private readonly object _gate = new();
private readonly List<FriendEntry> _entries = new();
public IReadOnlyList<FriendEntry> Snapshot()
{
lock (_gate) return _entries.ToArray();
}
public void Apply(FriendsUpdate update)
{
ArgumentNullException.ThrowIfNull(update);
lock (_gate)
{
switch (update.Type)
{
case FriendsUpdateType.Full:
_entries.Clear();
_entries.AddRange(update.Entries);
break;
case FriendsUpdateType.Add:
foreach (FriendEntry entry in update.Entries)
{
int index = _entries.FindIndex(candidate => candidate.Id == entry.Id);
if (index >= 0) _entries[index] = entry;
else _entries.Add(entry);
}
break;
case FriendsUpdateType.Remove:
case FriendsUpdateType.RemoveSilent:
foreach (FriendEntry entry in update.Entries)
_entries.RemoveAll(candidate => candidate.Id == entry.Id);
break;
case FriendsUpdateType.OnlineStatus:
foreach (FriendEntry entry in update.Entries)
{
int index = _entries.FindIndex(candidate => candidate.Id == entry.Id);
if (index >= 0) _entries[index] = entry;
}
break;
}
}
}
public void Clear()
{
lock (_gate) _entries.Clear();
}
}
public sealed record FriendEntry(
uint Id,
string Name,
bool Online,
bool AppearOffline,
IReadOnlyList<uint> Friends,
IReadOnlyList<uint> FriendOf);
public enum FriendsUpdateType : uint
{
Full = 0,
Add = 1,
Remove = 2,
RemoveSilent = 3,
OnlineStatus = 4,
}
public sealed record FriendsUpdate(
FriendsUpdateType Type,
IReadOnlyList<FriendEntry> Entries);

View file

@ -0,0 +1,35 @@
namespace AcDream.Core.Social;
/// <summary>Authoritative copy of retail's server-supplied <c>SquelchDB</c>.</summary>
public sealed class SquelchState
{
private readonly object _gate = new();
private SquelchDatabase _database = SquelchDatabase.Empty;
public SquelchDatabase Snapshot()
{
lock (_gate) return _database;
}
public void Replace(SquelchDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
lock (_gate) _database = database;
}
}
public sealed record SquelchInfo(
string Name,
bool AccountWide,
IReadOnlySet<uint> MessageTypes);
public sealed record SquelchDatabase(
IReadOnlyDictionary<string, uint> Accounts,
IReadOnlyDictionary<uint, SquelchInfo> Characters,
SquelchInfo Global)
{
public static SquelchDatabase Empty { get; } = new(
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase),
new Dictionary<uint, SquelchInfo>(),
new SquelchInfo(string.Empty, false, new HashSet<uint>()));
}

View file

@ -0,0 +1,31 @@
using System.Globalization;
using AcDream.Core.Physics;
namespace AcDream.Core.Ui;
/// <summary>Retail text formatting for world positions and outdoor cells.</summary>
public static class RetailPositionFormatter
{
/// <summary>
/// Verbatim field order and precision from
/// <c>Position::ToString @ 0x005A9330</c>.
/// </summary>
public static string Format(Position position)
{
var p = position.Frame.Origin;
var q = position.Frame.Orientation;
return string.Create(CultureInfo.InvariantCulture,
$"0x{position.ObjCellId:X8} [{p.X:F6} {p.Y:F6} {p.Z:F6}] " +
$"{q.W:F6} {q.X:F6} {q.Y:F6} {q.Z:F6}");
}
/// <summary>
/// Port of <c>LandDefs::CellidToCoordinateString @ 0x005A9CB0</c>.
/// Returns null for indoor/invalid cells, matching the underlying
/// <c>gid_to_lcoord</c> failure.
/// </summary>
public static string? FormatOutdoorCell(uint cellId) =>
RadarCoordinates.TryFromCell(cellId, out var coordinates)
? coordinates.CombinedText
: null;
}

View file

@ -8,4 +8,36 @@ public enum ClientCommandId
{
/// <summary>Recall to the character's bound lifestone.</summary>
LifestoneRecall,
MarketplaceRecall,
PkArenaRecall,
PkLiteArenaRecall,
HouseRecall,
MansionRecall,
QueryAge,
QueryBirth,
ToggleFrameRate,
ToggleUiLock,
ShowVersion,
ShowLocation,
ShowLastCorpseLocation,
Die,
ClearChat,
SaveUi,
LoadUi,
SaveAutoUi,
LoadAutoUi,
Away,
Consent,
Emote,
ListEmotes,
Friends,
FriendsAdd,
FriendsRemove,
Squelch,
Unsquelch,
Filter,
Unfilter,
ListMessageTypes,
FillComponents,
}

View file

@ -36,7 +36,8 @@ public static class ChatCommandRouter
{
if (!clientCommand.HasValidArguments)
{
vm.ShowSystemMessage($"Usage: {clientCommand.Usage}");
vm.ShowSystemMessage(clientCommand.InvalidArgumentsText
?? $"Usage: {clientCommand.Usage}");
return SubmitOutcome.ClientHandled;
}
@ -98,24 +99,6 @@ public static class ChatCommandRouter
return true;
}
if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls"))
{
vm.Clear();
return true;
}
if (EqAny(trimmed, "/framerate", "@framerate"))
{
vm.ShowFps();
return true;
}
if (EqAny(trimmed, "/loc", "@loc"))
{
vm.ShowLocation();
return true;
}
return false;
}

View file

@ -7,9 +7,9 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
/// This is intentionally separate from chat aliases and ACE server commands.
///
/// <para>
/// First vertical slice: retail registers <c>lifestone</c>, <c>lif</c>, and
/// <c>ls</c> against <c>ClientCommunicationSystem::DoLifestone @ 0x0056FC70</c>.
/// See <c>docs/research/2026-07-13-retail-client-command-routing-pseudocode.md</c>.
/// Aliases and ownership come from the named-retail command table. Packet
/// and local-state behavior is recorded in the command-family pseudocode
/// notes under <c>docs/research/</c>.
/// </para>
/// </summary>
public static class RetailClientCommandCatalog
@ -18,14 +18,16 @@ public static class RetailClientCommandCatalog
ClientCommandId Command,
string Usage,
string HelpText,
Func<string, bool> ValidateArguments);
Func<string, bool> ValidateArguments,
string? InvalidArgumentsText = null);
/// <summary>A resolved retail command plus its raw, trimmed argument text.</summary>
public readonly record struct Match(
ClientCommandId Command,
string Arguments,
string Usage,
bool HasValidArguments);
bool HasValidArguments,
string? InvalidArgumentsText);
private static readonly Definition Lifestone = new(
ClientCommandId.LifestoneRecall,
@ -33,12 +35,212 @@ public static class RetailClientCommandCatalog
HelpText: "/lifestone (/lif, /ls) - Returns you to the last lifestone you used without killing you.",
ValidateArguments: static arguments => arguments.Length == 0);
private static readonly Definition Marketplace = NoArguments(
ClientCommandId.MarketplaceRecall,
"/marketplace",
"/marketplace (/mar, /mp) - Teleports you to the Marketplace of Dereth.");
private static readonly Definition PkArena = NoArguments(
ClientCommandId.PkArenaRecall,
"/pkarena",
"/pkarena (/pka) - Teleports a Player Killer to the PK Arena.");
private static readonly Definition PkLiteArena = NoArguments(
ClientCommandId.PkLiteArenaRecall,
"/pklarena",
"/pklarena (/pla) - Teleports a PKLite player to the PKLite Arena.");
private static readonly Definition HouseRecall = NoArguments(
ClientCommandId.HouseRecall,
"/house recall",
"/house recall (/hor, /hr) - Teleports you to your house.");
private static readonly Definition MansionRecall = NoArguments(
ClientCommandId.MansionRecall,
"/house mansion_recall",
"/house mansion_recall (/hom, /hoa) - Teleports you to your allegiance mansion.");
private static readonly Definition QueryAge = NoArguments(
ClientCommandId.QueryAge,
"/age",
"/age - Displays how long your character has been played.");
private static readonly Definition QueryBirth = NoArguments(
ClientCommandId.QueryBirth,
"/birth",
"/birth - Displays when your character was created.");
private static readonly Definition FrameRate = NoArguments(
ClientCommandId.ToggleFrameRate,
"/framerate",
"/framerate - Toggles the framerate display.");
private static readonly Definition LockUi = NoArguments(
ClientCommandId.ToggleUiLock,
"/lockui",
"/lockui - Toggles whether the interface can be moved or resized.");
private static readonly Definition Version = NoArguments(
ClientCommandId.ShowVersion,
"/version",
"/version - Displays the client version.");
private static readonly Definition Location = NoArguments(
ClientCommandId.ShowLocation,
"/loc",
"/loc - Displays your current position.");
private static readonly Definition Corpse = new(
ClientCommandId.ShowLastCorpseLocation,
Usage: "/corpse",
HelpText: "/corpse (/cor) - Displays the location of your last outdoor death.",
// DoCorpse @ 0x00578220 ignores its argument count.
ValidateArguments: static _ => true);
private static readonly Definition Die = new(
ClientCommandId.Die,
Usage: "/die",
HelpText: "/die - Kills your character after confirmation.",
ValidateArguments: static arguments => arguments.Length == 0,
InvalidArgumentsText: "Please see @help die for more information on how to use this command.");
private static readonly Definition Clear = AnyArguments(
ClientCommandId.ClearChat,
"/clear [all]",
"/clear [all] - Clears the current chat window, or every chat window.");
private static readonly Definition SaveUi = AnyArguments(
ClientCommandId.SaveUi,
"/saveui [filename]",
"/saveui [filename] - Saves the current interface layout.");
private static readonly Definition LoadUi = AnyArguments(
ClientCommandId.LoadUi,
"/loadui [filename]",
"/loadui [filename] - Loads a saved interface layout.");
private static readonly Definition SaveAutoUi = AnyArguments(
ClientCommandId.SaveAutoUi,
"/saveautoui",
"/saveautoui - Saves the automatic character-and-resolution interface layout.");
private static readonly Definition LoadAutoUi = AnyArguments(
ClientCommandId.LoadAutoUi,
"/loadautoui",
"/loadautoui - Loads the automatic character-and-resolution interface layout.");
private static readonly Definition Away = AnyArguments(
ClientCommandId.Away,
"/afk [on|off|msg <message>]",
"/afk [on|off|msg <message>] - Sets your away-from-keyboard status.");
private static readonly Definition Consent = AnyArguments(
ClientCommandId.Consent,
"/consent <on|off|who|clear|remove <name>>",
"/consent - Manages corpse-looting consent.");
private static readonly Definition Emote = AnyArguments(
ClientCommandId.Emote,
"/emote <text>",
"/emote (/e, /em, /me) - Performs a text emote.");
private static readonly Definition Emotes = NoArguments(
ClientCommandId.ListEmotes,
"/emotes",
"/emotes - Lists all standard emotes.");
private static readonly Definition Friends = AnyArguments(
ClientCommandId.Friends,
"/friends [add|remove|online|old]",
"/friends - Helps you manage your friends list.");
private static readonly Definition FriendsAdd = AnyArguments(
ClientCommandId.FriendsAdd,
"/friends_add <name>",
"/friends_add <name> - Adds a character to your friends list.");
private static readonly Definition FriendsRemove = AnyArguments(
ClientCommandId.FriendsRemove,
"/friends_remove <name|-all>",
"/friends_remove <name|-all> - Removes friends from your list.");
private static readonly Definition Squelch = AnyArguments(
ClientCommandId.Squelch,
"/squelch [options] <name>",
"/squelch - Ignores messages from a player or account.");
private static readonly Definition Unsquelch = AnyArguments(
ClientCommandId.Unsquelch,
"/unsquelch [options] <name>",
"/unsquelch - Stops ignoring messages from a player or account.");
private static readonly Definition Filter = AnyArguments(
ClientCommandId.Filter,
"/filter -<message type>",
"/filter -<message type> - Globally hides a message category.");
private static readonly Definition Unfilter = AnyArguments(
ClientCommandId.Unfilter,
"/unfilter -<message type>",
"/unfilter -<message type> - Shows a globally hidden message category.");
private static readonly Definition MessageTypes = NoArguments(
ClientCommandId.ListMessageTypes,
"/messagetypes",
"/messagetypes - 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.");
private static readonly FrozenDictionary<string, Definition> ByVerb =
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
{
["lifestone"] = Lifestone,
["lif"] = Lifestone,
["ls"] = Lifestone,
["marketplace"] = Marketplace,
["mar"] = Marketplace,
["mp"] = Marketplace,
["pkarena"] = PkArena,
["pka"] = PkArena,
["pklarena"] = PkLiteArena,
["pla"] = PkLiteArena,
["hor"] = HouseRecall,
["hr"] = HouseRecall,
["hom"] = MansionRecall,
["hoa"] = MansionRecall,
["age"] = QueryAge,
["birth"] = QueryBirth,
["framerate"] = FrameRate,
["lockui"] = LockUi,
["version"] = Version,
["loc"] = Location,
["corpse"] = Corpse,
["cor"] = Corpse,
["die"] = Die,
["clear"] = Clear,
["saveui"] = SaveUi,
["loadui"] = LoadUi,
["saveautoui"] = SaveAutoUi,
["loadautoui"] = LoadAutoUi,
["afk"] = Away,
["consent"] = Consent,
["e"] = Emote,
["em"] = Emote,
["emote"] = Emote,
["me"] = Emote,
["emotes"] = Emotes,
["friends"] = Friends,
["friends_add"] = FriendsAdd,
["friends_remove"] = FriendsRemove,
["squelch"] = Squelch,
["unsquelch"] = Unsquelch,
["filter"] = Filter,
["unfilter"] = Unfilter,
["messagetypes"] = MessageTypes,
["fillcomps"] = FillComponents,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
/// <summary>
@ -60,22 +262,88 @@ public static class RetailClientCommandCatalog
string verb = separator < 0
? trimmed[1..]
: trimmed.Substring(1, separator - 1);
if (!ByVerb.TryGetValue(verb, out Definition? definition))
return false;
string arguments = separator < 0
? string.Empty
: trimmed[(separator + 1)..].Trim();
Definition? definition;
if (verb.Equals("house", 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;
}
else if (!ByVerb.TryGetValue(verb, out definition))
{
return false;
}
match = new Match(
definition.Command,
arguments,
definition.Usage,
definition.ValidateArguments(arguments));
definition.ValidateArguments(arguments),
definition.InvalidArgumentsText);
return true;
}
/// <summary>Help line generated from the same definition routing uses.</summary>
public static string BuildHelpText() => Lifestone.HelpText;
public static string BuildHelpText() => string.Join("\n ",
Lifestone.HelpText,
Marketplace.HelpText,
PkArena.HelpText,
PkLiteArena.HelpText,
HouseRecall.HelpText,
MansionRecall.HelpText,
QueryAge.HelpText,
QueryBirth.HelpText,
FrameRate.HelpText,
LockUi.HelpText,
Version.HelpText,
Location.HelpText,
Corpse.HelpText,
Die.HelpText,
Clear.HelpText,
SaveUi.HelpText,
LoadUi.HelpText,
SaveAutoUi.HelpText,
LoadAutoUi.HelpText,
Away.HelpText,
Consent.HelpText,
Emote.HelpText,
Emotes.HelpText,
Friends.HelpText,
Squelch.HelpText,
Unsquelch.HelpText,
Filter.HelpText,
Unfilter.HelpText,
MessageTypes.HelpText,
FillComponents.HelpText);
private static Definition NoArguments(
ClientCommandId command, string usage, string helpText) =>
new(command, usage, helpText, static arguments => arguments.Length == 0);
private static Definition AnyArguments(
ClientCommandId command, string usage, string helpText) =>
new(command, usage, helpText, static _ => true);
private static int IndexOfWhitespace(string value)
{

View file

@ -39,7 +39,8 @@ public sealed record GameplaySettings(
bool ShowHelm, // 0x100000 — render helm overlay on character
bool ShowCloak, // 0x800000 — render cloak on character
bool LockUI, // 0x1000000 — disable panel drag/resize
bool UseMouseTurning) // 0x400000 — turn character when right-mouse drags
bool UseMouseTurning, // 0x400000 — turn character when right-mouse drags
bool AcceptLootPermits = false) // 0x80000 — accept corpse-looting permissions
{
/// <summary>Sensible starting values for first launch. NOT bit-exact
/// to retail's <c>Default_CharacterOption = 0x50C4A54A</c> +
@ -61,5 +62,7 @@ public sealed record GameplaySettings(
ShowHelm: true,
ShowCloak: true,
LockUI: false,
UseMouseTurning: false);
UseMouseTurning: false,
// Default_CharacterOption 0x50C4A54A leaves bit 0x80000 clear.
AcceptLootPermits: false);
}

View file

@ -162,7 +162,8 @@ public sealed class SettingsStore
ShowHelm: ReadBool(gp, "showHelm", d.ShowHelm),
ShowCloak: ReadBool(gp, "showCloak", d.ShowCloak),
LockUI: ReadBool(gp, "lockUI", d.LockUI),
UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning));
UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning),
AcceptLootPermits: ReadBool(gp, "acceptLootPermits", d.AcceptLootPermits));
}
catch (Exception ex)
{
@ -419,6 +420,70 @@ public sealed class SettingsStore
WriteMutableRoot(root);
}
/// <summary>
/// Load one window from a user-named retail <c>@saveui</c> profile.
/// Named profiles are intentionally independent of character and screen
/// resolution, matching retail's portable UI files.
/// </summary>
public UiWindowLayout? LoadNamedWindowLayout(
string profileName,
string windowName,
UiWindowLayout fallback)
{
ArgumentNullException.ThrowIfNull(profileName);
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
if (!File.Exists(_path)) return null;
try
{
var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject;
JsonObject? node = root?["namedWindowLayouts"]?[profileName]?[windowName] as JsonObject;
return node is null
? null
: new UiWindowLayout(
ReadNodeFloat(node, "x", fallback.X),
ReadNodeFloat(node, "y", fallback.Y),
ReadNodeFloat(node, "width", fallback.Width),
ReadNodeFloat(node, "height", fallback.Height),
ReadNodeBool(node, "visible", fallback.Visible),
ReadNodeBool(node, "collapsed", fallback.Collapsed),
ReadNodeBool(node, "maximized", fallback.Maximized));
}
catch (Exception ex)
{
Console.WriteLine($"settings: failed to load named window layout from {_path}: {ex.Message}");
return null;
}
}
/// <summary>Save one window to a user-named retail <c>@saveui</c> profile.</summary>
public void SaveNamedWindowLayout(
string profileName,
string windowName,
UiWindowLayout layout)
{
ArgumentNullException.ThrowIfNull(profileName);
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
JsonObject root = LoadMutableRoot();
JsonObject profiles = GetOrCreateObject(root, "namedWindowLayouts");
JsonObject profile = GetOrCreateObject(profiles, profileName);
profile[windowName] = SerializeWindowLayout(layout);
root["version"] = CurrentSchemaVersion;
WriteMutableRoot(root);
}
private static JsonObject SerializeWindowLayout(UiWindowLayout layout) => new()
{
["x"] = layout.X,
["y"] = layout.Y,
["width"] = layout.Width,
["height"] = layout.Height,
["visible"] = layout.Visible,
["collapsed"] = layout.Collapsed,
["maximized"] = layout.Maximized,
};
private JsonObject LoadMutableRoot()
{
var dir = Path.GetDirectoryName(_path);
@ -481,6 +546,7 @@ public sealed class SettingsStore
=> new(StringComparer.Ordinal)
{
["advancedCombatUI"] = g.AdvancedCombatUI,
["acceptLootPermits"] = g.AcceptLootPermits,
["allowGive"] = g.AllowGive,
["autoRepeatAttack"] = g.AutoRepeatAttack,
["autoTarget"] = g.AutoTarget,