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:
Erik 2026-08-11 03:19:35 +02:00
parent 386076af0f
commit 7b60e71b85
11 changed files with 1007 additions and 23 deletions

View file

@ -89,6 +89,82 @@ public sealed class HeadlessConfigurationLoaderTests
StringComparison.Ordinal);
}
[Fact]
public void NumericKeyFailsLoadInsteadOfAliasingIntoAnAllowedId()
{
// SF-1 (OP7 review fix, 2026-08-11): Enum.TryParse on a non-[Flags]
// enum accepts a decimal numeric string — "15" parsed to 0x0F
// (FellowshipShareXP), which IS allow-listed, so the old
// parsed-value check let a key that is not an enum-member spelling
// at all silently pass. Validation must reject the STRING.
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session(
"bot",
"BOT_PASSWORD",
"\"characterOptions\":{\"15\":true}")));
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains("15", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void CommaCombinedKeyFailsLoadInsteadOfOrCombiningIntoAnAllowedId()
{
// SF-1 companion case: Enum.TryParse OR-combines a comma-separated
// member list — "ToggleRun,AutoTarget" (0x0A | 0x0D) parsed to
// 0x0F (FellowshipShareXP), again allow-listed, so a config that
// reads as two movement options would have silently set fellowship
// XP sharing instead.
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session(
"bot",
"BOT_PASSWORD",
"\"characterOptions\":{\"ToggleRun,AutoTarget\":true}")));
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains(
"ToggleRun,AutoTarget",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void BothFellowshipExclusionOptionsTrueFailsLoadNamingBothKeys()
{
// SF-2 (OP7 review fix, 2026-08-11): retail's own OnChanged mutual
// exclusion means IgnoreFellowshipRequests and
// FellowshipAutoAcceptRequests can never both be true at once —
// turning one on always clears the other. A config declaring both
// true would have the seeder oscillate (re-send) on every connect
// forever with neither value ever actually honoured. Reject it at
// load instead.
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session(
"bot",
"BOT_PASSWORD",
"\"characterOptions\":{\"IgnoreFellowshipRequests\":true,"
+ "\"FellowshipAutoAcceptRequests\":true}")));
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains(
"IgnoreFellowshipRequests",
exception.Message,
StringComparison.Ordinal);
Assert.Contains(
"FellowshipAutoAcceptRequests",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void NonBoolValueFailsLoad()
{

View file

@ -133,6 +133,140 @@ public sealed class HeadlessSessionHostTests
diagnostics.ToString());
}
[Fact]
public async Task DirectCredentialsPreserveDeclaredCharacterOptions()
{
// MF-1 (Campaign OP OP7 review fix, 2026-08-11): the K3 direct-CLI
// launch mode (--user/--password) rebuilds the configured session
// descriptor via HeadlessProcessHost.WithAccount before constructing
// HeadlessSessionHost. A hand-copied clone that forgets a property
// silently drops that feature for this launch mode with no error —
// exactly what happened to CharacterOptions. Assert the declared
// block survives the direct-credential path by checking the
// constructed session's seeder actually saw it.
var configuration = new HeadlessConfiguration
{
Version = 1,
Sessions =
[
Descriptor(characterOptions: new Dictionary<string, bool>
{
["AutoRepeatAttack"] = true,
}),
],
};
HeadlessPathSet paths = HeadlessPathSet.Resolve(
new HeadlessPathOverrides());
using var diagnostics = new StringWriter();
var operations = new FixtureSessionOperations();
using var host = new HeadlessProcessHost(
configuration,
paths,
TextReader.Null,
diagnostics,
operations,
directCredentials: new HeadlessDirectCredentials(
"direct-account",
"direct-secret"));
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
HeadlessExitCode result =
await host.RunAsync(cancellation.Token);
Assert.Equal(HeadlessExitCode.Success, result);
Assert.Equal("direct-account", operations.LastUser);
Assert.NotNull(host.Session.OptionsSeeder);
Assert.True(host.Session.OptionsSeeder!.HasDeclaredOptions);
}
[Fact]
public void DirectFirstEntryCompletion_InvokesOnLoginCompleteSentHookThroughProductionWiring()
{
// SF-4 (Campaign OP OP7 review fix, 2026-08-11): the THIRD
// production LoginComplete->seeder hook lives entirely inside
// HeadlessSessionHost.CreateEventRoute's own closure (the direct,
// non-portal first-entry completion callback wired to
// RuntimeFirstEntryDriveController via HeadlessSessionEventRoute's
// localPlayerCompleted parameter) — driving it end-to-end would
// need a real DAT-backed collision fixture this test project does
// not have. Reflection reaches the SAME closure instance
// HeadlessSessionHost actually constructed during Start() (not a
// hand-rolled reconstruction of it) and invokes it exactly as
// RuntimeFirstEntryDriveController would on residence completion,
// then proves the seeder reacted through the real wire — the same
// "declared option actually sends" proof
// HeadlessCharacterOptionsSeederWiringTests uses for the other two
// sites.
var operations = new FixtureSessionOperations();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(characterOptions: new Dictionary<string, bool>
{
["IgnoreAllegianceRequests"] = true,
}),
credential,
new HeadlessDiagnosticWriter(TextWriter.Null),
operations);
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
Assert.NotNull(host.OptionsSeeder);
WorldSession session = operations.Sessions[^1];
var sent = new List<byte[]>();
session.GameActionCapture = body => sent.Add(body);
object eventRoute = typeof(HeadlessSessionHost)
.GetField(
"_eventRoute",
BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(host)
?? throw new InvalidOperationException(
"HeadlessSessionHost constructed no event route.");
var localPlayerCompleted =
(Action<RuntimeEntityRecord>?)typeof(HeadlessSessionEventRoute)
.GetField(
"_localPlayerCompleted",
BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(eventRoute);
Assert.NotNull(localPlayerCompleted);
const uint playerGuid = 0x50000009u;
host.Runtime.PlayerIdentity.ServerGuid = playerGuid;
RuntimeEntityRecord record = host.Runtime.EntityObjects
.RegisterEntity(Spawn(playerGuid))
.Canonical!;
// Invoke the PRODUCTION closure directly — proves
// HeadlessSessionHost really wires _optionsSeeder?.NoteLoginCompleteSent()
// into this callback, not merely that some test double does.
localPlayerCompleted!(record);
Assert.Contains(
sent,
body => body.SequenceEqual(GameActionLoginComplete.Build()));
// No PlayerDescription has landed yet — HasServerSeed is still
// false, so nothing beyond LoginComplete can have sent.
Assert.DoesNotContain(
sent,
body => ActionOpcode(body)
== SocialActions.SetSingleCharacterOptionOpcode);
session.GameEvents.Dispatch(
GameEventEnvelope.TryParse(
WrapPlayerDescriptionEnvelope(options1: 0u, options2: 0u))!
.Value);
Assert.Contains(
sent,
body => ActionOpcode(body)
== SocialActions.SetSingleCharacterOptionOpcode);
}
[Fact]
public void DirectFrameUsesSharedRetailOrderAndMovementCadence()
{
@ -1832,7 +1966,8 @@ public sealed class HeadlessSessionHostTests
private static HeadlessSessionDescriptor Descriptor(
HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment,
string credentialReference = "BOT_PASSWORD") => new()
string credentialReference = "BOT_PASSWORD",
Dictionary<string, bool>? characterOptions = null) => new()
{
Id = "bot",
Endpoint = new HeadlessEndpointDescriptor
@ -1854,6 +1989,7 @@ public sealed class HeadlessSessionHostTests
Provider = provider,
Reference = credentialReference,
},
CharacterOptions = characterOptions,
};
private static void HydrateGroundedPlayer(GameRuntime runtime)
@ -2458,6 +2594,43 @@ public sealed class HeadlessSessionHostTests
BinaryPrimitives.ReadUInt32LittleEndian(
body.AsSpan(8, sizeof(uint)));
// SF-4 fixture: minimal PlayerDescription (0x0013) body carrying only
// the CharacterOptions1/2 trailer fields — copied from
// HeadlessCharacterOptionsSeederWiringTests.WrapPlayerDescriptionEnvelope
// (mirrors GameEventWiringTests.WireAll_PlayerDescription_PublishesCharacterOptions's
// fixture layout).
private static byte[] WrapPlayerDescriptionEnvelope(
uint options1,
uint options2)
{
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(0x40u); // option flags: CharacterOptions2
writer.Write(options1);
writer.Write(0u); // legacy hotbar count
writer.Write(0u); // spellbook filters
writer.Write(options2);
writer.Write(0u); // inventory count
writer.Write(0u); // equipped count
}
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 sealed class FixtureSessionOperations : ILiveSessionOperations
{
public List<WorldSession> Sessions { get; } = [];

View file

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

View file

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