fix(headless,runtime): OP7 review fixes + docs: OP3 re-review REOPEN (narrow)
TWO work products share this commit (a staged-index collision between the
coordinator's docs commit and the OP7 fixer's staged files — content
verified complete and coherent; only this message was wrong before the
amend):
1. OP7 review fixes (all nine findings from
docs/research/2026-08-11-op7-review.md):
- M1: HeadlessSessionDescriptor is a record; WithAccount uses 'with' non-destructive record copy,
so a future property cannot be silently dropped; direct-CLI
regression test proves CharacterOptions survives --user/--password.
- M2 root fix: LiveSessionEventRouter skips BOTH Replace and the
options notification on a trailer-truncated PlayerDescription — a
truncated re-seed can no longer install zeroed words under an armed
latch for OP7's automation to flush into 0x01A1.
- SF1: schema keys validate as ordinal strings against the allowed
names (numeric / comma-combined aliases rejected). SF2: both-true
fellowship exclusion rejected at load, naming both keys. SF3: the
onLoginCompleteSent observer moved after transit.EndTeleport().
SF4: production-hook coverage for all three LoginComplete sites.
SF5: test-script OP7 wire expectation corrected (batched ids ride
only the 0x01A1).
2. docs/research/2026-08-11-op3-rereview.md — OP3 re-review verdict
REOPEN (narrow): M1 byte-decode independently re-verified (6a 07 at
all six sites); residuals R1 (gate script promises a timestamp prefix
acdream doesn't render), R2 (null-controller player-mode still
refuses), R3 (dormancy pin lacks stimulus) — coordinator fixes follow.
Full Release suite at this tree: 12,956 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
386076af0f
commit
7b60e71b85
11 changed files with 1007 additions and 23 deletions
|
|
@ -290,6 +290,107 @@ public sealed class LiveSessionEventRouterTests
|
|||
router.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayerDescription_TrailerTruncatedReSeed_LeavesWordsAndLatchUnchangedAndDoesNotNotify()
|
||||
{
|
||||
// MF-2 (Campaign OP OP7 review fix, 2026-08-11): a re-seed whose
|
||||
// trailer truncates mid-parse must install NOTHING and notify NO
|
||||
// ONE — the placeholder zero words are not server truth, and the
|
||||
// prior good seed (real words, HasServerSeed already armed) must
|
||||
// survive untouched. Previously the router installed the zeroed
|
||||
// options2 AND fired OnCharacterOptionsChanged unconditionally,
|
||||
// letting a downstream seeder diff against zero and flush it.
|
||||
using var session = NewSession();
|
||||
var character = new RuntimeCharacterState();
|
||||
var observed = new List<(uint Options1, uint Options2)>();
|
||||
|
||||
var router = new LiveSessionEventRouter(
|
||||
session,
|
||||
NoOpEntitySink(),
|
||||
NoOpEnvironmentSink(),
|
||||
NewInventoryBindings(),
|
||||
new LiveCharacterSessionBindings(
|
||||
new CombatState(),
|
||||
character,
|
||||
ResolveSkillFormulaBonus: null,
|
||||
OnSkillsUpdated: null,
|
||||
OnConfirmationRequest: null,
|
||||
OnConfirmationDone: null,
|
||||
ClientTime: () => 0d,
|
||||
OnCharacterOptionsChanged: (options1, options2) =>
|
||||
observed.Add((options1, options2))),
|
||||
NewSocialBindings());
|
||||
router.Attach();
|
||||
|
||||
// Complete seed first — real words, latch arms.
|
||||
session.GameEvents.Dispatch(
|
||||
GameEventEnvelope.TryParse(
|
||||
WrapPlayerDescriptionEnvelope(0x50C4A54Au, 0x00948700u))!.Value);
|
||||
Assert.Single(observed);
|
||||
Assert.True(character.Options.HasServerSeed);
|
||||
Assert.Equal(0x50C4A54Au, character.Options.Options1);
|
||||
Assert.Equal(0x00948700u, character.Options.Options2);
|
||||
|
||||
// Truncated re-seed — options1 reads early (real-looking value),
|
||||
// the trailer then throws before options2 is ever read.
|
||||
session.GameEvents.Dispatch(
|
||||
GameEventEnvelope.TryParse(
|
||||
WrapTruncatedPlayerDescriptionEnvelope(0xDEADBEEFu))!.Value);
|
||||
|
||||
// No second notification, words untouched, latch still armed
|
||||
// (from the earlier GOOD seed, not from this truncated one).
|
||||
Assert.Single(observed);
|
||||
Assert.True(character.Options.HasServerSeed);
|
||||
Assert.Equal(0x50C4A54Au, character.Options.Options1);
|
||||
Assert.Equal(0x00948700u, character.Options.Options2);
|
||||
|
||||
router.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayerDescription_TrailerTruncatedFirstSeed_LeavesDefaultsAndNeverArmsLatch()
|
||||
{
|
||||
// MF-2 companion case: a truncated PlayerDescription that is the
|
||||
// FIRST one a session ever sees must leave the client-constructor
|
||||
// defaults in place and never arm HasServerSeed — a later flush
|
||||
// stays refused exactly as if no PlayerDescription had arrived.
|
||||
using var session = NewSession();
|
||||
var character = new RuntimeCharacterState();
|
||||
var observed = new List<(uint Options1, uint Options2)>();
|
||||
|
||||
var router = new LiveSessionEventRouter(
|
||||
session,
|
||||
NoOpEntitySink(),
|
||||
NoOpEnvironmentSink(),
|
||||
NewInventoryBindings(),
|
||||
new LiveCharacterSessionBindings(
|
||||
new CombatState(),
|
||||
character,
|
||||
ResolveSkillFormulaBonus: null,
|
||||
OnSkillsUpdated: null,
|
||||
OnConfirmationRequest: null,
|
||||
OnConfirmationDone: null,
|
||||
ClientTime: () => 0d,
|
||||
OnCharacterOptionsChanged: (options1, options2) =>
|
||||
observed.Add((options1, options2))),
|
||||
NewSocialBindings());
|
||||
router.Attach();
|
||||
|
||||
uint defaultOptions1 = character.Options.Options1;
|
||||
uint defaultOptions2 = character.Options.Options2;
|
||||
|
||||
session.GameEvents.Dispatch(
|
||||
GameEventEnvelope.TryParse(
|
||||
WrapTruncatedPlayerDescriptionEnvelope(0xDEADBEEFu))!.Value);
|
||||
|
||||
Assert.Empty(observed);
|
||||
Assert.False(character.Options.HasServerSeed);
|
||||
Assert.Equal(defaultOptions1, character.Options.Options1);
|
||||
Assert.Equal(defaultOptions2, character.Options.Options2);
|
||||
|
||||
router.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter()
|
||||
{
|
||||
|
|
@ -705,6 +806,37 @@ public sealed class LiveSessionEventRouterTests
|
|||
return body;
|
||||
}
|
||||
|
||||
// MF-2 fixture: a PlayerDescription whose trailer reads options1 (the
|
||||
// early field) then throws before ever reaching options2 — mirrors
|
||||
// PlayerDescriptionParserTests' truncated-shortcut-list fixture. An
|
||||
// unreasonable declared shortcut count trips the parser's own
|
||||
// FormatException guard immediately after options1 is read, so
|
||||
// TrailerTruncated comes back true with a real-looking options1 and a
|
||||
// never-populated (zero) options2 — exactly the shape MF-2 closes.
|
||||
private static byte[] WrapTruncatedPlayerDescriptionEnvelope(uint options1)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true))
|
||||
{
|
||||
writer.Write(0u); // property flags
|
||||
writer.Write(0x52u); // player weenie type
|
||||
writer.Write(0u); // vector flags
|
||||
writer.Write(0u); // has health
|
||||
writer.Write(0x01u); // option flags: Shortcut
|
||||
writer.Write(options1);
|
||||
writer.Write(1_000_000u); // claimed shortcut count — trips the >10_000 guard
|
||||
}
|
||||
|
||||
byte[] payload = stream.ToArray();
|
||||
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)GameEventType.PlayerDescription);
|
||||
Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static WorldSession NewSession() =>
|
||||
new(new IPEndPoint(IPAddress.Loopback, 9));
|
||||
|
||||
|
|
|
|||
|
|
@ -194,6 +194,105 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
Assert.Equal(0x01020001u, runtime.Portal.Snapshot.DestinationCell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectSinkContentLessSpawn_InvokesOnLoginCompleteSentHookExactlyOnce()
|
||||
{
|
||||
// SF-4 (Campaign OP OP7 review fix, 2026-08-11): the pre-existing
|
||||
// wiring tests drove the login-complete half only through
|
||||
// HeadlessSessionHost.OptionsSeeder's TEST seam, never through this
|
||||
// controller's own production onLoginCompleteSent call sites — a
|
||||
// future edit that dropped or misdirected the OnSpawned
|
||||
// content-less immediate-admission invoke (:199-201) would have
|
||||
// been invisible to the whole suite. Drives it with a counting
|
||||
// Action, matching the fix's "the seeder's TryRun latch sees the
|
||||
// LoginComplete half via the production path" requirement.
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
const uint playerGuid = 0x50000004u;
|
||||
runtime.PlayerIdentity.ServerGuid = playerGuid;
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9000),
|
||||
new FixtureTransport());
|
||||
session.GameActionCapture = _ => { };
|
||||
int hookInvocations = 0;
|
||||
var controller = new RuntimeLiveEntitySessionController(
|
||||
runtime,
|
||||
session,
|
||||
onLoginCompleteSent: () => hookInvocations++);
|
||||
LiveEntitySessionSink sink = controller.CreateSink();
|
||||
WorldSession.EntitySpawn spawn =
|
||||
Spawn(playerGuid, incarnation: 1);
|
||||
|
||||
sink.Spawned(spawn);
|
||||
Assert.Equal(1, hookInvocations);
|
||||
|
||||
// A repeat Create at the same incarnation must not re-fire the
|
||||
// hook — mirrors the pre-existing gameActions.Single() assertion
|
||||
// for the underlying LoginComplete send itself.
|
||||
sink.Spawned(spawn);
|
||||
Assert.Equal(1, hookInvocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectSinkPortalCompletion_InvokesOnLoginCompleteSentHookAfterTeleportEnds()
|
||||
{
|
||||
// SF-4 companion: covers TryAdvancePortalCompletion's own
|
||||
// onLoginCompleteSent invoke (the third production hook site lives
|
||||
// one level up in HeadlessSessionHost.CreateEventRoute's direct
|
||||
// first-entry callback, exercised at the host level). This also
|
||||
// doubles as the SF-3 ordering regression test: the hook body reads
|
||||
// TransitOwner state from INSIDE the callback, so if the invoke
|
||||
// ever regressed back to firing between the LoginComplete send and
|
||||
// transit.EndTeleport(), IsSessionIdle would observe false here.
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
const uint playerGuid = 0x50000005u;
|
||||
runtime.PlayerIdentity.ServerGuid = playerGuid;
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9000),
|
||||
new FixtureTransport());
|
||||
session.GameActionCapture = _ => { };
|
||||
int hookInvocations = 0;
|
||||
bool? sessionIdleAtSecondHookInvocation = null;
|
||||
var controller = new RuntimeLiveEntitySessionController(
|
||||
runtime,
|
||||
session,
|
||||
onLoginCompleteSent: () =>
|
||||
{
|
||||
hookInvocations++;
|
||||
if (hookInvocations == 2)
|
||||
{
|
||||
sessionIdleAtSecondHookInvocation =
|
||||
runtime.TransitOwner.CaptureOwnership().IsSessionIdle;
|
||||
}
|
||||
});
|
||||
LiveEntitySessionSink sink = controller.CreateSink();
|
||||
WorldSession.EntitySpawn spawn =
|
||||
Spawn(playerGuid, incarnation: 1);
|
||||
sink.Spawned(spawn);
|
||||
Assert.Equal(1, hookInvocations);
|
||||
|
||||
sink.TeleportStarted(1u);
|
||||
sink.PositionUpdated(new WorldSession.EntityPositionUpdate(
|
||||
playerGuid,
|
||||
spawn.Position!.Value with
|
||||
{
|
||||
LandblockId = 0x01020001u,
|
||||
PositionX = 30f,
|
||||
},
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 1,
|
||||
ForcePositionSequence: 0));
|
||||
|
||||
Assert.Equal(2, hookInvocations);
|
||||
Assert.True(runtime.Portal.Snapshot.Completed);
|
||||
Assert.True(sessionIdleAtSecondHookInvocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectSinkProjectsAcceptedLocalWorldStateThroughOneHostSeam()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue