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

@ -228,11 +228,15 @@ persists the sends.
`0x24`) — immediately, and ACE's handler additionally joins the
Turbine trade-chat room server-side for this session (observable via
ACE's own Turbine-chat membership logging if available).
- One `SetSingleCharacterOption (0x0005)` for `SalvageMultiple` (id
`0x22`) followed by exactly one `SetCharacterOptions (0x01A1)` blob
flush — `SalvageMultiple` is batched (dirties the module; OP7's
diff-and-send calls the explicit `SaveOptions` verb once, after every
declared id has been diffed, never interleaved).
- **No** `SetSingleCharacterOption (0x0005)` for `SalvageMultiple` (id
`0x22`) — it is batched, not auto-save, so `TrySetOption` only marks
the module dirty and never sends `0x0005` for it. The single
`SetCharacterOptions (0x01A1)` blob flush is the ONLY wire evidence of
this id changing (OP7's diff-and-send calls the explicit `SaveOptions`
verb once, after every declared id has been diffed, never interleaved
— so this same `0x01A1` also carries whatever the loop already wrote
locally for `IgnoreAllegianceRequests`/`ListenToTradeChat`, even though
those two ALSO got their own immediate `0x0005`).
- **No other option bits change.** ACE's stored `CharacterOptions1`/
`CharacterOptions2` for every UNDECLARED id stay exactly what they
were before the run.
@ -255,9 +259,10 @@ persists the sends.
current value produces nothing" rule — set-character-options-wire.md
§3.1/§3.5).
5. **Flip one declared value** in the config (e.g. `SalvageMultiple` back
to `false`) and run again. Expect exactly ONE `0x0005` +
`0x01A1` pair for that id only — the other two, still matching,
produce nothing.
to `false`) and run again. `SalvageMultiple` is batched, so expect
exactly ONE `0x01A1` blob flush and **no** `0x0005` for it — the other
two declared ids, still matching, produce nothing at all (not even the
blob).
### What to report

View file

@ -0,0 +1,430 @@
# Campaign OP slice OP3 — fix-round RE-REVIEW (closure check)
**Commit under review:** `386076af` ("fix(ui): OP3 review fixes — byte-verified
Magic chat lines, Gameplay/OptionPage shape, mid-air tri-state, shared
geometry")
**Claims closure of:** every finding in
`docs/research/2026-08-11-op3-review-mechanism.md` (M1, S1S6, N1N8) and
`docs/research/2026-08-11-op3-review-blast.md` (MUST-FIX 1/2, SHOULD-FIX 14,
NOTE 18).
**Method:** read-only. No build, no test run, no client launch. Git reads and
a raw byte read of the PDB-paired retail binary only.
## VERDICT: REOPEN (narrow)
The *substance* of every finding is closed correctly — including M1, whose
byte-level claim I re-derived independently and confirmed exactly. Three
residuals remain, all cheap, none requiring a design change:
| ID | Class | One-line |
|---|---|---|
| R1 | **MUST-FIX** | The fix round's own "timestamped, logged" claim is FALSE for acdream (no timestamp prefix is rendered anywhere; no chat-log-file writer exists). It is repeated in the gate script's step 15, so the connected gate now instructs the user to look for something that cannot appear. |
| R2 | SHOULD-FIX | The tri-state `IsGrounded` does not implement the third state it documents: `IsPlayerMode == true` with a null controller still returns `false` → mid-air refusal. Three comments and the commit message assert the opposite — the same comment-vs-code inversion S3 was filed to remove. |
| R3 | SHOULD-FIX | The new dormant-`ActivePageChanged` pin applies no stimulus, so it asserts silence in a vacuum. It passes identically if dormancy is completely broken. |
Everything else is genuinely closed. Details below, evidence first.
---
## 0. What I verified as CORRECTLY CLOSED
### 0.1 M1 — INDEPENDENTLY BYTE-VERIFIED, claim exact
The commit claims `6a 07` at `0x0049E972`/`E9E2`/`EA52`/`EAA4`/`EAF6`/`EB48`.
**Internal consistency (the check I was asked for).** The mechanism review
recorded the six `AddTextToScroll` call addresses as `0x0049E97C`/`E9EC`/
`EA5C`/`EAAE`/`EB00`/`EB52`. Every one of the commit's six offsets is exactly
**10 bytes** below its matching call — a uniform delta, and the first one
(`0049e972`) is verbatim the BN line the review quoted. Re-reading
`docs/research/named-retail/acclient_2013_pseudo_c.txt:169512-169640` confirms
all six `*(uint32_t*)((char*)var_2c)[4] = 7;` lines carry precisely those six
addresses. The recorded evidence is complete and self-consistent.
**Independent decode (beyond what was asked; the binary read is read-only).**
`C:\Users\erikn\Downloads\acclient.exe` — TimeDateStamp `0x52291F34` =
2013-09-06 00:17:56 UTC, the PDB-paired build; ImageBase `0x400000`, `.text`
VA `0x1000` / raw `0x1000`, so VA→file offset is identity minus base. Bytes at
each of the six sites, with six bytes of lead-in:
```
6A 00 push 0 ; arg5 windowId = 0
6A 01 push 1 ; arg4 allowPluginFilter = 1
8D 57 14 lea edx,[edi+14]
6A 07 push 7 ; arg3 type = 7 <-- THE CLAIM
52 push edx ; arg2 text
E8 16 56 0C 00 call 0x00563F90 ; ClientUISystem::GetUISystem
8B C8 mov ecx,eax ; this
E8 3F 96 FE FF call 0x00487FC0 ; ClientSystem::AddTextToScroll(char const*)
```
All six sites are byte-identical in shape (`6A 00 / 6A 01 / 8D {57,4F,47} 14 /
6A 07 / {52,51,50} / call / mov ecx,eax / call`). Both call targets resolve by
name in the pseudo-C: `:368536` `00563f90 ClientUISystem::GetUISystem()` and
`:148528` `00487fc0 ClientSystem::AddTextToScroll(ClientSystem* this @ ecx,
char const* arg2, uint32_t arg3, uint8_t arg4, uint32_t arg5)`. **`arg3` (type)
is 7 at all six sites. Confirmed.**
**The code change matches the evidence.** `RetailUiRuntime.cs:1945-1946` routes
the six macro lines through the new
`OptionsRuntimeBindings.DisplayMouseTurningMacroLine`, bound at
`InteractionRetainedUiComposition.cs:832-833` to
`AddText(text, RetailLogTextType.Magic)`; `RetailLogTextType.Magic = 0x07`
(`src/AcDream.Core/Chat/RetailLogTextType.cs:33`). The mid-air refusal
(`RetailUiRuntime.cs:1905`) and UA/RA (`RetailUiRuntime.cs:1982`
`OptionsPanelController.cs:189-192`) both still use `DisplaySystemMessage` =
`ClientLocal`. Exactly the split the review asked for.
**Route traced to the chat scroll, no truncation.**
`RuntimeCommunicationState.AddText` (`:195-203`): `Trim()`, then
`if (type == ClientLocal) { SpewBox.Enqueue; return; }` — Magic falls through
to `Chat.OnSystemMessage(text, 7)`. `ChatLog.OnSystemMessage` (`:266-293`)
appends a `ChatEntry` with `LogTextType = 7`; `Append` (`:414-418`) trims only
against `_maxEntries` (default **500**, `:42`), so six lines cannot be dropped
— the 4-slot `SpewBoxState.MaxConcurrentItems` truncation that discarded
*Camera Stiffness* and *Camera Adjustment* is gone. The main window's default
type filter is `0xFBFFFFFF` (`ChatWindowState.cs:111`), which clears only bit
`0x1A`; bit 7 passes. The `SystemDedupWindow` short-circuit cannot collapse
the burst — all six strings are distinct
(`MouseTurningSettingsMacro.cs:58-104`). Colour is `#3FBFFF` light blue
(`2026-08-09-chat-retail-color-table.md:151`), matching what the gate script
tells the user to expect. ✅
*(The one thing about this route that is NOT true is the "timestamped, logged"
half — see R1.)*
### 0.2 S1 — Gameplay's slot carries no `AfterApply`; the other three keep theirs
`OptionsPanelController.cs:138` `_pages.Add(GameplayPageId, new OptionPage
{ AfterApply = null });` and `:139-143` the loop that still gives
Character/Chat/Config `AfterApply = afterApply`. **The seam OP4-6 will bind
survived intact**: `Callbacks.AfterApply` (`:81`) → `RetailUiRuntime.cs:1983-1984`
`Publish(new SaveCharacterOptionsRuntimeCmd())`, unchanged, and the three
real pages are constructed from it in the same constructor. Nothing about
OP4-6's wiring path was narrowed.
Keeping an (empty) `OptionPage` for the Gameplay slot is one of the two fixes
the review itself offered ("or construct it with `AfterApply = null` and
document *why*"). `OnShown()``Apply()` over zero rows is then: empty
`foreach`, `AfterApply?.Invoke()` null, `OnOptionChanged?.Invoke()` null —
byte-for-byte the no-op retail's plain `UIElement::OnVisibilityChanged` is.
**The two tests were rewritten to the right shape.**
`OptionsPanelControllerTests.cs:63-83` `ActivateTabs_SelectsGameplayAsDefault_
ButNeverFlushesIt` now asserts `gameplayFlushCount == 0` with the controller's
own `AfterApply` wired — a real pin, not a rename. `:143-159`
`GameplayPage_OnShownAndOnHidden_NeverFlush_EvenWhenControllerAfterApplyIsWired`
pins the same property independent of tab framing. The two
`OptionPageModelTests` entries were retargeted at a synthetic empty page and
renamed to say so, with the class doc (`:5-22`) now explicitly stating neither
shape models the Gameplay tab.
**The model's doc no longer overclaims.** `OptionPageModel.cs:157-172` keeps
the generic empty-page property but bolds "**This is a property of the generic
empty-page shape, not a description of the Gameplay tab**" and cites
`acclient.h:55857` `UIElement_Field`. ✅
### 0.3 S2 — `OnOptionChanged` fires at retail's four call sites, Defaults ungated
`OptionPage.Apply` (`:222-228`) → rows, `AfterApply`, then `OnOptionChanged`
(retail `0x004F2C95`). `Reset` (`:236-241`) → changed rows, then
`OnOptionChanged` (`0x004F2D4A`). `Defaults` (`:246-251`) → all rows, then
`OnOptionChanged` (`0x004F2CE5`). Per-row live edit: `BoolOptionRow.
SetCurrentValue` (`:100-105`) notifies; `RestoreSavedValue`/
`RestoreDefaultValue` deliberately do not — retail's `Apply(0)` paths, where
the owning verb notifies once itself. Wired by `OptionPage.Register` (`:211`)
through the new `IOptionRow.AttachPageNotify`. This is exactly the shape the
review specified.
`Defaults` is never gated: the model contains no gate at all, and the
`OnOptionChanged` doc (`:191-203`) records that retail's own override never
fetches `0x100001FE`. `OnShown`/`OnHidden` inherit the notify through
`Apply`/`Reset`, matching retail's `OnVisibilityChanged``SaveCurrentValues`/
`RestoreSavedValues`, both of which end in `OnOptionChanged`.
**Usable by OP4-6.** The seam is `Action? OnOptionChanged` + the existing
`bool Changed` (`:217`); a page binds
`page.OnOptionChanged = () => { apply.Enabled = page.Changed; reset.Enabled =
page.Changed; }`, which is precisely retail's `GetChildRecursive(0x100001FC/FD)`
+ `SetState(0x01/0x0D)`. The model correctly does NOT own the enabled state
itself. Six new tests pin order (`["afterApply","onOptionChanged"]`), all three
verbs, the empty-page case, the `SetCurrentValue`-only row notify, the
`Restore*` non-notify, and `Register`'s attachment. ✅
### 0.4 S4/S5/S6 + blast MUST-FIX 2 — register and script bookkeeping
- **AD-77** (`retail-divergence-register.md:174`) is scoped client-wide ("every
`Mount*`/`RegisterMainPanel` call site for a `gmPanelUI` sibling …
client-wide, not an Options-specific gap"), classed AD not TS, carries the
five-column shape with retail anchors, and states the inherited-not-
introduced nuance the review asked for. Section count bumped **56 → 57** in
the same hunk (`:65`); TS count correctly *not* bumped (TS-74 amended, not
added). No retired row resurrected in the diff.
- **TS-74** (`:408`) now names all five store-only keys in What/Where
(`Camera_Stiffness`, `Camera_AdjustmentSpeed`, `Camera_AlignToSlope`,
`Input_MouseLookSensitivity`, `Input_InvertMouseLookYAxis`), cites
`CameraTurningSettings.cs` and `CameraPointerInputController.cs` as the two
separate stores, and adds the "chat line quotes an unrelated stored value"
consequence to Risk. Exactly S5's ask.
- **Test script**: step 3 gains the ghosted→enabled + highlight check; step 5
is corrected to bottom-edge-only with an explicit "this is a correction from
OP3-as-landed" note; steps 13/14 gain the UA/RA legibility eye-item (blast
SHOULD-FIX 4); steps 16/17 add relogin (`0x0005` survived) and relaunch
(five prefs survived), both written so a failure is *discriminating* rather
than ambiguous; "What to report" updated to 17 steps. ✅
### 0.5 blast SHOULD-FIX 2 — geometry now matches the cohort exactly
`RetailUiRuntime.cs:2021-2023` `ResizeX = false, ResizeY = true,
ResizableEdges = ResizeEdges.Bottom`, with `MinWidth`/`MinHeight` removed. I
read every sibling that calls `RegisterMainPanel`: spellbook (`:1337-1339`),
effects (`:1528-1530`), the four indicator-detail panels (`:1751-1753`),
character (`:2266-2268`), inventory (`:2349-2351`) — all identical. No
`ResizeX`/min-size divergence remains. `ContentAnchors` all-four matches
effects and the indicator-detail panels, so it is not a new outlier either. ✅
### 0.6 Blast radius OF the fixes — clean on all three axes
- **Typed message seam:** `OptionsRuntimeBindings` has exactly ONE construction
site (`InteractionRetainedUiComposition.cs:801`) and
`DisplayMouseTurningMacroLine` exactly ONE consumer
(`RetailUiRuntime.cs:1946`). No test constructs the record, so the added
positional parameter breaks nothing.
- **`Func<bool>``Func<bool?>`:** one production caller
(`RetailUiRuntime.cs:1899`); the `switch` covers `true`/`false`/`null`
exhaustively. The unrelated `IsGrounded` occurrences across Runtime/Core.Net
are `UpdatePosition.PositionFlags.IsGrounded` — a different symbol entirely.
- **`IOptionRow.AttachPageNotify`:** `BoolOptionRow` is the ONLY implementer
(no test fakes), so widening the interface compiles everywhere.
- **Geometry:** no test pins the old Options geometry. The only Options
geometry in tests is the new `RetailPanelUiControllerTests` helper mount. A
*stale persisted* width from the pre-fix build is also harmless — I traced
it: `RetailWindowLayoutPersistence.Apply:213``RetailWindowHandle.ResizeTo:69-70`
`RetailWindowManager.ResizeTo:184` `if (!frame.ResizeX) width = frame.Width;`.
The saved width is discarded on restore. Verified non-issue.
### 0.7 Two of the three new pins genuinely pin
- `RetailPanelUiControllerTests.cs:131-148` uses the REAL
`RetailPanelCatalog.Options` (10) / `.Character` (11) ids against a real
`RetailPanelUiController.RegisterMainPanel`, and asserts the show-hides-the-
other transition in both directions plus `ActivePanelId`. Genuine — it would
fail if `SetPanelVisibility`'s exclusion regressed.
- `RetailDialogFactoryTests.cs:95-110` exercises the queueing end-to-end
(`ActiveCount == 1`, `PendingCount == 1`), which really would break to `2/0`
if the omitted-key overload took a different key. Slightly tautological at
the language level (the default IS `DefaultQueueKey` in the signature,
`RetailDialogFactory.cs:128`), but the behavioural assertion is real.
- The third does not pin — see R3.
---
## 1. RESIDUALS
### R1 — MUST-FIX: "timestamped, logged" is retail's behaviour, not acdream's, and the gate script now tells the user to look for it
**The claim, in three places:**
1. `src/AcDream.App/UI/RetailUiRuntime.cs:190-192` (XML doc on
`DisplayMouseTurningMacroLine`): "Magic routes through
`RuntimeCommunicationState.AddText` to the scrolling chat transcript
(**light blue, timestamped, logged**)".
2. The commit message: "routed at Magic (scrolling chat transcript, light
blue, **timestamped**)".
3. `docs/research/2026-08-11-campaign-op-test-script.md`, step 15: "expect
LIGHT BLUE text in the SCROLLING CHAT TRANSCRIPT (not the transient SpewBox
overlay), **with a timestamp prefix**, same as any other Magic-colored
system line."
**acdream renders neither.** Evidence:
- `ChatEntry.Received` exists (`src/AcDream.Core/Chat/ChatLog.cs:457`) but has
**zero consumers**`git grep "\.Received" -- src/` returns only
`NetClient.cs`/`FragmentAssembler.cs` hits on unrelated `ReceivedBytes`/
`ReceivedCount` members. Nothing formats it into a line.
- `ChatSettings.ShowTimestamps`
(`src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs:46`) is a
stored preference whose only references are its own declaration, its default
(`:107`), the unrendered ImGui-era `SettingsPanel.cs:415-417`, and
`SettingsStore.cs:196/597`. **No renderer reads it.**
- There is no chat-log-file writer at all: `git grep -ni
"chatlog\.txt|WriteChatLog|LogChatToFile" -- src/` returns nothing, and
register row TS-69 records `@log` as unported precisely because "needs a
safely-lifecycled chat-to-file writer (deferred)".
**Why MUST.** Item 3 corrupts the acceptance instrument this whole fix round
exists to make meaningful. The blast review's own MUST-FIX rationale was that
M1 "changes what the gate is supposed to observe"; a gate step that asks the
user to confirm a prefix acdream cannot draw will produce either a false
failure report or, worse, a follow-up "fix" for a non-bug. Item 1 is the same
class of defect S1 just retired in this very commit: a doc sentence naming an
acdream type and method while describing retail's mechanism.
**Fix (documentation only, no code):** in step 15 drop "with a timestamp
prefix"; in `RetailUiRuntime.cs:190-192` either drop "timestamped, logged" or
re-attribute it ("retail additionally timestamps and log-files this type;
acdream does neither — see TS-69"). Nothing about the Magic routing itself
changes.
### R2 — SHOULD-FIX: the tri-state does not implement its own third state
`src/AcDream.App/Composition/InteractionRetainedUiComposition.cs:814-816`:
```csharp
IsGrounded: () => !d.PlayerMode.IsPlayerMode
? (bool?)null
: d.PlayerController.Controller is { IsAirborne: false },
```
Enumerating the states as asked:
| State | Expression | `RequestExitToCharacterSelection` | Retail | ✓ |
|---|---|---|---|---|
| player mode, controller live, grounded | `true` | `EndCharacterSession()` | proceed | ✅ |
| player mode, controller live, airborne | `false` | "Cannot log off while in mid-air." | refuse | ✅ |
| not player mode | `null` | silent | silent | ✅ |
| **player mode, controller `null`** | **`false`** | **refusal** | **silent** | ❌ |
The fourth row is the miss. `Controller is { IsAirborne: false }` is `false`
for a null controller, so it lands in `case false:` (`RetailUiRuntime.cs:1904-1906`)
and emits the refusal.
**Three comments assert the opposite of the code** — the exact inversion S3
was filed to remove:
- `InteractionRetainedUiComposition.cs:807-808`: "with no player object (not in
player mode, **or no live controller yet**) neither the refusal nor the
logoff itself ever fires, i.e. a SILENT no-op, not a refusal."
- `RetailUiRuntime.cs:161-163`: "`null` means 'no player' (acdream:
`!d.PlayerMode.IsPlayerMode || d.PlayerController.Controller is null`)".
- `RetailUiRuntime.cs:1908-1911`: "outside player mode (**or with no live
controller**) there is no player object for `UseTime` to test at all".
Plus the commit message's own "(or with no live controller)".
**Reachability is narrow but the two owners are explicitly independent.**
`IsPlayerMode` is App-owned and set true only after the controller is in hand
(`PlayerModeController.cs:370`, after `controller.Position` is read at `:362`),
and `Exit()` clears it at `:166`. But `PlayerModeController.cs:167-169` states
outright: "the movement controller is Runtime-owned — player-mode exit
detaches presentation only; the publication lifecycle (generation reset/
teardown) owns the controller's retirement." Two owners, two clocks. Any
ordering in which Runtime clears the controller before App clears
`IsPlayerMode` reaches row four.
**Fix (one expression):**
```csharp
IsGrounded: () => !d.PlayerMode.IsPlayerMode
|| d.PlayerController.Controller is null
? (bool?)null
: d.PlayerController.Controller is { IsAirborne: false },
```
which is literally what the XML doc already says.
**On the "no dedicated automated test" flag:** for the `switch` itself,
acceptable — `RetailUiRuntime` is composition-driven and a direct unit test
would need a UiRoot plus DAT layouts, and there are zero test constructions of
`OptionsRuntimeBindings` today. But note what the absence cost here: an
untested *expression* silently failed to implement its own documented
contract. If R2 is fixed, the cheapest real pin is a one-line test over the
lambda shape rather than the runtime — or simply leaving it, given the fix
makes expression and doc agree by construction.
### R3 — SHOULD-FIX: the dormant-`ActivePageChanged` pin applies no stimulus
`tests/AcDream.App.Tests/UI/Layout/OP2ReworkBlastRadiusConformanceTests.cs:83-93`:
```csharp
var host = Assert.IsType<UiTabPanel>(layout.FindElement(0x100000B8u));
int raised = 0;
host.ActivePageChanged += (_, _) => raised++;
Assert.False(host.BehaviorActive);
Assert.Equal(0, raised);
```
Nothing between the subscription and the assertion does anything. The test
would pass identically if the dormancy model were entirely broken, because no
switch is ever attempted. It adds only the subscription over the test directly
above it (`:61-70`), which already asserts `BehaviorActive == false` and
`ActivePageElementId == 0`.
The parent's framing is right and the answer is the weaker variant shipped.
Worth being precise about *what* stimulus is correct, because "call `SwitchTo`
and assert silence" would legitimately fail: `UiTabPanel.SwitchTo` has no
dormancy guard and raises unconditionally at its tail
(`src/AcDream.App/UI/UiTabPanel.cs`, `ActivePageChanged?.Invoke(...)` as the
last statement). Dormancy lives entirely in the *click bindings*:
`ActivateTabBehavior` is the only caller of
`RetailTabBinding.SetClick(button, () => SwitchTo(pageId))`, and
`RetailTabBinding.SetClick` (`RetailTabBinding.cs:12-23`) sets
`button.OnClick = action`.
**Fix:** drive the authored tab button on a dormant host —
```csharp
var button = layout.FindElement(host.Tabs[0].ButtonElementId);
Assert.Null(((UiButton)button!).OnClick); // never wired -> the real mechanism
Assert.Equal(0, raised);
Assert.Equal(0u, host.ActivePageElementId);
```
That fails the moment eager wiring returns, which is the regression AD-73
exists to prevent.
---
## 2. NOTES (no action required)
- **N1 — `EmptyPage_WithNoAfterApplyWired_Apply_NeverFlushes`
(`OptionPageModelTests.cs:90-101`) asserts only `Assert.Null(thrown)`.** The
name promises "never flushes"; with `AfterApply` null there is nothing to
count, so it is really a null-safety test. Harmless because the real S1 pin
lives in `OptionsPanelControllerTests.cs:143-159` and does count. Rename or
leave.
- **N2 — plan §5 still open-ended.**
`docs/plans/2026-08-10-options-panel-campaign.md:381` still reads "register
row in OP3 if the review deems it a divergence" with no pointer to AD-77. A
future reader re-litigates a settled ruling. One-line edit whenever that file
is next touched.
- **N3 — `BoolOptionRow.AttachPageNotify` is a single-slot assignment**
(`OptionPageModel.cs:107`). Registering one row into two pages silently drops
the first page's notify. This matches retail's single `UIOption::m_pOCH`
pointer, so it is faithful, not a bug — recorded so OP4-6 does not share a
row instance across pages expecting both to hear it.
- **N4 — Options can now only GROW.** With `MinWidth`/`MinHeight` removed and
no `DatConstraintSource` passed, `RetailWindowFrame.ResolveConstraint:187-201`
falls through to the `fallback` argument, which is the mounted outer extent
(`:161-164`) — i.e. min = the authored 300 x 362 plus chrome inset. That is
identical to all nine siblings, so it is the correct outcome, but gate-script
step 5 says only "resizable from the bottom edge only … remembers its
HEIGHT" without noting the window cannot be dragged *shorter* than it opens.
A tester may report that as a defect. One clause would pre-empt it.
- **N5 — `OptionsPanelController.Pages`' doc (`:104-109`) still describes the
four slots uniformly** ("Every entry exists from construction … only
GameplayPageId's stays permanently empty") without mentioning the
`AfterApply` asymmetry. The constructor comment immediately below covers it.
Cosmetic.
- **N6 — test counts unverified.** The commit's "12,947 passed / 4 skipped /
0 failed" was not re-run; this re-review is read-only by instruction.
---
## 3. Closure table
| Finding | Claimed | Actually |
|---|---|---|
| M1 (both lenses) | closed, byte-verified | **CLOSED** — independently re-decoded; `6a 07` at all six sites, `arg3` of `ClientSystem::AddTextToScroll @0x00487FC0`. Route traced to the chat scroll, filter passes, no truncation, no dedupe collapse. R1 is a doc/gate overclaim attached to it, not a routing error. |
| S1 | closed | **CLOSED** — Gameplay slot `AfterApply = null`; other three keep the real seam; both tests rewritten to the right shape; model doc corrected. |
| S2 | closed | **CLOSED** — notify at all three verb tails + `SetCurrentValue` only; Defaults ungated; seam usable by OP4-6. |
| S3 / blast SHOULD-FIX 1 | closed | **PARTIAL → R2.** Three of four states right; `IsPlayerMode && Controller == null` still refuses, and three comments plus the commit message say it does not. |
| S4 / blast MUST-FIX 2 | closed | **CLOSED** — AD-77 filed, client-wide, AD-classed, count 56→57, format correct. |
| S5 | closed | **CLOSED** — TS-74 What/Where names all five keys and both stores. |
| S6 | closed | **CLOSED** — all three script additions present and discriminating (but see R1 on step 15). |
| blast SHOULD-FIX 2 (geometry) | closed | **CLOSED** — matches all nine siblings exactly; stale persisted width provably discarded on restore. |
| blast SHOULD-FIX 3 (three pins) | closed | **2 of 3 CLOSED → R3.** Mutual exclusion and queue key genuinely pin; dormant `ActivePageChanged` does not. |
| blast SHOULD-FIX 4 (UA/RA legibility) | closed | **CLOSED** — eye-item added to steps 13/14 with the honest-fix direction named. |
| blast NOTE 8 (close-button logging) | closed | **CLOSED**`OptionsPanelController.cs:176-179`, symmetric with `BindButton`. |
| New blast radius from the fixes | — | **CLEAN** — one construction site for the new seam, one caller for the changed signature, one `IOptionRow` implementer, no test pins the old geometry. |

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>

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