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:
Erik 2026-08-10 23:31:34 +02:00
parent b585d80e7a
commit 86c0a7e0ee
19 changed files with 1464 additions and 62 deletions

View file

@ -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)

View file

@ -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)]

View file

@ -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()
{

View file

@ -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);
}
}

View file

@ -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];
}
}

View file

@ -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) ─────────────────────────────

View file

@ -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; } = [];