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

@ -29,7 +29,13 @@ internal sealed class HeadlessContentDescriptor
public string PreparedAssetPath { get; init; } = string.Empty;
}
internal sealed class HeadlessSessionDescriptor
// MF-1 (Campaign OP OP7 review fix, 2026-08-11): record, not class — the
// direct-CLI launch path (HeadlessProcessHost.WithAccount) needs a `with`
// expression so adding a future property can't silently drop it from a
// hand-copied clone the way `CharacterOptions` was dropped here (the K3
// direct-credential launch mode reached HeadlessSessionHost with
// CharacterOptions == null, silently no-op'ing the whole OP7 feature).
internal sealed record HeadlessSessionDescriptor
{
[JsonRequired]
public string Id { get; init; } = string.Empty;

View file

@ -56,6 +56,26 @@ internal static class HeadlessConfigurationLoader
CharacterOptionId.SalvageMultiple,
];
/// <summary>
/// SF-1 (Campaign OP OP7 review fix, 2026-08-11): the exact allow-listed
/// SPELLINGS, checked ordinally against the raw JSON key. Validating
/// against <c>Enum.TryParse</c>'s parsed VALUE instead let a key that is
/// not an allow-listed spelling at all reach the wire under a different
/// id — <c>Enum.TryParse</c> on a non-<c>[Flags]</c> enum still accepts a
/// decimal numeric string (<c>"15"</c> parses to <c>0x0F</c>) and a
/// comma-separated list of member names, OR-combined
/// (<c>"ToggleRun,AutoTarget"</c> = <c>0x0A | 0x0D</c> = <c>0x0F</c>) —
/// both alias into <see cref="CharacterOptionId.FellowshipShareXP"/>,
/// which is itself allow-listed, so the wrong id silently passed. Keying
/// off <c>id.ToString()</c> keeps this set in exact lockstep with
/// <see cref="AllowedCharacterOptions"/> with no separate literal list to
/// drift out of sync.
/// </summary>
private static readonly HashSet<string> AllowedCharacterOptionNames =
new(
AllowedCharacterOptions.Select(static id => id.ToString()),
StringComparer.Ordinal);
private static readonly JsonSerializerOptions Options = new()
{
AllowTrailingCommas = false,
@ -207,6 +227,11 @@ internal static class HeadlessConfigurationLoader
/// every other type-shape violation this loader lets the deserializer
/// reject directly — <see cref="HeadlessConfigurationException"/> is
/// reserved for semantic validation of already-well-typed values).
/// SF-1 (OP7 review fix, 2026-08-11): validates the raw JSON key STRING
/// ordinally against <see cref="AllowedCharacterOptionNames"/> — never
/// <c>Enum.TryParse</c>'s parsed value, which accepts numeric strings and
/// comma-combined member lists that are not allow-listed spellings at
/// all (see that field's own doc).
/// </summary>
private static void ValidateCharacterOptions(HeadlessSessionDescriptor session)
{
@ -215,8 +240,7 @@ internal static class HeadlessConfigurationLoader
foreach (string name in declared.Keys)
{
if (!Enum.TryParse(name, ignoreCase: false, out CharacterOptionId id)
|| !AllowedCharacterOptions.Contains(id))
if (!AllowedCharacterOptionNames.Contains(name))
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' characterOptions declares "
@ -224,5 +248,29 @@ internal static class HeadlessConfigurationLoader
+ "option name.");
}
}
// SF-2 (OP7 review fix, 2026-08-11): retail's own OnChanged mutual
// exclusion (RuntimeCharacterState.TrySetOption's MF-2 recursive
// clear) makes IgnoreFellowshipRequests and
// FellowshipAutoAcceptRequests both-true unsatisfiable — turning one
// ON always clears the other. A config declaring both true would
// have the seeder re-diff and re-send on every single connect
// forever, with neither declared value ever actually honoured.
// Reject the contradiction at load, before it can reach the wire.
if (declared.TryGetValue(
nameof(CharacterOptionId.IgnoreFellowshipRequests), out bool ignoreFellowship)
&& ignoreFellowship
&& declared.TryGetValue(
nameof(CharacterOptionId.FellowshipAutoAcceptRequests), out bool autoAcceptFellowship)
&& autoAcceptFellowship)
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' characterOptions declares both "
+ $"'{nameof(CharacterOptionId.IgnoreFellowshipRequests)}' and "
+ $"'{nameof(CharacterOptionId.FellowshipAutoAcceptRequests)}' "
+ "as true; retail's own mutual exclusion makes that "
+ "combination unsatisfiable — turning one on always clears "
+ "the other.");
}
}
}

View file

@ -143,18 +143,15 @@ internal sealed class HeadlessProcessHost : IDisposable
internal HeadlessProcessContentSnapshot? Content =>
_content?.CaptureSnapshot();
// MF-1 (Campaign OP OP7 review fix, 2026-08-11): `with` copies every
// record property that this method doesn't explicitly override, so a
// future HeadlessSessionDescriptor property can never be silently
// dropped here the way CharacterOptions previously was by the
// hand-rolled six-of-seven-property object initializer.
private static HeadlessSessionDescriptor WithAccount(
HeadlessSessionDescriptor source,
string account) =>
new()
{
Id = source.Id,
Endpoint = source.Endpoint,
Account = account,
Character = source.Character,
Policy = source.Policy,
Credential = source.Credential,
};
source with { Account = account };
internal Task<HeadlessExitCode> RunAsync(
CancellationToken cancellationToken)

View file

@ -209,8 +209,19 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
onDesiredComponents: null,
onCharacterOptions: (options1, options2, trailerTruncated) =>
{
// MF-2 (Campaign OP OP7 review fix, 2026-08-11): a
// trailer-truncated PlayerDescription's option words are
// the parser's zero placeholders, not server truth (R3,
// above). Installing them AND notifying subscribers let a
// headless seeder diff against zero and flush it into
// 0x01A1 even though HasServerSeed stayed armed from an
// earlier complete seed. Root fix: on truncation, install
// NOTHING and notify NO ONE — the words the caller last
// had (real, seeded) remain current.
if (trailerTruncated)
return;
character.Character.Options.Replace(
options1, options2, armServerSeed: !trailerTruncated);
options1, options2, armServerSeed: true);
character.OnCharacterOptionsChanged?.Invoke(options1, options2);
},
clientTime: character.ClientTime,

View file

@ -875,11 +875,18 @@ public sealed class RuntimeLiveEntitySessionController
RuntimeWorldHostAcknowledgementStage.TerminalProjected);
_session.SendGameAction(GameActionLoginComplete.Build());
_onLoginCompleteSent?.Invoke();
transit.EndTeleport();
_log(
$"headless: portal complete generation={generation} "
+ $"cell=0x{destination.CellId:X8}");
// SF-3 (Campaign OP OP7 review fix, 2026-08-11): invoke AFTER the
// teleport-completion tail, matching the other production call
// site's shape (OnSpawned, above). The observer body is not
// trivial — it runs the full diff, real SendGameActions, and
// event-hub publication to bot policies — so a throw from it must
// not abort transit.EndTeleport() with the retry token already
// discarded.
_onLoginCompleteSent?.Invoke();
}
/// <summary>