fix(runtime,net): OP1 re-review residuals R1/R2/R3 (coordinator pass)

R1: the K4-load-bearing IsDirty pre-check moves into GameRuntime's two
once-allocated hook lambdas — SendBlob's closure environment is allocated
in FlushCharacterOptions's PROLOGUE, ahead of any guard inside the body,
so the clean-tick fast path must never enter the method at all. The body
keeps its check as idempotent defense only; the doc comment now describes
the real mechanism instead of overclaiming.

R2: RuntimeCharacterOptionsState gains a dirty-generation token. MarkDirty
bumps it on EVERY call (including while already dirty); TryFlush /
TryFlushIfAutoSaveDue capture it before invoking the callback and only
clear IsDirty when it is unchanged after — a dirtying change landing
DURING a flush (cross-thread, or re-entrant from the callback itself,
the re-review's NOTE-6 case) now stays dirty and flushes on its own later
trigger instead of being silently erased by the trailing clear. The S2
interleaving test now asserts the retained dirty state it previously
ignored; a deterministic re-entrancy test pins the same-thread shape.

R3: a trailer-truncated PlayerDescription parse carries zero placeholder
option words, not server truth — GameEventWiring now forwards
TrailerTruncated, LiveSessionEventRouter passes armServerSeed:
!trailerTruncated, and Replace withholds the 0x01A1 flush authorization
for truncated seeds while still installing the words (pre-existing local
behavior unchanged). Newly wire-reaching via the R1/MF-1 timer, hence
closed now rather than left a NOTE.

Full Release suite: 12,870 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 01:13:57 +02:00
parent b236a44279
commit 6f48e34152
6 changed files with 130 additions and 25 deletions

View file

@ -657,6 +657,17 @@ public sealed class RuntimeCharacterOptionsState
private readonly TimeProvider _timeProvider;
private readonly object _dirtyGate = new();
/// <summary>
/// R2 (OP1 re-review, 2026-08-11): bumped on EVERY <see cref="MarkDirty"/>
/// call — including ones that arrive while already dirty. A flush captures
/// the generation before invoking its callback and only clears
/// <see cref="IsDirty"/> when the generation is unchanged after, so a
/// concurrent dirtying change that lands DURING the callback keeps the
/// module dirty and gets its own later flush instead of being silently
/// erased by the trailing clear.
/// </summary>
private long _dirtyGeneration;
private uint _options1 = DefaultOptions1;
private uint _options2 = DefaultOptions2;
private long _revision;
@ -723,7 +734,16 @@ public sealed class RuntimeCharacterOptionsState
/// wholesale overwrite would let a stale pending-save appear to persist
/// a change that no longer exists locally.
/// </summary>
public void Replace(uint options1, uint options2)
/// <param name="options1">The PlayerDescription's CharacterOptions1 word.</param>
/// <param name="options2">The PlayerDescription's CharacterOptions2 word.</param>
/// <param name="armServerSeed">R3 (OP1 re-review, 2026-08-11): pass
/// <c>false</c> for a TRAILER-TRUNCATED PlayerDescription parse — its
/// option words are the parser's zero placeholders, not server truth, and
/// arming the flush gate on them would let the timer ship zeroed words
/// over the character's real options (the exact wipe class the latch
/// closes). The words still install (pre-existing local behavior); only
/// the flush authorization is withheld.</param>
public void Replace(uint options1, uint options2, bool armServerSeed = true)
{
Volatile.Write(ref _options1, options1);
Volatile.Write(ref _options2, options2);
@ -731,7 +751,8 @@ public sealed class RuntimeCharacterOptionsState
lock (_dirtyGate)
{
_isDirty = false;
_hasServerSeed = true;
if (armServerSeed)
_hasServerSeed = true;
}
}
@ -865,6 +886,11 @@ public sealed class RuntimeCharacterOptionsState
{
lock (_dirtyGate)
{
// The generation bumps on EVERY call (R2) — an in-flight flush's
// trailing clear compares generations, so a change arriving while
// the callback runs stays dirty. The timer stamp still belongs to
// the FIRST dirtying change only (retail's UseTime model).
_dirtyGeneration++;
if (_isDirty) return;
_isDirty = true;
_firstDirtiedAt = _timeProvider.GetUtcNow();
@ -896,14 +922,20 @@ public sealed class RuntimeCharacterOptionsState
public bool TryFlush(Action flush)
{
ArgumentNullException.ThrowIfNull(flush);
long observedGeneration;
lock (_dirtyGate)
{
if (!_isDirty || !_hasServerSeed) return false;
observedGeneration = _dirtyGeneration;
}
flush();
lock (_dirtyGate)
{
_isDirty = false;
// R2: only clear when no dirtying change landed during the
// callback — otherwise the newer change keeps the module dirty
// and flushes on its own later trigger.
if (_dirtyGeneration == observedGeneration)
_isDirty = false;
}
return true;
}
@ -920,15 +952,19 @@ public sealed class RuntimeCharacterOptionsState
public bool TryFlushIfAutoSaveDue(Action flush)
{
ArgumentNullException.ThrowIfNull(flush);
long observedGeneration;
lock (_dirtyGate)
{
if (!_isDirty || !_hasServerSeed) return false;
if (_timeProvider.GetUtcNow() - _firstDirtiedAt < AutoSaveDelay) return false;
observedGeneration = _dirtyGeneration;
}
flush();
lock (_dirtyGate)
{
_isDirty = false;
// R2: same generation-guarded clear as TryFlush.
if (_dirtyGeneration == observedGeneration)
_isDirty = false;
}
return true;
}