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:
parent
b236a44279
commit
6f48e34152
6 changed files with 130 additions and 25 deletions
|
|
@ -1513,15 +1513,15 @@ public sealed class GameEventWiringTests
|
|||
public void WireAll_PlayerDescription_PublishesCharacterOptions()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
(uint Options1, uint Options2)? observed = null;
|
||||
(uint Options1, uint Options2, bool Truncated)? observed = null;
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
new ClientObjectTable(),
|
||||
new CombatState(),
|
||||
new Spellbook(),
|
||||
new ChatLog(),
|
||||
onCharacterOptions: (options1, options2) =>
|
||||
observed = (options1, options2));
|
||||
onCharacterOptions: (options1, options2, trailerTruncated) =>
|
||||
observed = (options1, options2, trailerTruncated));
|
||||
|
||||
var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream);
|
||||
|
|
@ -1541,7 +1541,7 @@ public sealed class GameEventWiringTests
|
|||
WrapEnvelope(GameEventType.PlayerDescription, stream.ToArray()));
|
||||
dispatcher.Dispatch(envelope!.Value);
|
||||
|
||||
Assert.Equal((0x50C4A54Au, 0x948700u), observed);
|
||||
Assert.Equal((0x50C4A54Au, 0x948700u, false), observed);
|
||||
}
|
||||
|
||||
private static byte[] BuildEnchantment(
|
||||
|
|
|
|||
|
|
@ -625,6 +625,59 @@ public sealed class RuntimeCharacterStateTests
|
|||
bool flushed = await flushTask.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.True(flushed);
|
||||
Assert.True(probeCompletedPromptly);
|
||||
|
||||
// R2 (OP1 re-review, 2026-08-11): the concurrent MarkDirty landed
|
||||
// DURING the callback, so the trailing generation-guarded clear must
|
||||
// NOT erase it — the module stays dirty and that change gets its own
|
||||
// later flush instead of being silently lost.
|
||||
Assert.True(options.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFlush_KeepsModuleDirty_WhenADirtyingChangeLandsInsideTheCallback()
|
||||
{
|
||||
// R2's deterministic same-thread shape (also the re-review's NOTE-6
|
||||
// re-entrancy case): a MarkDirty issued from INSIDE the flush
|
||||
// callback must survive the trailing clear via the generation token.
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
options.Replace(options.Options1, options.Options2);
|
||||
options.TrySetOption(
|
||||
(uint)CharacterOptionId.AutoTarget, false, (_, _) => { });
|
||||
Assert.True(options.IsDirty);
|
||||
|
||||
Assert.True(options.TryFlush(options.MarkDirty));
|
||||
Assert.True(options.IsDirty);
|
||||
|
||||
// The retained dirty state flushes normally afterwards.
|
||||
Assert.True(options.TryFlush(() => { }));
|
||||
Assert.False(options.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replace_WithoutServerSeedArming_DoesNotAuthorizeFlush()
|
||||
{
|
||||
// R3 (OP1 re-review, 2026-08-11): a trailer-truncated
|
||||
// PlayerDescription carries zero placeholder option words — its
|
||||
// Replace installs the words but must NOT arm the flush gate.
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
options.Replace(0u, 0u, armServerSeed: false);
|
||||
Assert.False(options.HasServerSeed);
|
||||
|
||||
// Words are zero after the truncated seed, so flipping TO true is the
|
||||
// change (false would hit retail's unchanged-value no-op).
|
||||
options.TrySetOption(
|
||||
(uint)CharacterOptionId.AutoTarget, true, (_, _) => { });
|
||||
Assert.True(options.IsDirty);
|
||||
Assert.False(options.TryFlush(() => throw new InvalidOperationException(
|
||||
"a truncated-trailer seed must never authorize a blob flush")));
|
||||
|
||||
// A later COMPLETE PlayerDescription arms the gate; after re-dirtying,
|
||||
// the flush proceeds.
|
||||
options.Replace(0x50C4A54Au, 0x00948700u);
|
||||
Assert.True(options.HasServerSeed);
|
||||
options.TrySetOption(
|
||||
(uint)CharacterOptionId.AutoTarget, false, (_, _) => { });
|
||||
Assert.True(options.TryFlush(() => { }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue