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
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue