feat(runtime,net): Campaign OP slice OP1 — full character-option table, dirty model, real 0x01A1 blob builder
The retail Options panel (Campaign OP) needs a Runtime-owned option map
covering all 53 PlayerOption ids and the real batched SetCharacterOptions
(0x01A1) blob before any UI can be built on top of it. Today's surface only
modeled 6 ListenTo*Chat ids and the 0x01A1 builder was a malformed 16-byte
stub (deleted at Campaign CH slice CH3, docs/research/2026-08-09-chat-side-
channels-vs-ace.md).
- CharacterOptionTable.cs: the ONE typed table, PlayerOption id (0x00..0x34)
-> (Options1/Options2 word, mask, IsAutoSave, ClientDefault), transcribed
from acclient.h's verbatim CharacterOption/CharacterOptions2/PlayerOption
enums and byte-verified against IsAutoSaveOption @0x0059A600 (the 21-id
auto-save table) and GetDefaultOptionValue @0x005D2A30 (the Defaults-
button table). Reconstructing CharacterOptions1/2 defaults from the
ClientDefault column independently reproduces 0x50C4A54A / 0x00008700,
cross-confirming the id-mask mapping. CharacterOptionId (SocialActions.cs)
widened from 6 to all 53 ids to match.
- RuntimeCharacterOptionsState: SetOptionBit now resolves through the full
table (was a 6-case switch). New TrySetOption is the ONE shared local-
write-then-send/dirty seam — mirrors CPlayerModule::OnChanged exactly:
write the bit locally first, then either send 0x0005 immediately (auto-
save ids) or MarkDirty for the batched blob, no-op on an unchanged value
(retail's own early-return) or an unmodeled id. New dirty model (IsDirty/
FirstDirtiedAt/MarkDirty/TryFlush/TryFlushIfAutoSaveDue) uses an injected
TimeProvider so it's fully unit-testable without a live clock.
- Both IRuntimeCharacterCommands.SetSingleOption adapters (Direct + Current)
now route through TrySetOption instead of duplicating the write; this
fixes the headless local-write gap the OP1 research flagged (the direct
adapter previously sent the wire message without writing the bit first,
same class of bug CH4 fixed for the graphical host). Both also reject an
id outside the table instead of silently accepting it. LiveSessionRuntime
Factory's SendSingleCharacterOption closure now delegates to the same
seam instead of duplicating write-then-send inline.
- New IRuntimeCharacterCommands.SaveOptions(generation) — the explicit
blob-flush verb (retail's SaveToServer(force: 0)) — wired end-to-end in
both adapters, including a new SaveCharacterOptionsRuntimeCmd on the
graphical router.
- SocialActions.BuildSetCharacterOptions + WorldSession.SendSetCharacterOptions:
the real PlayerModule::Pack body per the wire research's field-by-field
layout — header always 0x460 OR'd with 0x001/0x008 when shortcuts/desired
comps are non-empty, favorite spells always 8 lists, never sets 0x100 or
0x200. Echoes last-parsed shortcuts/favorites/desired-comps/spellbook
filters (via new CharacterOptionsBlobSource) instead of zeroing them.
Conformance: a hand-computed golden byte vector (not generated by the
builder under test — the CH3 builder died of tests that pinned a wrong
shape and looked green) plus a round-trip through PlayerDescriptionParser.
Contract deviation: the 480 s auto-save timer and the flush-before-logout
trigger are implemented as fully-tested pure state-machine logic
(TryFlushIfAutoSaveDue) but are NOT wired into either host's live per-frame
loop or graceful-shutdown sequence in this slice — only the explicit
SaveOptions verb is production-wired. Wiring the timer touches App's
UpdateFrameOrchestrator graph and Headless's tick loop (outside this
slice's Runtime/wire-layer scope); wiring logout risks the already-fragile
graceful-shutdown sequence CLAUDE.md flags. Filed as TS-71 per the plan's
own escape valve ("target: not deferred" with a register row if deferred).
Also filed: AP-193 (the 0x34 HearPKDeathMessages id/mask is ACE-sourced,
unverifiable against the 2013 binary) and AP-194 (GetDefaultOptionValue's
table disagrees with the constructor default for ConfirmVolatileRareUse/
ShowHelm/ShowCloak — retail's own quirk, reproduced not fixed).
Tests: table completeness x53, auto-save/client-default split pinned
id-by-id against the byte-verified tables, unknown/reserved-id rejection
(0x35/0x36 landmines), local-write-then-send on both adapters + the router,
the dirty/flush state machine, SaveOptions, and the wire golden vector +
PlayerDescriptionParser round-trip. Full Release suite: 12,745 passed / 4
skipped / 0 failed (baseline 12,611/4/0 — slice adds 134 passing tests,
zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b585d80e7a
commit
86c0a7e0ee
19 changed files with 1464 additions and 62 deletions
File diff suppressed because one or more lines are too long
|
|
@ -45,6 +45,11 @@ internal sealed record LiveSessionCommandBindings(
|
|||
RuntimeCommunicationState Communication,
|
||||
RuntimeCharacterState CharacterState,
|
||||
Action<uint, bool> SendSingleCharacterOption,
|
||||
// Campaign OP slice OP1 (2026-08-10): the real SetCharacterOptions
|
||||
// (0x01A1) blob-flush verb — CH3's TODO, now resurrected per wire
|
||||
// research §2.3-§2.7. No-ops when the batched module is clean, matching
|
||||
// retail's CPlayerModule::SaveToServer(force: 0).
|
||||
Action SaveCharacterOptions,
|
||||
Action<string>? Log = null);
|
||||
|
||||
internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry);
|
||||
|
|
@ -69,6 +74,7 @@ internal readonly record struct TrainSkillRuntimeCmd(uint StatId, uint Cost);
|
|||
internal readonly record struct SetSingleCharacterOptionRuntimeCmd(
|
||||
uint OptionId,
|
||||
bool Value);
|
||||
internal readonly record struct SaveCharacterOptionsRuntimeCmd;
|
||||
internal readonly record struct AddFriendRuntimeCmd(string Name);
|
||||
internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId);
|
||||
internal readonly record struct ClearFriendsRuntimeCmd;
|
||||
|
|
@ -169,6 +175,8 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
bindings.SendSingleCharacterOption(
|
||||
command.OptionId,
|
||||
command.Value)));
|
||||
commands.Register<SaveCharacterOptionsRuntimeCmd>(
|
||||
_ => SendIfActive(bindings.SaveCharacterOptions));
|
||||
commands.Register<AddFriendRuntimeCmd>(
|
||||
command => SendIfActive(() => bindings.AddFriend(command.Name)));
|
||||
commands.Register<RemoveFriendRuntimeCmd>(
|
||||
|
|
|
|||
|
|
@ -327,27 +327,47 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
private LiveSessionCommandBindings CreateCommandBindings(
|
||||
WorldSession session)
|
||||
{
|
||||
// CH4 re-review SHOULD-FIX 2 (2026-08-09): single local-write
|
||||
// chokepoint for retail's SetSingleCharacterOption (0x0005) local
|
||||
// option-bit write, reached by BOTH entrances that can flip a
|
||||
// character option — @join/@leave
|
||||
// CH4 re-review SHOULD-FIX 2 (2026-08-09), widened by Campaign OP
|
||||
// slice OP1 (2026-08-10): single write-then-send/dirty chokepoint
|
||||
// for retail's PlayerModule::OnChanged policy, reached by BOTH
|
||||
// entrances that can flip a character option — @join/@leave
|
||||
// (ClientCommandController.Bindings.SetSingleCharacterOption below)
|
||||
// and the Settings Chat toggles (LiveSessionCommandBindings.
|
||||
// SendSingleCharacterOption at the bottom of this method, routed
|
||||
// through RuntimeSettingsController.PublishHearOptionChange ->
|
||||
// RuntimeSettingsTargets.SetSingleCharacterOption ->
|
||||
// SetSingleCharacterOptionRuntimeCmd -> LiveSessionCommandRouter).
|
||||
// Retail's PlayerModule::SetHear*Chat family writes the bit into the
|
||||
// local options copy FIRST, then notifies the server, for both
|
||||
// entrances alike — routing only @join/@leave through the local
|
||||
// write (the prior CH4 fix) left a Settings-route toggle stale in
|
||||
// TurbineChatMembershipGate (which reads _domain.Character.Options)
|
||||
// until the next PlayerDescription happened to arrive.
|
||||
void SendSingleCharacterOption(uint optionId, bool value)
|
||||
{
|
||||
_domain.Character.Options.SetOptionBit(optionId, value);
|
||||
session.SendSetSingleCharacterOption(optionId, value);
|
||||
}
|
||||
// OP1 moved the actual write-then-send/dirty POLICY into
|
||||
// RuntimeCharacterOptionsState.TrySetOption (the shared Runtime seam
|
||||
// src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs's
|
||||
// SetSingleOption now also calls) so graphical and headless hosts —
|
||||
// and every entrance within this host — share exactly one ordering:
|
||||
// write the bit locally FIRST, then either send 0x0005 immediately
|
||||
// (retail's auto-save ids) or mark the batched module dirty.
|
||||
void SendSingleCharacterOption(uint optionId, bool value) =>
|
||||
_domain.Character.Options.TrySetOption(
|
||||
optionId,
|
||||
value,
|
||||
sendAutoSave: () =>
|
||||
session.SendSetSingleCharacterOption(optionId, value));
|
||||
|
||||
// OP1: the explicit SaveOptions verb — retail's
|
||||
// CPlayerModule::SaveToServer(force: 0). No-ops when the batched
|
||||
// module is clean.
|
||||
void SaveCharacterOptionsIfDirty() =>
|
||||
_domain.Character.Options.TryFlush(() =>
|
||||
{
|
||||
CharacterOptionsBlobEcho echo = CharacterOptionsBlobSource.Capture(
|
||||
_domain.Character,
|
||||
_domain.Inventory.Shortcuts);
|
||||
session.SendSetCharacterOptions(
|
||||
echo.Options1,
|
||||
echo.Options2,
|
||||
echo.Shortcuts,
|
||||
echo.FavoriteSpells,
|
||||
echo.DesiredComponents,
|
||||
echo.SpellbookFilters);
|
||||
});
|
||||
|
||||
return new(
|
||||
ClientCommands: new ClientCommandController.Bindings(
|
||||
|
|
@ -520,6 +540,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
Communication: _domain.Communication,
|
||||
CharacterState: _domain.Character,
|
||||
SendSingleCharacterOption: SendSingleCharacterOption,
|
||||
SaveCharacterOptions: SaveCharacterOptionsIfDirty,
|
||||
Log: _log);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -673,6 +673,20 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return Result(gate);
|
||||
// OP1 (Campaign OP, 2026-08-10): reject an id outside
|
||||
// CharacterOptionTable at THIS seam too — the router's
|
||||
// SetSingleCharacterOptionRuntimeCmd handler ultimately funnels into
|
||||
// RuntimeCharacterOptionsState.TrySetOption (the SAME shared write-
|
||||
// then-send/dirty policy the direct host uses), which would also
|
||||
// reject it, but LiveCommandBus.Publish has no return value for that
|
||||
// rejection to travel back on.
|
||||
if (!CharacterOptionTable.TryGet(optionId, out _))
|
||||
{
|
||||
return EmitResult(
|
||||
RuntimeCommandDomain.Character,
|
||||
operation: 4,
|
||||
RuntimeCommandStatus.Rejected);
|
||||
}
|
||||
_commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value));
|
||||
return EmitResult(
|
||||
RuntimeCommandDomain.Character,
|
||||
|
|
@ -680,6 +694,19 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
RuntimeCommandStatus.Accepted);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult SaveOptions(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return Result(gate);
|
||||
_commands.Publish(new SaveCharacterOptionsRuntimeCmd());
|
||||
return EmitResult(
|
||||
RuntimeCommandDomain.Character,
|
||||
operation: 5,
|
||||
RuntimeCommandStatus.Accepted);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult Execute(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
in RuntimeFriendCommand command)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
|
|
@ -50,6 +51,36 @@ public static class SocialActions
|
|||
// SendSetSingleCharacterOption), so only it is implemented.
|
||||
public const uint SetSingleCharacterOptionOpcode = 0x0005u; // u32 optionId, u32 value (0/1)
|
||||
|
||||
// OP1 (Campaign OP, 2026-08-10): the real batched-option blob, resurrected
|
||||
// per docs/research/2026-08-10-set-character-options-wire.md §2.3-§2.7 —
|
||||
// NOT the malformed 16-byte CH3 builder this opcode used to name (deleted
|
||||
// 2026-08-09, post-mortem in that doc §6). Body IS
|
||||
// `PlayerModule::Pack @0x005D45C0`.
|
||||
public const uint SetCharacterOptionsOpcode = 0x01A1u;
|
||||
|
||||
/// <summary>
|
||||
/// <c>PlayerModulePackHeader</c> bits retail's 2013 client ALWAYS sets
|
||||
/// (<c>PlayerModule::SetPackHeader @0x005D44A0</c>, BYTE-VERIFIED — wire
|
||||
/// research §2.2): <c>SpellLists8 (0x400)</c>, <c>SpellbookFilters
|
||||
/// (0x020)</c>, <c>2ndCharacterOptions/Options2 (0x040)</c>. The other
|
||||
/// unconditional bits from the same disassembly are OR'd in below when
|
||||
/// their section is non-empty; <c>SquelchList (0x02)</c>,
|
||||
/// <c>MultiSpellList (0x04)</c>, <c>ExtendedMultiSpellLists (0x10)</c>,
|
||||
/// and <c>TimeStampFormat (0x80)</c> are NEVER set by the 2013 client and
|
||||
/// never appear here; <c>GenericQualitiesData (0x100)</c> is never set by
|
||||
/// acdream (wire research §2.4d U2 — float sub-table shape disputed
|
||||
/// between retail and ACE, unreachable if we never set it);
|
||||
/// <c>GameplayOptions (0x200)</c> is omitted while acdream packs nothing
|
||||
/// into <c>m_colGameplayOptions</c> (safe per §2.5 — the receiver leaves
|
||||
/// its collection untouched when the flag is absent).
|
||||
/// </summary>
|
||||
private const uint PlayerModulePackHeaderBase =
|
||||
0x400u // PM_Packed_8_SpellLists
|
||||
| 0x020u // PM_Packed_SpellbookFilters
|
||||
| 0x040u; // PM_Packed_2ndCharacterOptions
|
||||
private const uint PlayerModulePackHeaderShortcuts = 0x001u; // PM_Packed_ShortCutManager
|
||||
private const uint PlayerModulePackHeaderDesiredComps = 0x008u; // PM_Packed_DesiredComps
|
||||
|
||||
/// <summary>Query a target's health — server replies with UpdateHealth (0x01C0).</summary>
|
||||
public static byte[] BuildQueryHealth(uint seq, uint targetGuid)
|
||||
{
|
||||
|
|
@ -159,6 +190,128 @@ public static class SocialActions
|
|||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flush the batched-option module: <c>SetCharacterOptions (0x01A1)</c>.
|
||||
/// Body IS <c>PlayerModule::Pack @0x005D45C0</c> — wire research §2.3
|
||||
/// field-by-field, exactly. The header is always
|
||||
/// <see cref="PlayerModulePackHeaderBase"/> (<c>0x460</c>) OR'd with the
|
||||
/// per-section bits below when that section is non-empty; ACE stores
|
||||
/// <paramref name="options1"/>/<paramref name="options2"/> and discards
|
||||
/// the four "TODO" sections (shortcuts, spell lists, desired comps,
|
||||
/// spellbook filters) into their own dedicated GameActions, but retail
|
||||
/// still packs them, so this builder echoes the caller's last-parsed
|
||||
/// values instead of zeroing them (§5.3) — <b>never</b> invent zeros for
|
||||
/// state this session actually has. <see cref="favoriteSpells"/> MUST
|
||||
/// have exactly 8 entries, matching retail's unconditional
|
||||
/// <c>favorite_spells_[8]</c> — an empty tab is a lone <c>u32 0</c>.
|
||||
/// Never sets header bit <c>0x100</c> (GenericQualitiesData, U2 —
|
||||
/// unresolved float sub-table shape) or <c>0x200</c> (GameplayOptions,
|
||||
/// unpacked by acdream today — CH6f). Every field is a 4-byte-aligned
|
||||
/// <c>u32</c>/record, so the trailing pad (§2.7) is always zero bytes in
|
||||
/// practice, but the computation is still performed for exact fidelity
|
||||
/// with <c>PlayerModule::Pack</c>'s own unconditional pad step.
|
||||
/// </summary>
|
||||
public static byte[] BuildSetCharacterOptions(
|
||||
uint seq,
|
||||
uint options1,
|
||||
uint options2,
|
||||
IReadOnlyList<ShortcutEntry> shortcuts,
|
||||
IReadOnlyList<IReadOnlyList<uint>> favoriteSpells,
|
||||
IReadOnlyDictionary<uint, uint> desiredComponents,
|
||||
uint spellbookFilters)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(shortcuts);
|
||||
ArgumentNullException.ThrowIfNull(favoriteSpells);
|
||||
ArgumentNullException.ThrowIfNull(desiredComponents);
|
||||
if (favoriteSpells.Count != 8)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Retail PlayerModule::Pack always emits exactly 8 favorite-spell lists (acclient.h:36507 favorite_spells_[8]).",
|
||||
nameof(favoriteSpells));
|
||||
}
|
||||
|
||||
uint header = PlayerModulePackHeaderBase;
|
||||
if (shortcuts.Count > 0) header |= PlayerModulePackHeaderShortcuts;
|
||||
if (desiredComponents.Count > 0) header |= PlayerModulePackHeaderDesiredComps;
|
||||
|
||||
int payloadSize =
|
||||
4 // header
|
||||
+ 4 // options1
|
||||
+ (shortcuts.Count > 0 ? 4 + 12 * shortcuts.Count : 0)
|
||||
+ FavoriteSpellsPackSize(favoriteSpells)
|
||||
+ (desiredComponents.Count > 0 ? 4 + 8 * desiredComponents.Count : 0)
|
||||
+ 4 // spellbookFilters
|
||||
+ 4; // options2
|
||||
int pad = (4 - (payloadSize & 3)) & 3;
|
||||
|
||||
byte[] body = new byte[12 + payloadSize + pad];
|
||||
int p = 0;
|
||||
WriteU32(body, ref p, GameActionEnvelope);
|
||||
WriteU32(body, ref p, seq);
|
||||
WriteU32(body, ref p, SetCharacterOptionsOpcode);
|
||||
WriteU32(body, ref p, header);
|
||||
WriteU32(body, ref p, options1);
|
||||
|
||||
if (shortcuts.Count > 0)
|
||||
{
|
||||
WriteU32(body, ref p, (uint)shortcuts.Count);
|
||||
foreach (ShortcutEntry entry in shortcuts)
|
||||
{
|
||||
WriteI32(body, ref p, entry.Index);
|
||||
WriteU32(body, ref p, entry.ObjectId);
|
||||
WriteU32(body, ref p, entry.SpellId);
|
||||
}
|
||||
}
|
||||
|
||||
for (int tab = 0; tab < 8; tab++)
|
||||
{
|
||||
IReadOnlyList<uint> list = favoriteSpells[tab];
|
||||
int count = list?.Count ?? 0;
|
||||
WriteU32(body, ref p, (uint)count);
|
||||
for (int i = 0; i < count; i++)
|
||||
WriteU32(body, ref p, list![i]);
|
||||
}
|
||||
|
||||
if (desiredComponents.Count > 0)
|
||||
{
|
||||
// PackableHashTable<K,V>::Pack @0x005692B0: sizeInfo = (tableSize
|
||||
// << 16) | count. ACE (and acdream's own inbound parser) only
|
||||
// reads the low 16 bits; the advisory high half is left zero.
|
||||
WriteU32(body, ref p, (uint)desiredComponents.Count);
|
||||
foreach (KeyValuePair<uint, uint> kvp in desiredComponents)
|
||||
{
|
||||
WriteU32(body, ref p, kvp.Key);
|
||||
WriteU32(body, ref p, kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
WriteU32(body, ref p, spellbookFilters);
|
||||
WriteU32(body, ref p, options2);
|
||||
// Tail pad bytes are already zero from `new byte[]`; nothing to write.
|
||||
return body;
|
||||
}
|
||||
|
||||
private static int FavoriteSpellsPackSize(
|
||||
IReadOnlyList<IReadOnlyList<uint>> favoriteSpells)
|
||||
{
|
||||
int size = 0;
|
||||
for (int tab = 0; tab < 8; tab++)
|
||||
size += 4 + 4 * (favoriteSpells[tab]?.Count ?? 0);
|
||||
return size;
|
||||
}
|
||||
|
||||
private static void WriteU32(byte[] dest, ref int pos, uint value)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(dest.AsSpan(pos), value);
|
||||
pos += 4;
|
||||
}
|
||||
|
||||
private static void WriteI32(byte[] dest, ref int pos, int value)
|
||||
{
|
||||
BinaryPrimitives.WriteInt32LittleEndian(dest.AsSpan(pos), value);
|
||||
pos += 4;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static byte[] SingleGuid(uint seq, uint sub, uint guid)
|
||||
|
|
@ -189,19 +342,76 @@ public static class SocialActions
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// ACE <c>CharacterOption</c> ids (a LINEAR enum, distinct from the
|
||||
/// The linear <c>PlayerOption</c> id space (a LINEAR enum, distinct from the
|
||||
/// <c>CharacterOptions1</c>/<c>CharacterOptions2</c> BITFIELDS) — the first
|
||||
/// <c>u32</c> of a <c>SetSingleCharacterOption (0x0005)</c> payload. Only
|
||||
/// the six <c>ListenTo*Chat</c> ids Campaign CH slice CH3 (2026-08-09) needs
|
||||
/// are modeled here; ACE <c>Source/ACE.Entity/Enum/CharacterOption.cs</c>
|
||||
/// has the complete list.
|
||||
/// <c>u32</c> of a <c>SetSingleCharacterOption (0x0005)</c> payload, and the
|
||||
/// key into <see cref="AcDream.Runtime.Gameplay.CharacterOptionTable"/>.
|
||||
/// Campaign OP slice OP1 (2026-08-10) widened this from the 6
|
||||
/// <c>ListenTo*Chat</c> ids Campaign CH slice CH3 needed to the complete
|
||||
/// <c>0x00..0x34</c> set, verbatim from <c>named-retail/acclient.h:4162</c>
|
||||
/// (<c>enum PlayerOption</c>) — every member below is
|
||||
/// <c><Name>_PlayerOption</c> there with its <c>_PlayerOption</c>
|
||||
/// suffix dropped, EXCEPT the six pre-existing <c>ListenTo*Chat</c> members
|
||||
/// (retail names them <c>Hear*Chat_PlayerOption</c>; kept as-is rather than
|
||||
/// renamed, since every existing caller — <c>TurbineChatMembershipGate</c>,
|
||||
/// its tests, the CH3/CH4 chat wiring — already spells them this way).
|
||||
/// <c>HearPkDeathMessages</c> (<c>0x34</c>) is ACE-sourced, not present in
|
||||
/// the 2013 PDB (the id was <c>TotalNumberOfPlayerOptions_PlayerOption</c>
|
||||
/// there) — register row, wire research §8.1.
|
||||
/// </summary>
|
||||
public enum CharacterOptionId : uint
|
||||
{
|
||||
AutoRepeatAttack = 0x00,
|
||||
IgnoreAllegianceRequests = 0x01,
|
||||
IgnoreFellowshipRequests = 0x02,
|
||||
IgnoreTradeRequests = 0x03,
|
||||
DisableMostWeatherEffects = 0x04,
|
||||
PersistentAtDay = 0x05,
|
||||
AllowGive = 0x06,
|
||||
ViewCombatTarget = 0x07,
|
||||
ShowTooltips = 0x08,
|
||||
UseDeception = 0x09,
|
||||
ToggleRun = 0x0A,
|
||||
StayInChatMode = 0x0B,
|
||||
AdvancedCombatUI = 0x0C,
|
||||
AutoTarget = 0x0D,
|
||||
VividTargetingIndicator = 0x0E,
|
||||
FellowshipShareXP = 0x0F,
|
||||
AcceptLootPermits = 0x10,
|
||||
FellowshipShareLoot = 0x11,
|
||||
FellowshipAutoAcceptRequests = 0x12,
|
||||
SideBySideVitals = 0x13,
|
||||
CoordinatesOnRadar = 0x14,
|
||||
SpellDuration = 0x15,
|
||||
DisableHouseRestrictionEffects = 0x16,
|
||||
DragItemOnPlayerOpensSecureTrade = 0x17,
|
||||
DisplayAllegianceLogonNotifications = 0x18,
|
||||
UseChargeAttack = 0x19,
|
||||
UseCraftSuccessDialog = 0x1A,
|
||||
ListenToAllegianceChat = 0x1B,
|
||||
DisplayDateOfBirth = 0x1C,
|
||||
DisplayAge = 0x1D,
|
||||
DisplayChessRank = 0x1E,
|
||||
DisplayFishingSkill = 0x1F,
|
||||
DisplayNumberDeaths = 0x20,
|
||||
DisplayTimeStamps = 0x21,
|
||||
SalvageMultiple = 0x22,
|
||||
ListenToGeneralChat = 0x23,
|
||||
ListenToTradeChat = 0x24,
|
||||
ListenToLFGChat = 0x25,
|
||||
ListenToRoleplayChat = 0x26,
|
||||
AppearOffline = 0x27,
|
||||
DisplayNumberCharacterTitles = 0x28,
|
||||
MainPackPreferred = 0x29,
|
||||
LeadMissileTargets = 0x2A,
|
||||
UseFastMissiles = 0x2B,
|
||||
FilterLanguage = 0x2C,
|
||||
ConfirmVolatileRareUse = 0x2D,
|
||||
ListenToSocietyChat = 0x2E,
|
||||
ShowHelm = 0x2F,
|
||||
DisableDistanceFog = 0x30,
|
||||
UseMouseTurning = 0x31,
|
||||
ShowCloak = 0x32,
|
||||
LockUI = 0x33,
|
||||
HearPkDeathMessages = 0x34,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2205,6 +2205,33 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(SocialActions.BuildSetSingleCharacterOption(seq, optionId, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send retail <c>SetCharacterOptions (0x01A1)</c> — the batched-option
|
||||
/// module flush (Campaign OP slice OP1, 2026-08-10). Callers own the
|
||||
/// dirty check (<c>RuntimeCharacterOptionsState.TryFlush</c> /
|
||||
/// <c>TryFlushIfAutoSaveDue</c>); this method always sends when called,
|
||||
/// matching retail's <c>CPlayerModule::SaveToServer</c> once its own
|
||||
/// <c>m_bDirty</c> gate has already passed.
|
||||
/// </summary>
|
||||
public void SendSetCharacterOptions(
|
||||
uint options1,
|
||||
uint options2,
|
||||
IReadOnlyList<ShortcutEntry> shortcuts,
|
||||
IReadOnlyList<IReadOnlyList<uint>> favoriteSpells,
|
||||
IReadOnlyDictionary<uint, uint> desiredComponents,
|
||||
uint spellbookFilters)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(SocialActions.BuildSetCharacterOptions(
|
||||
seq,
|
||||
options1,
|
||||
options2,
|
||||
shortcuts,
|
||||
favoriteSpells,
|
||||
desiredComponents,
|
||||
spellbookFilters));
|
||||
}
|
||||
|
||||
public void SendAddFriend(string name)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
|
|
|
|||
|
|
@ -196,7 +196,8 @@ public sealed class GameRuntime
|
|||
context,
|
||||
faultInjection);
|
||||
|
||||
context.Character = new RuntimeCharacterState();
|
||||
context.Character = new RuntimeCharacterState(
|
||||
timeProvider: dependencies.TimeProvider);
|
||||
construction.Own(context.Character);
|
||||
Fault(
|
||||
GameRuntimeConstructionPoint.CharacterCreated,
|
||||
|
|
|
|||
|
|
@ -250,6 +250,15 @@ public interface IRuntimeCharacterCommands
|
|||
RuntimeGenerationToken expectedGeneration,
|
||||
uint optionId,
|
||||
bool value);
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPlayerModule::SaveToServer(force: 0)</c> — the batched-
|
||||
/// option blob-flush verb (Campaign OP slice OP1, 2026-08-10). Flushes
|
||||
/// <c>SetCharacterOptions (0x01A1)</c> iff the module is dirty; a clean
|
||||
/// module sends nothing, matching retail exactly (both its own
|
||||
/// production call sites — Apply, logout — pass <c>force = 0</c>).
|
||||
/// </summary>
|
||||
RuntimeCommandResult SaveOptions(RuntimeGenerationToken expectedGeneration);
|
||||
}
|
||||
|
||||
public enum RuntimeFriendCommandKind
|
||||
|
|
|
|||
169
src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs
Normal file
169
src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// One row of <see cref="CharacterOptionTable"/>: which word the id lives in
|
||||
/// (<see cref="IsOptions1"/> selects <c>CharacterOptions1</c> vs
|
||||
/// <c>CharacterOptions2</c>), its bit <see cref="Mask"/>, whether it sends
|
||||
/// <c>SetSingleCharacterOption (0x0005)</c> immediately
|
||||
/// (<see cref="IsAutoSave"/>) or only dirties the batched
|
||||
/// <c>SetCharacterOptions (0x01A1)</c> module, and the value retail's own
|
||||
/// Character-tab Defaults button would restore (<see cref="ClientDefault"/>).
|
||||
/// </summary>
|
||||
public readonly record struct CharacterOptionTableEntry(
|
||||
CharacterOptionId Id,
|
||||
bool IsOptions1,
|
||||
uint Mask,
|
||||
bool IsAutoSave,
|
||||
bool ClientDefault);
|
||||
|
||||
/// <summary>
|
||||
/// The ONE typed table for every retail character option: linear
|
||||
/// <c>PlayerOption</c> id (<c>0x00..0x34</c>) to
|
||||
/// (<c>CharacterOptions1</c>|<c>CharacterOptions2</c> word, bit mask,
|
||||
/// auto-save wire policy, client Defaults-button value). Campaign OP slice
|
||||
/// OP1 (2026-08-10) — replaces the 6-id partial coverage
|
||||
/// <c>RuntimeCharacterOptionsState.SetOptionBit</c> used to hand-roll.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Sources, all byte-verified against the PDB-paired 2013 EoR binary</b>
|
||||
/// (docs/research/2026-08-10-character-options-map.md +
|
||||
/// docs/research/2026-08-10-set-character-options-wire.md):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>Word</c>/<c>Mask</c> — verbatim
|
||||
/// <c>named-retail/acclient.h:3404-3436</c> (<c>enum CharacterOption</c>,
|
||||
/// the Options1 bitfield — despite the name, this is NOT the same enum as
|
||||
/// <see cref="CharacterOptionId"/>) and <c>acclient.h:3451-3481</c>
|
||||
/// (<c>enum CharacterOptions2</c>), cross-referenced by name against
|
||||
/// <c>acclient.h:4162-4218</c> (<c>enum PlayerOption</c>, the id space
|
||||
/// itself). Reconstructing <c>CharacterOptions1.Default</c> from every
|
||||
/// <c>ClientDefault</c> row below whose word is Options1 yields exactly
|
||||
/// <c>0x50C4A54A</c>; Options2 yields <c>0x00008700</c> — both independently
|
||||
/// confirmed against the retail constructor's own literal writes
|
||||
/// (character-options-map.md §1.4, wire doc §2.5).</description></item>
|
||||
/// <item><description><c>IsAutoSave</c> —
|
||||
/// <c>CPlayerModule::IsAutoSaveOption @0x0059A600</c>'s 0x34-byte jump table
|
||||
/// at VA <c>0x0059A62C</c> (wire doc §3.2): 21 of 53 ids send <c>0x0005</c>
|
||||
/// immediately; the rest only mark <c>PlayerModule</c> dirty for the batched
|
||||
/// blob.</description></item>
|
||||
/// <item><description><c>ClientDefault</c> —
|
||||
/// <c>PlayerModule::GetDefaultOptionValue @0x005D2A30</c>'s 0x2B-byte table
|
||||
/// at VA <c>0x005D2A5C</c> (wire doc §8.2): only covers ids <c>0x00..0x2A</c>
|
||||
/// (16 default-ON); every id above <c>0x2A</c> (<c>0x2B..0x34</c>) falls off
|
||||
/// the end of that table and defaults to <c>false</c> here even though THREE
|
||||
/// of them — <c>ConfirmVolatileRareUse</c>, <c>ShowHelm</c>,
|
||||
/// <c>ShowCloak</c> — are actually ON in the raw constructor default word
|
||||
/// <c>0x00948700</c>. This is retail's OWN behavior (the Defaults button
|
||||
/// does not reproduce a fresh <c>PlayerModule</c>), reproduced here
|
||||
/// deliberately — see the matching row in
|
||||
/// docs/architecture/retail-divergence-register.md. Do not "fix" it to
|
||||
/// match the constructor default.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// <b><see cref="CharacterOptionId.HearPkDeathMessages"/> (<c>0x34</c>)</b>
|
||||
/// does not exist in the 2013 build (<c>PlayerOption</c> there terminates at
|
||||
/// <c>TotalNumberOfPlayerOptions_PlayerOption = 0x34</c>). Its
|
||||
/// <c>Options2</c> mask <c>0x02000000</c> is ACE-sourced
|
||||
/// (<c>ListenToPKDeathMessages</c>) and UNVERIFIABLE against our binary —
|
||||
/// register row. Its auto-save classification is likewise an open unknown
|
||||
/// (wire doc §8.1 U4: the 2013 <c>IsAutoSaveOption</c> bounds check would
|
||||
/// reject any id > <c>0x33</c> by construction, which is evidence about
|
||||
/// the 2013 build, not the final client that actually shipped this option).
|
||||
/// Modeled here as batched (not auto-save) — the conservative reading: it
|
||||
/// never sends anything acdream cannot otherwise justify, and ACE's
|
||||
/// <c>0x0005</c> handler's <c>default:</c> branch just stores the bit either
|
||||
/// way, so nothing server-observable depends on the choice.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CharacterOptionTable
|
||||
{
|
||||
private static readonly Dictionary<uint, CharacterOptionTableEntry> Entries = Build();
|
||||
|
||||
public static bool TryGet(uint optionId, out CharacterOptionTableEntry entry) =>
|
||||
Entries.TryGetValue(optionId, out entry);
|
||||
|
||||
public static bool TryGet(CharacterOptionId optionId, out CharacterOptionTableEntry entry) =>
|
||||
TryGet((uint)optionId, out entry);
|
||||
|
||||
/// <summary>Every modeled id, id-ascending. Used by conformance tests
|
||||
/// that must walk the complete <c>0x00..0x34</c> set.</summary>
|
||||
public static IReadOnlyList<CharacterOptionTableEntry> All { get; } =
|
||||
[.. Entries.Values.OrderBy(static e => (uint)e.Id)];
|
||||
|
||||
private static Dictionary<uint, CharacterOptionTableEntry> Build()
|
||||
{
|
||||
var table = new Dictionary<uint, CharacterOptionTableEntry>(53);
|
||||
|
||||
void Add(
|
||||
CharacterOptionId id,
|
||||
bool isOptions1,
|
||||
uint mask,
|
||||
bool autoSave,
|
||||
bool clientDefault) =>
|
||||
table.Add(
|
||||
(uint)id,
|
||||
new CharacterOptionTableEntry(id, isOptions1, mask, autoSave, clientDefault));
|
||||
|
||||
// acclient.h:4162-4218 order (== PlayerOption id-ascending).
|
||||
Add(CharacterOptionId.AutoRepeatAttack, true, 0x00000002u, true, true);
|
||||
Add(CharacterOptionId.IgnoreAllegianceRequests, true, 0x00000004u, true, false);
|
||||
Add(CharacterOptionId.IgnoreFellowshipRequests, true, 0x00000008u, true, true);
|
||||
Add(CharacterOptionId.IgnoreTradeRequests, true, 0x00020000u, false, false);
|
||||
Add(CharacterOptionId.DisableMostWeatherEffects, true, 0x00010000u, false, false);
|
||||
Add(CharacterOptionId.PersistentAtDay, false, 0x00000001u, false, false);
|
||||
Add(CharacterOptionId.AllowGive, true, 0x00000040u, false, true);
|
||||
Add(CharacterOptionId.ViewCombatTarget, true, 0x00000080u, false, false);
|
||||
Add(CharacterOptionId.ShowTooltips, true, 0x00000100u, false, true);
|
||||
Add(CharacterOptionId.UseDeception, true, 0x00000200u, false, false);
|
||||
Add(CharacterOptionId.ToggleRun, true, 0x00000400u, false, true);
|
||||
Add(CharacterOptionId.StayInChatMode, true, 0x00000800u, false, false);
|
||||
Add(CharacterOptionId.AdvancedCombatUI, true, 0x00001000u, false, false);
|
||||
Add(CharacterOptionId.AutoTarget, true, 0x00002000u, false, true);
|
||||
Add(CharacterOptionId.VividTargetingIndicator, true, 0x00008000u, false, true);
|
||||
Add(CharacterOptionId.FellowshipShareXP, true, 0x00040000u, true, true);
|
||||
Add(CharacterOptionId.AcceptLootPermits, true, 0x00080000u, true, false);
|
||||
Add(CharacterOptionId.FellowshipShareLoot, true, 0x00100000u, true, false);
|
||||
Add(CharacterOptionId.FellowshipAutoAcceptRequests, true, 0x20000000u, true, false);
|
||||
Add(CharacterOptionId.SideBySideVitals, true, 0x00200000u, false, false);
|
||||
Add(CharacterOptionId.CoordinatesOnRadar, true, 0x00400000u, false, true);
|
||||
Add(CharacterOptionId.SpellDuration, true, 0x00800000u, false, true);
|
||||
Add(CharacterOptionId.DisableHouseRestrictionEffects, true, 0x02000000u, false, false);
|
||||
Add(CharacterOptionId.DragItemOnPlayerOpensSecureTrade, true, 0x04000000u, false, false);
|
||||
Add(CharacterOptionId.DisplayAllegianceLogonNotifications, true, 0x08000000u, false, false);
|
||||
Add(CharacterOptionId.UseChargeAttack, true, 0x10000000u, true, true);
|
||||
Add(CharacterOptionId.UseCraftSuccessDialog, true, 0x80000000u, false, false);
|
||||
Add(CharacterOptionId.ListenToAllegianceChat, true, 0x40000000u, true, true);
|
||||
Add(CharacterOptionId.DisplayDateOfBirth, false, 0x00000002u, false, false);
|
||||
Add(CharacterOptionId.DisplayAge, false, 0x00000020u, false, false);
|
||||
Add(CharacterOptionId.DisplayChessRank, false, 0x00000004u, false, false);
|
||||
Add(CharacterOptionId.DisplayFishingSkill, false, 0x00000008u, false, false);
|
||||
Add(CharacterOptionId.DisplayNumberDeaths, false, 0x00000010u, false, false);
|
||||
Add(CharacterOptionId.DisplayTimeStamps, false, 0x00000040u, false, false);
|
||||
Add(CharacterOptionId.SalvageMultiple, false, 0x00000080u, false, false);
|
||||
Add(CharacterOptionId.ListenToGeneralChat, false, 0x00000100u, true, true);
|
||||
Add(CharacterOptionId.ListenToTradeChat, false, 0x00000200u, true, true);
|
||||
Add(CharacterOptionId.ListenToLFGChat, false, 0x00000400u, true, true);
|
||||
Add(CharacterOptionId.ListenToRoleplayChat, false, 0x00000800u, true, false);
|
||||
Add(CharacterOptionId.AppearOffline, false, 0x00001000u, true, false);
|
||||
Add(CharacterOptionId.DisplayNumberCharacterTitles, false, 0x00002000u, false, false);
|
||||
Add(CharacterOptionId.MainPackPreferred, false, 0x00004000u, false, false);
|
||||
Add(CharacterOptionId.LeadMissileTargets, false, 0x00008000u, true, true);
|
||||
Add(CharacterOptionId.UseFastMissiles, false, 0x00010000u, true, false);
|
||||
Add(CharacterOptionId.FilterLanguage, false, 0x00020000u, false, false);
|
||||
Add(CharacterOptionId.ConfirmVolatileRareUse, false, 0x00040000u, false, false);
|
||||
Add(CharacterOptionId.ListenToSocietyChat, false, 0x00080000u, true, false);
|
||||
Add(CharacterOptionId.ShowHelm, false, 0x00100000u, true, false);
|
||||
Add(CharacterOptionId.DisableDistanceFog, false, 0x00200000u, false, false);
|
||||
Add(CharacterOptionId.UseMouseTurning, false, 0x00400000u, true, false);
|
||||
Add(CharacterOptionId.ShowCloak, false, 0x00800000u, true, false);
|
||||
Add(CharacterOptionId.LockUI, false, 0x01000000u, true, false);
|
||||
// D3 / register row: id and mask are ACE-sourced (ListenToPKDeathMessages),
|
||||
// unverifiable against the 2013 binary. See the type doc above.
|
||||
Add(CharacterOptionId.HearPkDeathMessages, false, 0x02000000u, false, false);
|
||||
|
||||
return table;
|
||||
}
|
||||
}
|
||||
49
src/AcDream.Runtime/Gameplay/CharacterOptionsBlobSource.cs
Normal file
49
src/AcDream.Runtime/Gameplay/CharacterOptionsBlobSource.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// The exact non-boolean fields <c>PlayerModule::Pack</c> ALSO writes into
|
||||
/// the <c>SetCharacterOptions (0x01A1)</c> blob alongside the two option
|
||||
/// bitfields — shortcuts, the 8 favorite-spell lists, desired components,
|
||||
/// and the spellbook filter word. Wire research §5.3: ACE discards these
|
||||
/// four sections into its own dedicated GameActions, but retail still packs
|
||||
/// them, so a faithful builder echoes Runtime's already-parsed
|
||||
/// last-<c>PlayerDescription</c> state instead of zeroing them.
|
||||
/// </summary>
|
||||
public readonly record struct CharacterOptionsBlobEcho(
|
||||
uint Options1,
|
||||
uint Options2,
|
||||
IReadOnlyList<ShortcutEntry> Shortcuts,
|
||||
IReadOnlyList<IReadOnlyList<uint>> FavoriteSpells,
|
||||
IReadOnlyDictionary<uint, uint> DesiredComponents,
|
||||
uint SpellbookFilters);
|
||||
|
||||
/// <summary>
|
||||
/// Captures a <see cref="CharacterOptionsBlobEcho"/> from Runtime's live
|
||||
/// state. The SAME capture is used by every host that can flush the batched
|
||||
/// module (both <c>IRuntimeCharacterCommands.SaveOptions</c> adapters) so
|
||||
/// there is exactly one place that assembles the echo fields.
|
||||
/// </summary>
|
||||
public static class CharacterOptionsBlobSource
|
||||
{
|
||||
public static CharacterOptionsBlobEcho Capture(
|
||||
RuntimeCharacterState character,
|
||||
ShortcutStore shortcuts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(character);
|
||||
ArgumentNullException.ThrowIfNull(shortcuts);
|
||||
|
||||
var favorites = new IReadOnlyList<uint>[8];
|
||||
for (int tab = 0; tab < 8; tab++)
|
||||
favorites[tab] = character.Spellbook.GetFavorites(tab);
|
||||
|
||||
return new CharacterOptionsBlobEcho(
|
||||
character.Options.Options1,
|
||||
character.Options.Options2,
|
||||
shortcuts.Items,
|
||||
favorites,
|
||||
character.Spellbook.DesiredComponents,
|
||||
character.Spellbook.SpellbookFilters);
|
||||
}
|
||||
}
|
||||
|
|
@ -86,11 +86,13 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
private int _jumpSkillBase = -1;
|
||||
private PlayerSkillMath.AugmentationBonuses _movementSkillAugmentations;
|
||||
|
||||
public RuntimeCharacterState(SpellTable? spellTable = null)
|
||||
public RuntimeCharacterState(
|
||||
SpellTable? spellTable = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
Spellbook = new Spellbook(spellTable);
|
||||
LocalPlayer = new LocalPlayerState(Spellbook);
|
||||
Options = new RuntimeCharacterOptionsState();
|
||||
Options = new RuntimeCharacterOptionsState(timeProvider);
|
||||
MovementSkills = new RuntimeMovementSkillState();
|
||||
View = new CharacterView(this);
|
||||
Spellbook.StateChanged += OnSpellbookChanged;
|
||||
|
|
@ -620,7 +622,9 @@ public readonly record struct RuntimeCharacterOptionsSnapshot(
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical session-owned copy of retail's two character-option bitfields.
|
||||
/// Canonical session-owned copy of retail's two character-option bitfields,
|
||||
/// plus the batched-module dirty model
|
||||
/// (<c>CPlayerModule::m_bDirty</c>/<c>m_timeFirstDirtied</c>).
|
||||
/// <c>PlayerModule::PlayerModule @ 0x005D51F0</c> installs the defaults.
|
||||
/// Runtime reset restores the equivalent fresh-player-module state because
|
||||
/// one Runtime owner survives across graphical and no-window sessions.
|
||||
|
|
@ -631,9 +635,27 @@ public sealed class RuntimeCharacterOptionsState
|
|||
(uint)PlayerDescriptionParser.CharacterOptions1.Default;
|
||||
public const uint DefaultOptions2 = 0x00948700u;
|
||||
|
||||
/// <summary>
|
||||
/// <c>CPlayerModule::UseTime @0x0059A710</c>, BYTE-VERIFIED literal
|
||||
/// <c>480.0</c> (wire research §3.3): the batched module flushes 480
|
||||
/// seconds after it FIRST went dirty, not after the last change. A
|
||||
/// property (not a field) so this type keeps zero static mutable state
|
||||
/// — see <c>GameRuntimeContractTests.J4GameplayOwnersHaveNoStaticMutableSessionState</c>.
|
||||
/// </summary>
|
||||
public static TimeSpan AutoSaveDelay => TimeSpan.FromSeconds(480);
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _dirtyGate = new();
|
||||
private uint _options1 = DefaultOptions1;
|
||||
private uint _options2 = DefaultOptions2;
|
||||
private long _revision;
|
||||
private bool _isDirty;
|
||||
private DateTimeOffset _firstDirtiedAt;
|
||||
|
||||
public RuntimeCharacterOptionsState(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public uint Options1 => Volatile.Read(ref _options1);
|
||||
public uint Options2 => Volatile.Read(ref _options2);
|
||||
|
|
@ -641,6 +663,21 @@ public sealed class RuntimeCharacterOptionsState
|
|||
public RuntimeCharacterOptionsSnapshot Snapshot =>
|
||||
new(_options1, _options2, Revision);
|
||||
|
||||
/// <summary>Retail's <c>m_bDirty</c> — an unflushed batched-option
|
||||
/// change is waiting on Apply / logout / the 480 s timer.</summary>
|
||||
public bool IsDirty
|
||||
{
|
||||
get { lock (_dirtyGate) return _isDirty; }
|
||||
}
|
||||
|
||||
/// <summary>Retail's <c>m_timeFirstDirtied</c> — the instant the module
|
||||
/// FIRST went dirty since its last flush, or <c>null</c> when
|
||||
/// clean.</summary>
|
||||
public DateTimeOffset? FirstDirtiedAt
|
||||
{
|
||||
get { lock (_dirtyGate) return _isDirty ? _firstDirtiedAt : null; }
|
||||
}
|
||||
|
||||
public bool DragItemOnPlayerOpensSecureTrade =>
|
||||
Snapshot.DragItemOnPlayerOpensSecureTrade;
|
||||
|
||||
|
|
@ -651,63 +688,143 @@ public sealed class RuntimeCharacterOptionsState
|
|||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THE shared local-write-then-send/dirty seam every entrance that can
|
||||
/// flip a character option funnels through — @join/@leave, the Settings
|
||||
/// Chat toggles, the Options panel, a headless bot, both
|
||||
/// <c>IRuntimeCharacterCommands.SetSingleOption</c> host adapters.
|
||||
/// Mirrors <c>CPlayerModule::OnChanged(PlayerOption) @0x0059A8E0</c>
|
||||
/// exactly: write the bit into this LOCAL copy FIRST (so a same-session
|
||||
/// consumer like <see cref="TurbineChatMembershipGate"/> is correct
|
||||
/// before any round trip), THEN either invoke
|
||||
/// <paramref name="sendAutoSave"/> immediately (retail's
|
||||
/// <c>IsAutoSaveOption</c> branch — <c>Event_PlayerOptionChangedEvent</c>,
|
||||
/// the <c>0x0005</c> send) or <see cref="MarkDirty"/> for the batched
|
||||
/// <c>0x01A1</c> flush (the else branch). Matches retail's own
|
||||
/// unchanged-value early return (wire research §3.1 — "an unchanged
|
||||
/// option produces no notice, no side effect, no message at all") by
|
||||
/// no-op'ing when <paramref name="value"/> already holds. Returns
|
||||
/// <c>false</c> for an id outside <see cref="CharacterOptionTable"/>
|
||||
/// (retail's own <c>IsAutoSaveOption</c>/id-cast bounds check would
|
||||
/// reject it too) — callers turn that into a
|
||||
/// <see cref="RuntimeCommandStatus.Rejected"/>, never a silent send.
|
||||
/// </summary>
|
||||
public bool TrySetOption(uint characterOptionId, bool value, Action sendAutoSave)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sendAutoSave);
|
||||
if (!CharacterOptionTable.TryGet(characterOptionId, out CharacterOptionTableEntry entry))
|
||||
return false;
|
||||
|
||||
uint word = entry.IsOptions1 ? Options1 : Options2;
|
||||
if (((word & entry.Mask) != 0u) == value)
|
||||
return true;
|
||||
|
||||
SetOptionBit(characterOptionId, value);
|
||||
|
||||
if (entry.IsAutoSave)
|
||||
sendAutoSave();
|
||||
else
|
||||
MarkDirty();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set ONE character-option bit locally, by its linear
|
||||
/// <c>CharacterOptionId</c> (the same id carried on the wire by
|
||||
/// <c>SetSingleCharacterOption (0x0005)</c>). Retail's
|
||||
/// <c>PlayerModule::SetHearGeneralChat @0x005D35C0</c> (and its five
|
||||
/// <c>SetHear*Chat</c> siblings) write the bit into this LOCAL copy
|
||||
/// FIRST, before the client ever notifies the server. CH4
|
||||
/// REJECT-review SHOULD-FIX 4 (2026-08-09): acdream's <c>@join</c>/
|
||||
/// <c>@leave</c> previously pushed only the wire message and left this
|
||||
/// state untouched, so <see cref="AcDream.Runtime.Gameplay.TurbineChatMembershipGate"/>
|
||||
/// kept refusing a room the player had just joined until the next
|
||||
/// <c>PlayerDescription</c> happened to arrive. Only the six
|
||||
/// <c>ListenTo*Chat</c> ids <c>CharacterOptionId</c> models are
|
||||
/// recognized here; any other id is a silent no-op — this state only
|
||||
/// tracks what the Turbine-chat membership gate needs, not a complete
|
||||
/// <c>PlayerModule</c> mirror.
|
||||
/// <c>SetSingleCharacterOption (0x0005)</c>), resolved through the
|
||||
/// complete <see cref="CharacterOptionTable"/> (Campaign OP slice OP1,
|
||||
/// 2026-08-10 — widened from the 6 <c>ListenTo*Chat</c> ids Campaign CH
|
||||
/// slice CH3 modeled). Retail's <c>PlayerModule::SetHearGeneralChat
|
||||
/// @0x005D35C0</c> (and every sibling <c>Set<Option></c> accessor)
|
||||
/// writes the bit into this LOCAL copy FIRST, before the client ever
|
||||
/// notifies the server — <see cref="TrySetOption"/> is the seam that
|
||||
/// preserves that ordering end-to-end; call this directly only when you
|
||||
/// specifically want the bit write WITHOUT the send/dirty policy (e.g.
|
||||
/// reseeding local state that a fresh <c>PlayerDescription</c> already
|
||||
/// authoritatively carries). An id outside the table is a silent no-op.
|
||||
/// </summary>
|
||||
public void SetOptionBit(uint characterOptionId, bool value)
|
||||
{
|
||||
(bool isOptions1, uint mask) = characterOptionId switch
|
||||
{
|
||||
(uint)CharacterOptionId.ListenToAllegianceChat =>
|
||||
(true, (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat),
|
||||
(uint)CharacterOptionId.ListenToGeneralChat =>
|
||||
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat),
|
||||
(uint)CharacterOptionId.ListenToTradeChat =>
|
||||
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat),
|
||||
(uint)CharacterOptionId.ListenToLFGChat =>
|
||||
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat),
|
||||
(uint)CharacterOptionId.ListenToRoleplayChat =>
|
||||
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat),
|
||||
(uint)CharacterOptionId.ListenToSocietyChat =>
|
||||
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat),
|
||||
_ => (false, 0u),
|
||||
};
|
||||
if (mask == 0u)
|
||||
if (!CharacterOptionTable.TryGet(characterOptionId, out CharacterOptionTableEntry entry))
|
||||
return;
|
||||
|
||||
if (isOptions1)
|
||||
if (entry.IsOptions1)
|
||||
{
|
||||
uint updated = value ? (Options1 | mask) : (Options1 & ~mask);
|
||||
uint updated = value ? (Options1 | entry.Mask) : (Options1 & ~entry.Mask);
|
||||
Volatile.Write(ref _options1, updated);
|
||||
}
|
||||
else
|
||||
{
|
||||
uint updated = value ? (Options2 | mask) : (Options2 & ~mask);
|
||||
uint updated = value ? (Options2 | entry.Mask) : (Options2 & ~entry.Mask);
|
||||
Volatile.Write(ref _options2, updated);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>CPlayerModule::OnChanged</c> else-branch: <c>if
|
||||
/// (!m_bDirty) { m_bDirty = 1; m_timeFirstDirtied = Timer::cur_time; }</c>
|
||||
/// — only the FIRST dirtying change since the last flush stamps the
|
||||
/// timer; later batched changes before the next flush do not push it
|
||||
/// out.
|
||||
/// </summary>
|
||||
public void MarkDirty()
|
||||
{
|
||||
lock (_dirtyGate)
|
||||
{
|
||||
if (_isDirty) return;
|
||||
_isDirty = true;
|
||||
_firstDirtiedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>CPlayerModule::SaveToServer(force: 0) @0x0059A660</c> —
|
||||
/// both production call sites (Apply, logout) pass <c>force = 0</c>, so
|
||||
/// a clean module sends nothing. The explicit <c>SaveOptions</c>
|
||||
/// Runtime command flushes through here.
|
||||
/// </summary>
|
||||
public bool TryFlush(Action flush)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(flush);
|
||||
lock (_dirtyGate)
|
||||
{
|
||||
if (!_isDirty) return false;
|
||||
flush();
|
||||
_isDirty = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>CPlayerModule::UseTime @0x0059A710</c>: flush iff dirty
|
||||
/// AND at least <see cref="AutoSaveDelay"/> (480 s, BYTE-VERIFIED) has
|
||||
/// elapsed since <see cref="FirstDirtiedAt"/>. A no-op host may call
|
||||
/// this once per tick; it is cheap and inert unless the timer is
|
||||
/// actually due.
|
||||
/// </summary>
|
||||
public bool TryFlushIfAutoSaveDue(Action flush)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(flush);
|
||||
lock (_dirtyGate)
|
||||
{
|
||||
if (!_isDirty) return false;
|
||||
if (_timeProvider.GetUtcNow() - _firstDirtiedAt < AutoSaveDelay) return false;
|
||||
flush();
|
||||
_isDirty = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
Volatile.Write(ref _options1, DefaultOptions1);
|
||||
Volatile.Write(ref _options2, DefaultOptions2);
|
||||
Interlocked.Increment(ref _revision);
|
||||
lock (_dirtyGate)
|
||||
_isDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -658,11 +658,47 @@ public sealed class DirectGameRuntimeCommandAdapter
|
|||
Validate(expectedGeneration, out WorldSession? session);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return Result(gate);
|
||||
session!.SendSetSingleCharacterOption(optionId, value);
|
||||
// OP1 (Campaign OP, 2026-08-10): route through the SAME shared
|
||||
// write-then-send seam the graphical host's LiveSessionRuntimeFactory
|
||||
// closure uses, fixing the headless local-write gap lane B §4.4 /
|
||||
// lane C §7.4 found (this path used to send the wire message WITHOUT
|
||||
// writing the bit locally first).
|
||||
bool accepted = _runtime.CharacterOwner.Options.TrySetOption(
|
||||
optionId,
|
||||
value,
|
||||
sendAutoSave: () =>
|
||||
session!.SendSetSingleCharacterOption(optionId, value));
|
||||
return EmitResult(
|
||||
RuntimeCommandDomain.Character,
|
||||
operation: 4,
|
||||
RuntimeCommandStatus.Accepted);
|
||||
accepted ? RuntimeCommandStatus.Accepted : RuntimeCommandStatus.Rejected);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult SaveOptions(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
RuntimeCommandStatus gate =
|
||||
Validate(expectedGeneration, out WorldSession? session);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return Result(gate);
|
||||
bool flushed = _runtime.CharacterOwner.Options.TryFlush(() =>
|
||||
{
|
||||
CharacterOptionsBlobEcho echo = CharacterOptionsBlobSource.Capture(
|
||||
_runtime.CharacterOwner,
|
||||
_runtime.InventoryOwner.Shortcuts);
|
||||
session!.SendSetCharacterOptions(
|
||||
echo.Options1,
|
||||
echo.Options2,
|
||||
echo.Shortcuts,
|
||||
echo.FavoriteSpells,
|
||||
echo.DesiredComponents,
|
||||
echo.SpellbookFilters);
|
||||
});
|
||||
return EmitResult(
|
||||
RuntimeCommandDomain.Character,
|
||||
operation: 5,
|
||||
RuntimeCommandStatus.Accepted,
|
||||
primaryObjectId: flushed ? 1u : 0u);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult Execute(
|
||||
|
|
|
|||
|
|
@ -307,6 +307,10 @@ public sealed class InteractionUiRuntimeSourcesTests
|
|||
bool value) =>
|
||||
Accepted(expectedGeneration);
|
||||
|
||||
public RuntimeCommandResult SaveOptions(
|
||||
RuntimeGenerationToken expectedGeneration) =>
|
||||
Accepted(expectedGeneration);
|
||||
|
||||
private RuntimeCommandResult Accepted(
|
||||
RuntimeGenerationToken generation,
|
||||
uint objectId = 0u)
|
||||
|
|
|
|||
|
|
@ -438,6 +438,24 @@ public sealed class LiveSessionCommandRouterTests
|
|||
Assert.Equal([(0x26u, true)], options);
|
||||
}
|
||||
|
||||
// ── OP1 (Campaign OP, 2026-08-10): SaveCharacterOptionsRuntimeCmd ────
|
||||
|
||||
[Fact]
|
||||
public void SaveCharacterOptionsCommand_RoutesToBindingOnlyWhileActive()
|
||||
{
|
||||
int flushes = 0;
|
||||
LiveSessionCommandRouter router = NewRouter(
|
||||
saveCharacterOptions: () => flushes++);
|
||||
|
||||
router.Publish(new SaveCharacterOptionsRuntimeCmd());
|
||||
router.Activate();
|
||||
router.Publish(new SaveCharacterOptionsRuntimeCmd());
|
||||
router.Dispose();
|
||||
router.Publish(new SaveCharacterOptionsRuntimeCmd());
|
||||
|
||||
Assert.Equal(1, flushes);
|
||||
}
|
||||
|
||||
// ── CH4 re-review SHOULD-FIX 2 (2026-08-09) ─────────────────────────
|
||||
// The Settings Chat toggles reach this same SetSingleCharacterOptionRuntimeCmd
|
||||
// route (RuntimeSettingsController.PublishHearOptionChange ->
|
||||
|
|
@ -559,7 +577,8 @@ public sealed class LiveSessionCommandRouterTests
|
|||
ClientCommandController.Bindings? clientBindings = null,
|
||||
RuntimeCommunicationState? communication = null,
|
||||
RuntimeCharacterState? characterState = null,
|
||||
Action<uint, bool>? sendSingleCharacterOption = null) => new(
|
||||
Action<uint, bool>? sendSingleCharacterOption = null,
|
||||
Action? saveCharacterOptions = null) => new(
|
||||
new LiveSessionCommandBindings(
|
||||
clientBindings ?? NewClientBindings(),
|
||||
chat ?? new ChatLog(),
|
||||
|
|
@ -591,6 +610,7 @@ public sealed class LiveSessionCommandRouterTests
|
|||
Communication: communication ?? new RuntimeCommunicationState(),
|
||||
CharacterState: characterState ?? new RuntimeCharacterState(),
|
||||
SendSingleCharacterOption: sendSingleCharacterOption ?? ((_, _) => { }),
|
||||
SaveCharacterOptions: saveCharacterOptions ?? (() => { }),
|
||||
Log: log));
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
|
|
|
|||
|
|
@ -523,6 +523,44 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Assert.Equal(published, harness.Commands.Published.Count);
|
||||
}
|
||||
|
||||
// ── OP1 (Campaign OP, 2026-08-10) ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SetSingleOption_UnknownId_RejectsWithoutPublishing()
|
||||
{
|
||||
using var harness = new Harness();
|
||||
_ = harness.Runtime.Session.Start(harness.Runtime.Generation);
|
||||
RuntimeGenerationToken generation = harness.Runtime.Generation;
|
||||
IGameRuntimeCommands commands = harness.Runtime;
|
||||
int published = harness.Commands.Published.Count;
|
||||
|
||||
// 0x36 == CharacterOptions2Default — the whole-default-mask
|
||||
// landmine (wire research §5.4.3), never a real option.
|
||||
RuntimeCommandResult result = commands.Character.SetSingleOption(
|
||||
generation,
|
||||
0x36u,
|
||||
true);
|
||||
|
||||
Assert.Equal(RuntimeCommandStatus.Rejected, result.Status);
|
||||
Assert.Equal(published, harness.Commands.Published.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveOptions_PublishesSaveCharacterOptionsCommand()
|
||||
{
|
||||
using var harness = new Harness();
|
||||
_ = harness.Runtime.Session.Start(harness.Runtime.Generation);
|
||||
RuntimeGenerationToken generation = harness.Runtime.Generation;
|
||||
IGameRuntimeCommands commands = harness.Runtime;
|
||||
|
||||
RuntimeCommandResult result = commands.Character.SaveOptions(generation);
|
||||
|
||||
Assert.True(result.Accepted);
|
||||
Assert.Contains(
|
||||
harness.Commands.Published,
|
||||
static command => command is SaveCharacterOptionsRuntimeCmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphicalAndNoWindowJ4CommandsProduceIdenticalCanonicalState()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -130,4 +132,166 @@ public sealed class SocialActionsTests
|
|||
Assert.Equal(0u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
||||
}
|
||||
|
||||
// ── OP1 (Campaign OP, 2026-08-10): BuildSetCharacterOptions (0x01A1) ──
|
||||
// docs/research/2026-08-10-set-character-options-wire.md §2.3-§2.7. The
|
||||
// golden vector below is HAND-COMPUTED, field by field, from that
|
||||
// layout — not generated by calling the builder under test. The CH3
|
||||
// builder (deleted 2026-08-09) died of ten green tests pinning a wrong
|
||||
// shape; a golden vector this way is the only test that can catch the
|
||||
// SAME class of mistake (wire doc §6.2).
|
||||
|
||||
[Fact]
|
||||
public void BuildSetCharacterOptions_GoldenByteVector_MatchesHandComputedLayout()
|
||||
{
|
||||
ShortcutEntry[] shortcuts = [new ShortcutEntry(0, 0x80000001u, 0u)];
|
||||
IReadOnlyList<uint>[] favorites =
|
||||
[
|
||||
new uint[] { 1234u }, // tab 0
|
||||
Array.Empty<uint>(),
|
||||
Array.Empty<uint>(),
|
||||
Array.Empty<uint>(),
|
||||
Array.Empty<uint>(),
|
||||
Array.Empty<uint>(),
|
||||
Array.Empty<uint>(),
|
||||
Array.Empty<uint>(),
|
||||
];
|
||||
var desiredComponents = new Dictionary<uint, uint> { [0x68000001u] = 12u };
|
||||
|
||||
byte[] body = SocialActions.BuildSetCharacterOptions(
|
||||
seq: 5u,
|
||||
options1: 0x50C4A54Au,
|
||||
options2: 0x00948700u,
|
||||
shortcuts: shortcuts,
|
||||
favoriteSpells: favorites,
|
||||
desiredComponents: desiredComponents,
|
||||
spellbookFilters: 0x3FFFu);
|
||||
|
||||
byte[] expected =
|
||||
[
|
||||
0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1
|
||||
0x05, 0x00, 0x00, 0x00, // seq 5
|
||||
0xA1, 0x01, 0x00, 0x00, // opcode 0x01A1
|
||||
0x69, 0x04, 0x00, 0x00, // header 0x469 (base 0x460 | shortcuts 0x001 | desiredComps 0x008)
|
||||
0x4A, 0xA5, 0xC4, 0x50, // options1 0x50C4A54A
|
||||
0x01, 0x00, 0x00, 0x00, // shortcuts count = 1
|
||||
0x00, 0x00, 0x00, 0x00, // index 0
|
||||
0x01, 0x00, 0x00, 0x80, // objectId 0x80000001
|
||||
0x00, 0x00, 0x00, 0x00, // spellId 0
|
||||
0x01, 0x00, 0x00, 0x00, // tab0 count = 1
|
||||
0xD2, 0x04, 0x00, 0x00, // spellId 1234 (0x4D2)
|
||||
0x00, 0x00, 0x00, 0x00, // tab1 count = 0
|
||||
0x00, 0x00, 0x00, 0x00, // tab2 count = 0
|
||||
0x00, 0x00, 0x00, 0x00, // tab3 count = 0
|
||||
0x00, 0x00, 0x00, 0x00, // tab4 count = 0
|
||||
0x00, 0x00, 0x00, 0x00, // tab5 count = 0
|
||||
0x00, 0x00, 0x00, 0x00, // tab6 count = 0
|
||||
0x00, 0x00, 0x00, 0x00, // tab7 count = 0
|
||||
0x01, 0x00, 0x00, 0x00, // desiredComps sizeInfo = 1
|
||||
0x01, 0x00, 0x00, 0x68, // key 0x68000001
|
||||
0x0C, 0x00, 0x00, 0x00, // value 12
|
||||
0xFF, 0x3F, 0x00, 0x00, // spellbookFilters 0x3FFF
|
||||
0x00, 0x87, 0x94, 0x00, // options2 0x00948700
|
||||
];
|
||||
|
||||
Assert.Equal(expected, body);
|
||||
Assert.Equal(92, body.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSetCharacterOptions_OmitsOptionalHeaderBitsWhenSectionsEmpty()
|
||||
{
|
||||
IReadOnlyList<uint>[] favorites =
|
||||
[
|
||||
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>(),
|
||||
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>(),
|
||||
];
|
||||
|
||||
byte[] body = SocialActions.BuildSetCharacterOptions(
|
||||
seq: 1u,
|
||||
options1: 0u,
|
||||
options2: 0u,
|
||||
shortcuts: Array.Empty<ShortcutEntry>(),
|
||||
favoriteSpells: favorites,
|
||||
desiredComponents: new Dictionary<uint, uint>(),
|
||||
spellbookFilters: 0u);
|
||||
|
||||
// Base header only: PM_Packed_8_SpellLists | SpellbookFilters | 2ndCharacterOptions.
|
||||
Assert.Equal(0x460u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
// envelope+seq+opcode(12) + header+options1(8) + 8 empty lists(32)
|
||||
// + spellbookFilters+options2(8).
|
||||
Assert.Equal(12 + 8 + 32 + 8, body.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSetCharacterOptions_RequiresExactlyEightFavoriteSpellLists()
|
||||
{
|
||||
IReadOnlyList<uint>[] tooFew = [Array.Empty<uint>(), Array.Empty<uint>()];
|
||||
|
||||
Assert.Throws<ArgumentException>(() => SocialActions.BuildSetCharacterOptions(
|
||||
seq: 1u,
|
||||
options1: 0u,
|
||||
options2: 0u,
|
||||
shortcuts: Array.Empty<ShortcutEntry>(),
|
||||
favoriteSpells: tooFew,
|
||||
desiredComponents: new Dictionary<uint, uint>(),
|
||||
spellbookFilters: 0u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSetCharacterOptions_RoundTripsThroughPlayerDescriptionParser()
|
||||
{
|
||||
ShortcutEntry[] shortcuts =
|
||||
[
|
||||
new ShortcutEntry(3, 0x70000010u, 0u),
|
||||
new ShortcutEntry(4, 0x70000011u, 5u),
|
||||
];
|
||||
IReadOnlyList<uint>[] favorites = new IReadOnlyList<uint>[8];
|
||||
favorites[0] = new uint[] { 111u, 222u };
|
||||
for (int tab = 1; tab < 8; tab++)
|
||||
favorites[tab] = Array.Empty<uint>();
|
||||
var desiredComponents = new Dictionary<uint, uint>
|
||||
{
|
||||
[0x68000002u] = 3u,
|
||||
[0x68000003u] = 7u,
|
||||
};
|
||||
|
||||
byte[] body = SocialActions.BuildSetCharacterOptions(
|
||||
seq: 9u,
|
||||
options1: 0x12345678u,
|
||||
options2: 0x0000ABCDu,
|
||||
shortcuts: shortcuts,
|
||||
favoriteSpells: favorites,
|
||||
desiredComponents: desiredComponents,
|
||||
spellbookFilters: 0x1234u);
|
||||
|
||||
// Strip the 12-byte envelope/seq/opcode — PlayerModule::Pack's own
|
||||
// payload starts at `header`, which is exactly where
|
||||
// PlayerDescriptionParser's trailer starts reading too (wire
|
||||
// research §2.6: ACE's PlayerDescription trailer reader and
|
||||
// PlayerModule::Pack agree field-for-field). Prefix a minimal empty
|
||||
// PlayerDescription header (propertyFlags=0, weenieType=0,
|
||||
// vectorFlags=0, hasHealth=0) so the parser walks straight into it.
|
||||
byte[] packPayload = body[12..];
|
||||
byte[] syntheticPlayerDescription = new byte[16 + packPayload.Length];
|
||||
packPayload.CopyTo(syntheticPlayerDescription, 16);
|
||||
|
||||
PlayerDescriptionParser.Parsed? parsed =
|
||||
PlayerDescriptionParser.TryParse(syntheticPlayerDescription);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.False(parsed!.Value.TrailerTruncated);
|
||||
Assert.Equal(0x12345678u, parsed.Value.Options1);
|
||||
Assert.Equal(0x0000ABCDu, parsed.Value.Options2);
|
||||
Assert.Equal(0x1234u, parsed.Value.SpellbookFilters);
|
||||
Assert.Equal(shortcuts, parsed.Value.Shortcuts);
|
||||
Assert.Equal(8, parsed.Value.HotbarSpells.Count);
|
||||
Assert.Equal(new uint[] { 111u, 222u }, parsed.Value.HotbarSpells[0]);
|
||||
for (int tab = 1; tab < 8; tab++)
|
||||
Assert.Empty(parsed.Value.HotbarSpells[tab]);
|
||||
Assert.Equal(2, parsed.Value.DesiredComps.Count);
|
||||
Assert.Contains((0x68000002u, 3u), parsed.Value.DesiredComps);
|
||||
Assert.Contains((0x68000003u, 7u), parsed.Value.DesiredComps);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP1 (2026-08-10) conformance for
|
||||
/// <see cref="CharacterOptionTable"/> — table completeness across the
|
||||
/// complete <c>0x00..0x34</c> id space, the auto-save split pinned
|
||||
/// id-by-id against
|
||||
/// docs/research/2026-08-10-set-character-options-wire.md §3.2's
|
||||
/// byte-verified table, the client-Defaults split against §8.2's, and
|
||||
/// unknown-id rejection (ACE throws on an unmodeled id — one must never
|
||||
/// reach the wire, wire doc §5.4.2).
|
||||
/// </summary>
|
||||
public sealed class CharacterOptionTableTests
|
||||
{
|
||||
// wire research §3.2 — CPlayerModule::IsAutoSaveOption @0x0059A600,
|
||||
// byte-verified 0x34-byte table at VA 0x0059A62C. 21 ids.
|
||||
private static readonly CharacterOptionId[] AutoSaveIds =
|
||||
[
|
||||
CharacterOptionId.AutoRepeatAttack,
|
||||
CharacterOptionId.IgnoreAllegianceRequests,
|
||||
CharacterOptionId.IgnoreFellowshipRequests,
|
||||
CharacterOptionId.FellowshipShareXP,
|
||||
CharacterOptionId.AcceptLootPermits,
|
||||
CharacterOptionId.FellowshipShareLoot,
|
||||
CharacterOptionId.FellowshipAutoAcceptRequests,
|
||||
CharacterOptionId.UseChargeAttack,
|
||||
CharacterOptionId.ListenToAllegianceChat,
|
||||
CharacterOptionId.ListenToGeneralChat,
|
||||
CharacterOptionId.ListenToTradeChat,
|
||||
CharacterOptionId.ListenToLFGChat,
|
||||
CharacterOptionId.ListenToRoleplayChat,
|
||||
CharacterOptionId.AppearOffline,
|
||||
CharacterOptionId.LeadMissileTargets,
|
||||
CharacterOptionId.UseFastMissiles,
|
||||
CharacterOptionId.ListenToSocietyChat,
|
||||
CharacterOptionId.ShowHelm,
|
||||
CharacterOptionId.UseMouseTurning,
|
||||
CharacterOptionId.ShowCloak,
|
||||
CharacterOptionId.LockUI,
|
||||
];
|
||||
|
||||
// wire research §8.2 — PlayerModule::GetDefaultOptionValue @0x005D2A30,
|
||||
// byte-verified 0x2B-byte table at VA 0x005D2A5C. 16 default-ON ids
|
||||
// (every id past 0x2A falls off the end of that table and defaults to
|
||||
// false, even though 3 of them are ON in the raw constructor word —
|
||||
// see the divergence register row, D3/OP1).
|
||||
private static readonly CharacterOptionId[] ClientDefaultOnIds =
|
||||
[
|
||||
CharacterOptionId.AutoRepeatAttack,
|
||||
CharacterOptionId.IgnoreFellowshipRequests,
|
||||
CharacterOptionId.AllowGive,
|
||||
CharacterOptionId.ShowTooltips,
|
||||
CharacterOptionId.ToggleRun,
|
||||
CharacterOptionId.AutoTarget,
|
||||
CharacterOptionId.VividTargetingIndicator,
|
||||
CharacterOptionId.FellowshipShareXP,
|
||||
CharacterOptionId.CoordinatesOnRadar,
|
||||
CharacterOptionId.SpellDuration,
|
||||
CharacterOptionId.UseChargeAttack,
|
||||
CharacterOptionId.ListenToAllegianceChat,
|
||||
CharacterOptionId.ListenToGeneralChat,
|
||||
CharacterOptionId.ListenToTradeChat,
|
||||
CharacterOptionId.ListenToLFGChat,
|
||||
CharacterOptionId.LeadMissileTargets,
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void All_HasExactly53Entries_Ids0x00Through0x34Contiguous()
|
||||
{
|
||||
CharacterOptionTableEntry[] all = [.. CharacterOptionTable.All];
|
||||
|
||||
Assert.Equal(53, all.Length);
|
||||
for (uint id = 0x00; id <= 0x34; id++)
|
||||
{
|
||||
Assert.True(
|
||||
CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry),
|
||||
$"id 0x{id:X2} missing from CharacterOptionTable");
|
||||
Assert.Equal(id, (uint)entry.Id);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllModeledIds))]
|
||||
public void IsAutoSave_MatchesByteVerifiedSplit(CharacterOptionId id)
|
||||
{
|
||||
bool expected = Array.IndexOf(AutoSaveIds, id) >= 0;
|
||||
|
||||
Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry));
|
||||
Assert.Equal(expected, entry.IsAutoSave);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllModeledIds))]
|
||||
public void ClientDefault_MatchesByteVerifiedSplit(CharacterOptionId id)
|
||||
{
|
||||
bool expected = Array.IndexOf(ClientDefaultOnIds, id) >= 0;
|
||||
|
||||
Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry));
|
||||
Assert.Equal(expected, entry.ClientDefault);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoSaveIds_CountIs21()
|
||||
{
|
||||
Assert.Equal(21, AutoSaveIds.Length);
|
||||
Assert.Equal(21, CharacterOptionTable.All.Count(static e => e.IsAutoSave));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientDefaultOnIds_CountIs16()
|
||||
{
|
||||
Assert.Equal(16, ClientDefaultOnIds.Length);
|
||||
Assert.Equal(16, CharacterOptionTable.All.Count(static e => e.ClientDefault));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReconstructedClientDefaultWords_MatchIndependentlyConfirmedConstants()
|
||||
{
|
||||
// wire research §1.4/§2.5: OR'ing every ClientDefault=true row's mask
|
||||
// into its own word reconstructs EXACTLY CharacterOptions1.Default
|
||||
// (0x50C4A54A, also the retail constructor literal) for Options1,
|
||||
// and 0x00008700 for Options2 — the client Defaults-button value,
|
||||
// deliberately NOT the same as the raw constructor default
|
||||
// (0x00948700, RuntimeCharacterOptionsState.DefaultOptions2) because
|
||||
// GetDefaultOptionValue's table stops at id 0x2A.
|
||||
uint options1 = 0u;
|
||||
uint options2 = 0u;
|
||||
foreach (CharacterOptionTableEntry entry in CharacterOptionTable.All)
|
||||
{
|
||||
if (!entry.ClientDefault) continue;
|
||||
if (entry.IsOptions1) options1 |= entry.Mask;
|
||||
else options2 |= entry.Mask;
|
||||
}
|
||||
|
||||
Assert.Equal(0x50C4A54Au, options1);
|
||||
Assert.Equal(0x00008700u, options2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x35u)] // CharacterOptions1Default — the WHOLE default mask, not a real option
|
||||
[InlineData(0x36u)] // CharacterOptions2Default — same landmine, other word
|
||||
[InlineData(0xFFFFu)]
|
||||
[InlineData(0xFFFFFFFFu)]
|
||||
public void TryGet_RejectsUnknownAndReservedIds(uint optionId)
|
||||
{
|
||||
Assert.False(CharacterOptionTable.TryGet(optionId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotCheck_WordAndMaskAgainstVerbatimAcclientEnums()
|
||||
{
|
||||
// acclient.h:3404-3436 `enum CharacterOption` / :3451-3481
|
||||
// `enum CharacterOptions2`.
|
||||
Assert.True(CharacterOptionTable.TryGet(
|
||||
CharacterOptionId.AutoRepeatAttack, out CharacterOptionTableEntry autoRepeat));
|
||||
Assert.True(autoRepeat.IsOptions1);
|
||||
Assert.Equal(0x00000002u, autoRepeat.Mask);
|
||||
|
||||
// PersistentAtDay (id 0x05) lives in Options2 despite its low id —
|
||||
// acclient.h:3454 `PersistentAtDay_CharacterOptions2 = 0x1`.
|
||||
Assert.True(CharacterOptionTable.TryGet(
|
||||
CharacterOptionId.PersistentAtDay, out CharacterOptionTableEntry persistentAtDay));
|
||||
Assert.False(persistentAtDay.IsOptions1);
|
||||
Assert.Equal(0x00000001u, persistentAtDay.Mask);
|
||||
|
||||
Assert.True(CharacterOptionTable.TryGet(
|
||||
CharacterOptionId.ListenToAllegianceChat, out CharacterOptionTableEntry allegiance));
|
||||
Assert.True(allegiance.IsOptions1);
|
||||
Assert.Equal(0x40000000u, allegiance.Mask);
|
||||
|
||||
// HearPkDeathMessages (0x34) — ACE-sourced, unverifiable against the
|
||||
// 2013 binary; register row.
|
||||
Assert.True(CharacterOptionTable.TryGet(
|
||||
CharacterOptionId.HearPkDeathMessages, out CharacterOptionTableEntry pkDeath));
|
||||
Assert.False(pkDeath.IsOptions1);
|
||||
Assert.Equal(0x02000000u, pkDeath.Mask);
|
||||
Assert.False(pkDeath.IsAutoSave);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> AllModeledIds()
|
||||
{
|
||||
for (uint id = 0x00; id <= 0x34; id++)
|
||||
yield return [(CharacterOptionId)id];
|
||||
}
|
||||
}
|
||||
|
|
@ -286,6 +286,167 @@ public sealed class RuntimeCharacterStateTests
|
|||
Assert.Equal(beforeRevision, options.Revision);
|
||||
}
|
||||
|
||||
// ── OP1 (Campaign OP, 2026-08-10): TrySetOption — the shared
|
||||
// local-write-then-send/dirty seam, + the dirty/flush state machine ────
|
||||
|
||||
[Fact]
|
||||
public void TrySetOption_AutoSaveId_WritesLocallyThenSendsImmediately_NeverDirties()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
options.Replace(options.Options1, 0u); // every Options2 Hear*Chat bit off
|
||||
var sent = new List<(uint OptionId, bool Value)>();
|
||||
|
||||
bool accepted = options.TrySetOption(
|
||||
(uint)CharacterOptionId.ListenToGeneralChat,
|
||||
true,
|
||||
sendAutoSave: () => sent.Add(
|
||||
((uint)CharacterOptionId.ListenToGeneralChat, true)));
|
||||
|
||||
Assert.True(accepted);
|
||||
Assert.Equal(
|
||||
(uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat,
|
||||
options.Options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat);
|
||||
Assert.Equal([((uint)CharacterOptionId.ListenToGeneralChat, true)], sent);
|
||||
Assert.False(options.IsDirty);
|
||||
Assert.Null(options.FirstDirtiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySetOption_BatchedId_WritesLocallyAndMarksDirty_NeverSends()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
var sent = new List<(uint OptionId, bool Value)>();
|
||||
|
||||
// AutoTarget (0x0D) is default-ON per CharacterOptionTable — flip it
|
||||
// off to exercise a real transition.
|
||||
bool accepted = options.TrySetOption(
|
||||
(uint)CharacterOptionId.AutoTarget,
|
||||
false,
|
||||
sendAutoSave: () => sent.Add(((uint)CharacterOptionId.AutoTarget, false)));
|
||||
|
||||
Assert.True(accepted);
|
||||
// AutoTarget_CharacterOption = 0x2000 (acclient.h:3417).
|
||||
Assert.Equal(0u, options.Options1 & 0x00002000u);
|
||||
Assert.Empty(sent);
|
||||
Assert.True(options.IsDirty);
|
||||
Assert.NotNull(options.FirstDirtiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySetOption_UnchangedValue_IsANoOp_MatchingRetailEarlyReturn()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
var sent = new List<(uint, bool)>();
|
||||
// AutoTarget defaults ON — re-asserting ON must be a no-op (retail:
|
||||
// an unchanged option produces no notice, no side effect, no send).
|
||||
bool accepted = options.TrySetOption(
|
||||
(uint)CharacterOptionId.AutoTarget,
|
||||
true,
|
||||
sendAutoSave: () => sent.Add(((uint)CharacterOptionId.AutoTarget, true)));
|
||||
|
||||
Assert.True(accepted);
|
||||
Assert.Empty(sent);
|
||||
Assert.False(options.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySetOption_UnknownId_ReturnsFalse_NeverInvokesCallback()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
bool invoked = false;
|
||||
|
||||
bool accepted = options.TrySetOption(0x35u, true, () => invoked = true);
|
||||
|
||||
Assert.False(accepted);
|
||||
Assert.False(invoked);
|
||||
Assert.False(options.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkDirty_OnlySecondCallDoesNotPushOutFirstDirtiedAt()
|
||||
{
|
||||
var clock = new ManualTimeProvider();
|
||||
var options = new RuntimeCharacterOptionsState(clock);
|
||||
|
||||
options.TrySetOption(
|
||||
(uint)CharacterOptionId.AutoTarget, false, () => { });
|
||||
DateTimeOffset? firstStamp = options.FirstDirtiedAt;
|
||||
Assert.NotNull(firstStamp);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(10));
|
||||
options.TrySetOption(
|
||||
(uint)CharacterOptionId.ShowTooltips, false, () => { });
|
||||
|
||||
Assert.Equal(firstStamp, options.FirstDirtiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFlush_NoOpWhenClean_FlushesAndClearsWhenDirty()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
int cleanFlushes = 0;
|
||||
Assert.False(options.TryFlush(() => cleanFlushes++));
|
||||
Assert.Equal(0, cleanFlushes);
|
||||
|
||||
options.TrySetOption(
|
||||
(uint)CharacterOptionId.AutoTarget, false, () => { });
|
||||
Assert.True(options.IsDirty);
|
||||
|
||||
int dirtyFlushes = 0;
|
||||
Assert.True(options.TryFlush(() => dirtyFlushes++));
|
||||
Assert.Equal(1, dirtyFlushes);
|
||||
Assert.False(options.IsDirty);
|
||||
Assert.Null(options.FirstDirtiedAt);
|
||||
|
||||
// A second flush on a now-clean module is a no-op — retail's
|
||||
// SaveToServer(force: 0) sends nothing for a clean module.
|
||||
Assert.False(options.TryFlush(() => dirtyFlushes++));
|
||||
Assert.Equal(1, dirtyFlushes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFlushIfAutoSaveDue_DoesNotFireBeforeThreshold_FiresAtThreshold()
|
||||
{
|
||||
var clock = new ManualTimeProvider();
|
||||
var options = new RuntimeCharacterOptionsState(clock);
|
||||
options.TrySetOption(
|
||||
(uint)CharacterOptionId.AutoTarget, false, () => { });
|
||||
|
||||
int flushes = 0;
|
||||
clock.Advance(RuntimeCharacterOptionsState.AutoSaveDelay - TimeSpan.FromSeconds(1));
|
||||
Assert.False(options.TryFlushIfAutoSaveDue(() => flushes++));
|
||||
Assert.True(options.IsDirty);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(1));
|
||||
Assert.True(options.TryFlushIfAutoSaveDue(() => flushes++));
|
||||
Assert.Equal(1, flushes);
|
||||
Assert.False(options.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSession_ClearsDirtyState()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
options.TrySetOption(
|
||||
(uint)CharacterOptionId.AutoTarget, false, () => { });
|
||||
Assert.True(options.IsDirty);
|
||||
|
||||
options.ResetSession();
|
||||
|
||||
Assert.False(options.IsDirty);
|
||||
Assert.Null(options.FirstDirtiedAt);
|
||||
}
|
||||
|
||||
private sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(2026, 8, 10, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan elapsed) => _now += elapsed;
|
||||
}
|
||||
|
||||
// ── Campaign P Slice P1 (2026-07-30): burden/stamina/vitae-adjusted ───
|
||||
// ── run/jump skill (pseudocode doc §9) ─────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -274,6 +274,156 @@ public sealed class DirectGameRuntimeCommandAdapterTests
|
|||
Assert.False(runtime.Session.IsInWorld);
|
||||
}
|
||||
|
||||
// ── OP1 (Campaign OP, 2026-08-10): the headless local-write-then-send
|
||||
// seam, SaveOptions, and unknown-id rejection ───────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SetSingleOption_AutoSaveId_WritesLocalBitBeforeTheWireSendFires()
|
||||
{
|
||||
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
|
||||
CreateStartedHarness();
|
||||
uint? options2AtSendTime = null;
|
||||
operations.Sessions[^1].GameActionCapture = _ =>
|
||||
options2AtSendTime ??= runtime.CharacterOwner.Options.Options2;
|
||||
|
||||
// Options2 default (0x00948700) has HearGeneralChat (0x100) ON;
|
||||
// toggling it OFF exercises the local-write-then-send ordering this
|
||||
// slice fixed on the headless path (lane B §4.4 / lane C §7.4).
|
||||
RuntimeCommandResult result = adapter.Character.SetSingleOption(
|
||||
runtime.Generation,
|
||||
(uint)CharacterOptionId.ListenToGeneralChat,
|
||||
false);
|
||||
|
||||
Assert.True(result.Accepted);
|
||||
Assert.NotNull(options2AtSendTime);
|
||||
Assert.Equal(0u, options2AtSendTime!.Value & 0x00000100u);
|
||||
Assert.Equal(
|
||||
0u,
|
||||
runtime.CharacterOwner.Options.Options2 & 0x00000100u);
|
||||
runtime.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetSingleOption_BatchedId_MarksDirtyWithoutSendingAnything()
|
||||
{
|
||||
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
|
||||
CreateStartedHarness();
|
||||
var gameActions = new List<byte[]>();
|
||||
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
|
||||
|
||||
// AutoTarget (0x0D) is batched, default ON — flip it off.
|
||||
RuntimeCommandResult result = adapter.Character.SetSingleOption(
|
||||
runtime.Generation,
|
||||
(uint)CharacterOptionId.AutoTarget,
|
||||
false);
|
||||
|
||||
Assert.True(result.Accepted);
|
||||
Assert.Empty(gameActions);
|
||||
Assert.True(runtime.CharacterOwner.Options.IsDirty);
|
||||
runtime.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetSingleOption_UnknownId_RejectsWithoutSendingAnything()
|
||||
{
|
||||
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
|
||||
CreateStartedHarness();
|
||||
var gameActions = new List<byte[]>();
|
||||
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
|
||||
|
||||
// 0x35 == CharacterOptions1Default — the whole-default-mask landmine
|
||||
// (wire research §5.4.3), never a real option.
|
||||
RuntimeCommandResult result = adapter.Character.SetSingleOption(
|
||||
runtime.Generation,
|
||||
0x35u,
|
||||
true);
|
||||
|
||||
Assert.Equal(RuntimeCommandStatus.Rejected, result.Status);
|
||||
Assert.Empty(gameActions);
|
||||
runtime.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveOptions_FlushesTheDirtyBlobThenNoOpsWhenClean()
|
||||
{
|
||||
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
|
||||
CreateStartedHarness();
|
||||
var gameActions = new List<byte[]>();
|
||||
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
|
||||
|
||||
adapter.Character.SetSingleOption(
|
||||
runtime.Generation, (uint)CharacterOptionId.AutoTarget, false);
|
||||
Assert.True(runtime.CharacterOwner.Options.IsDirty);
|
||||
Assert.Empty(gameActions);
|
||||
|
||||
RuntimeCommandResult saved = adapter.Character.SaveOptions(runtime.Generation);
|
||||
|
||||
Assert.True(saved.Accepted);
|
||||
Assert.False(runtime.CharacterOwner.Options.IsDirty);
|
||||
byte[] blob = Assert.Single(gameActions);
|
||||
Assert.Equal(
|
||||
SocialActions.SetCharacterOptionsOpcode,
|
||||
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
blob.AsSpan(8)));
|
||||
|
||||
// A clean module's second SaveOptions sends nothing more.
|
||||
RuntimeCommandResult savedAgain =
|
||||
adapter.Character.SaveOptions(runtime.Generation);
|
||||
Assert.True(savedAgain.Accepted);
|
||||
Assert.Single(gameActions);
|
||||
runtime.Dispose();
|
||||
}
|
||||
|
||||
private static (GameRuntime Runtime, DirectGameRuntimeCommandAdapter Adapter, FixtureSessionOperations Operations)
|
||||
CreateStartedHarness()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
var gameplay = new FixtureGameplayOperations();
|
||||
var runtime = new GameRuntime(new GameRuntimeDependencies(
|
||||
gameplay,
|
||||
gameplay,
|
||||
gameplay,
|
||||
gameplay,
|
||||
SessionOperations: operations));
|
||||
gameplay.Bind(runtime);
|
||||
var resetHost = new FixtureResetHost();
|
||||
DirectGameRuntimeCommandAdapter? adapter = null;
|
||||
LiveSessionConnectOptions options = new(
|
||||
true,
|
||||
"127.0.0.1",
|
||||
9000,
|
||||
"account",
|
||||
"password");
|
||||
var live = new LiveSessionHost(
|
||||
runtime.Session,
|
||||
new LiveSessionHostBindings(
|
||||
new LiveSessionRoutingFactories(
|
||||
_ => new FixtureEventRoute(),
|
||||
session => adapter!.CreateRoute(session)),
|
||||
generation => runtime.ResetGeneration(
|
||||
generation,
|
||||
resetHost),
|
||||
new LiveSessionSelectionBindings(
|
||||
id => runtime.PlayerIdentity.ServerGuid = id,
|
||||
_ => { },
|
||||
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
|
||||
_ => { },
|
||||
_ => { },
|
||||
runtime.ActionOwner.Combat.Clear),
|
||||
new LiveSessionEnteredWorldBindings(
|
||||
_ => { },
|
||||
() => { },
|
||||
() => { },
|
||||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
options);
|
||||
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
|
||||
_ = adapter.Session.Start(runtime.Generation);
|
||||
return (runtime, adapter, operations);
|
||||
}
|
||||
|
||||
private sealed class FixtureSessionOperations : ILiveSessionOperations
|
||||
{
|
||||
public List<WorldSession> Sessions { get; } = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue