acdream/docs/research/2026-08-11-op1-rereview.md
Erik 8a05fda445 docs: OP1 re-review CLOSED — ledger records slice OP1 complete
Focused re-review verifies all ten fix dispositions genuinely close their
findings (fellowship order re-derived from OnChanged @0x0059A8E0; the
seed latch confirmed upstream of every one of the three blob call sites;
TS-71 retirement and TS-73 accuracy checked). Residuals owed to a small
coordinator pass, none reopening: R1 closure hoisting defeats the
per-tick allocation guard (move the IsDirty check into the ctor
lambdas); R2 concurrent MarkDirty during a flush is silently erased by
the trailing clear (needs the dirty-generation token); R3 NOTE — a
truncated-trailer PlayerDescription (options words zero) arms the seed
latch, newly wire-reaching via the timer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:56:36 +02:00

485 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Campaign OP slice OP1 — fix-round closure re-review
**Commit under review:** `09029f9f` ("fix(runtime,net): OP1 review fixes —
server-seed gate, tick-wired auto-save/logout flush, fellowship mutual
exclusion").
**Closes (claimed):** every finding in
`docs/research/2026-08-10-op1-review-mechanism.md` (MF-1/MF-2/MF-3) and
`docs/research/2026-08-10-op1-review-blast.md` (M1, S1S6).
**Mode:** read-only. No build, no test run, no launch. Git reads only.
**Scope:** closure check against the diff and the post-commit code — not a
fresh review of OP1.
**Date:** 2026-08-11.
**Verdict: CLOSED.** All ten dispositions genuinely close their finding's
failure scenario. No finding is merely gestured at. 2 SHOULD-FIX and 5 NOTE
residuals below, all introduced *by* the fixes or adjacent to them; none
reopens a review finding.
---
## 1. Per-finding closure
### MF-1 (mechanism MUST-FIX 1) — TS-71's false deferral rationale · **CLOSED**
The fixer took the stronger of the two offered remedies: wired the triggers
and retired the row.
**Seam, verified end to end:**
- `src/AcDream.Runtime/GameRuntime.cs:339-342` — the constructor calls
`context.Session.ConfigureAutoSaveTick(...)` /
`ConfigurePreLogoffFlush(...)` on the same object it assigns to `Session`
at `:315`.
- `src/AcDream.Runtime/Session/LiveSessionController.cs:319, 331` — both
hooks are `internal`, single-assignment, null-checked.
- Tick half: `LiveSessionController.cs:482` calls
`InvokeAutoSaveTick(scope.Session)` inside `RunTopLevel`, **after** the
`IsCurrent(scope, generation)` guard at `:467`.
- Stop half: `LiveSessionController.cs:686-687` calls
`InvokePreLogoffFlush(activeScope.Session)` as the first statement of
`StopCore`, before the generation bump and before `DrainRetiredScope`
`SessionScope.DrainTeardown``operations.DisposeSession`
`WorldSession.Dispose``CharacterLogOff.BuildRequestBody`
(`src/AcDream.Core.Net/WorldSession.cs:2966`). So the blob really does
precede the logoff request, as retail's `CPlayerSystem::LogOffCharacter`
does.
**Both hosts, zero host edits — confirmed.** `new LiveSessionController` has
exactly two production sites, both inside `GameRuntime`
(`GameRuntime.cs:167-168`), so no production controller can miss the wiring.
The two tick sites are unchanged by this commit:
`src/AcDream.Headless/Hosting/HeadlessSessionHost.cs:341` and
`src/AcDream.App/World/RetailLiveFrameCoordinator.cs:61`. The diff touches
no file under `src/AcDream.Headless/`, and its two App files
(`LiveSessionRuntimeFactory.cs`, `CurrentGameRuntimeCommandAdapter.cs`) are
a binding factory and a command adapter, not a host loop.
**Not just hook mechanics.** Beyond the six isolated hook tests added to
`tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs`, two
end-to-end tests drive the real `GameRuntime` wiring:
`Session_Tick_AutoFlushesTheDirtyBlob_OnceThe480sTimerIsDue` and
`Stop_AutoFlushesTheDirtyBlob_BeforeTheCharacterLogoffRequest`
(`tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs`,
new in this commit) — both assert the `0x01A1` opcode at body offset 8, and
the second asserts flush-before-logoff by capture order.
**The per-tick allocation pre-check is only PARTLY real — see R1.**
### MF-2 (mechanism SHOULD-FIX 2) — `OnChanged`'s side-effect switch · **CLOSED**
I read `CPlayerModule::OnChanged @0x0059A8E0` myself at
`docs/research/named-retail/acclient_2013_pseudo_c.txt:423491-423554`
rather than trusting either review. The body is:
```
0059a8e9 CM_UI::SendNotice_PlayerOptionChanged(arg2);
0059a904 switch (arg2) {
0059a96a case 2: if (PlayerModule::IgnoreFellowshipRequests(this)) SetFellowshipAutoAcceptRequests(this, 0);
0059a921 case 4: SmartBox::EnableWeather(...)
0059a916 case 5: LScape::SetDay(...)
0059a954 case 7: ClientCombatSystem::TrackTarget(...)
0059a980 case 0x12: if (PlayerModule::FellowshipAutoAcceptRequests(this)) SetIgnoreFellowshipRequests(this, 0);
0059a940 case 0x30: LScape::m_fFogEnabled = ...
}
0059a99d if (CPlayerModule::IsAutoSaveOption(...)) { Event_PlayerOptionChangedEvent(...); return; }
0059a9c1 if (!m_bDirty) { m_bDirty = 1; m_timeFirstDirtied = Timer::cur_time; }
```
Checked point by point against
`src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:785-821`:
| Question | Retail | acdream | Verdict |
|---|---|---|---|
| Which id clears which | `case 2` (Ignore) clears **AutoAccept**; `case 0x12` (AutoAccept) clears **Ignore** | `:800-806` id `0x02` → clears `FellowshipAutoAcceptRequests`; `:807-813` id `0x12` → clears `IgnoreFellowshipRequests` | ✅ not transposed |
| Guard | reads the option **after** the accessor's own write, i.e. "if now ON" | writes at `:795`, then tests `value` at `:800`/`:807` — equivalent, and the comment says why | ✅ |
| Turning one OFF | guard false, no clear | `value &&` short-circuits | ✅ (`TrySetOption_TurningOffAFellowshipOption_NeverTriggersTheClear`) |
| Other already clear | the nested `Set…` accessor's own unchanged-value early return fires — **no** `OnChanged`, **no** second `0x0005` | the recursive `TrySetOption` hits the same early return at `:792-793` | ✅ (`..._WhenTheOtherIsAlreadyOff_SendsOnlyThePrimary`) |
| Wire order | the nested call finishes its whole `OnChanged` (including its `Event_PlayerOptionChangedEvent`) before the outer call reaches step 3 → **clear first, primary second** | recursion at `:800-813` precedes `sendAutoSave(characterOptionId, value)` at `:815-816` | ✅ (both order tests assert the exact two-element `sent` sequence) |
| Termination | the clear passes `0`, which cannot re-arm either "if now true" guard | same | ✅ one level, always |
Id/mask spot-checks against the verbatim header:
`IgnoreFellowshipRequests_PlayerOption = 0x2` /
`FellowshipAutoAcceptRequests_PlayerOption = 0x12`
(`docs/research/named-retail/acclient.h:4167, 4183`) match
`src/AcDream.Core.Net/Messages/SocialActions.cs:366, 382`;
`IgnoreFellowshipRequests_CharacterOption = 0x8` /
`AutoAcceptFellowRequest_CharacterOption = 0x20000000`
(`acclient.h:3409, 3430`) match
`src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs:113, 129`. Both rows
carry `IsAutoSave = true`, so the recursion can never reach `MarkDirty`
as the remark claims.
The four presentation cases are correctly left unported and correctly filed
(TS-73, §2).
### MF-3 (mechanism SHOULD-FIX 3) — no id→(word, mask) pin · **CLOSED**
`tests/AcDream.Runtime.Tests/Gameplay/CharacterOptionTableTests.cs` adds
`WordAndMask_MatchesIndependentTranscriptionOfVerbatimAcclientEnums`, a
`[Theory]` with **53 `[InlineData]` rows** — one per id in the `0x00..0x34`
space — asserting `(IsOptions1, Mask)`. I spot-checked the five
transposition-prone rows the mechanism review singled out
(`IgnoreTradeRequests` O1 `0x20000`, `DisplayAge` O2 `0x20`,
`DisplayNumberDeaths` O2 `0x10`, `UseCraftSuccessDialog` O1 `0x80000000`,
`ConfirmVolatileRareUse` O2 `0x40000`); all match both the header and the
table. `WordAndMask_AreAllPairwiseDistinct` additionally closes blast NOTE
N7 with the `Distinct().Count() == 53` check the reconstruction test
structurally cannot do. See R6 for the one column still unpinned.
### M1 (blast MUST-FIX) — no server-seed gate on the blob flush · **CLOSED**
The latch: `_hasServerSeed` (`RuntimeCharacterState.cs:665`) starts `false`,
is set **only** by `Replace` (`:726-736`) and cleared **only** by
`ResetSession` (`:944-955`). Both `TryFlush` (`:901`) and
`TryFlushIfAutoSaveDue` (`:925`) test `!_isDirty || !_hasServerSeed` and
return `false` without invoking the callback.
**Traced: no path can build or send a `0x01A1` pre-seed.** There is exactly
one wire method (`WorldSession.SendSetCharacterOptions`,
`src/AcDream.Core.Net/WorldSession.cs:2216`) and exactly one builder
(`SocialActions.BuildSetCharacterOptions`), whose only non-test caller is
that method. `SendSetCharacterOptions` has **three** call sites in `src/`,
and every one is the body of a `TryFlush`/`TryFlushIfAutoSaveDue` callback,
so the gate sits upstream of both the `Capture` and the send on all three:
| Trigger | Site | Gated by |
|---|---|---|
| Explicit `SaveOptions`, graphical | `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:359-372` | `TryFlush` |
| Explicit `SaveOptions`, headless/bot | `src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:691-703` | `TryFlush` |
| New 480 s timer tick **and** new pre-logoff flush | `src/AcDream.Runtime/GameRuntime.cs:386-403` (one shared `SendBlob`) | `TryFlushIfAutoSaveDue` / `TryFlush` |
`Options.Replace` likewise has exactly one production caller —
`src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:212`, the
`PlayerDescription` route — so the latch cannot be armed by anything but a
parsed server description. (One caveat on *which* descriptions arm it: R3.)
Nothing is lost when the gate refuses: `_isDirty` is untouched, so the
pending change survives until a real seed arrives — asserted by
`TryFlush_RefusesBeforeServerSeed_EvenWhenDirty_ThenSucceedsAfterSeed`,
`TryFlushIfAutoSaveDue_RefusesBeforeServerSeed_EvenAtThreshold`,
`ReconnectSequence_ResetSessionClearsSeed_NewReplaceUnblocksFlushAgain`
(`RuntimeCharacterStateTests.cs`) and
`SaveOptions_RefusesBeforeServerSeed_EvenWhenDirty_ThenSucceedsAfterSeed`
(`DirectGameRuntimeCommandAdapterTests.cs`).
### S1 (blast) — CH3/CH4 regression test mirrored the pre-OP1 shape · **CLOSED**
`tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs:498-505` now
binds `characterState.Options.TrySetOption(id, value, sendAutoSave: …)`
the actual production seam — instead of the hand-rolled
`SetOptionBit` + `sent.Add` substitute. The test therefore now exercises
the unchanged-value early return and the MF-2 side effect, which was the
point of the finding.
### S2 (blast) — flush callback invoked under `_dirtyGate` · **CLOSED (with R2)**
Both methods now use decide-under-lock / callback-outside-lock /
clear-under-lock (`RuntimeCharacterState.cs:896-909` and the identical
shape at `:920-934`):
```csharp
lock (_dirtyGate) { if (!_isDirty || !_hasServerSeed) return false; } // :899-902
flush(); // :903
lock (_dirtyGate) { _isDirty = false; } // :904-907
return true;
```
The callback runs outside the lock on **every** path — there is no branch
that still invokes it inside — and the throw path leaves the module dirty
(`TryFlush_PreservesDirtyState_WhenTheCallbackThrows`). `_dirtyGate` is now
a strict leaf lock: no site holds it while acquiring another. The tick
path's order is `LiveSessionController._gate``_dirtyGate` (`Tick` holds
`_gate` across `InvokeAutoSaveTick`, `LiveSessionController.cs:453-484`),
the same direction as App's router, so the named inversion is gone.
**On the re-entrancy NOTE the fixer left open:** the same-thread claim is
correct. A re-entrant `MarkDirty` from inside a flush callback sets
`_isDirty = true` and is then erased by the trailing clear — identical
outcome before and after the restructure, because C# locks are reentrant.
It is unreachable today: all three callbacks do exactly
`CharacterOptionsBlobSource.Capture` + `WorldSession.SendSetCharacterOptions`,
and that send is a pure outbound enqueue (`SendGameAction`) that pumps no
inbound handlers, so nothing on the stack can call `MarkDirty`,
`TrySetOption` or `Replace`. The *cross-thread* half of that claim is wrong
— see R2.
### S3 (blast) — ownership ledger blind to the dirty flag · **CLOSED**
`RuntimeCharacterOwnershipSnapshot` gains `OptionsAreClean`
(`RuntimeCharacterState.cs:33`, default `true`, appended last so no
positional call site breaks) and `IsConverged` requires it (`:50`);
`CaptureOwnership` populates it from `!Options.IsDirty` (`:235`). Safe
against false non-convergence: the only consumers are the disposal-stage
ledger (`GameRuntime.cs:708, 736`) and `RuntimeGameplayOwnership.Capture`,
and stage 6 disposes `CharacterOwner` (→ `Options.ResetSession()`
`_isDirty = false`) before reading it.
### S4 (blast) — `SaveOptions` encoded a bool in `PrimaryObjectId` · **CLOSED**
`DirectGameRuntimeCommandAdapter.SaveOptions` now emits
`(Accepted, objectId 0)` unconditionally
(`DirectGameRuntimeCommandAdapter.cs:712-716`), matching
`CurrentGameRuntimeCommandAdapter.SaveOptions`
(`src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs:706-710`).
**K2 event-stream consumers checked:** `PrimaryObjectId` is carried by
`GameRuntimeEvents.cs:36/144/242` into the ordered bot-facing stream and is
read by **no** production consumer — `grep` over `src/AcDream.Headless`
finds no `ResultObjectId` use at all. The only assertions on it are the two
new `Assert.Equal(0u, …)` lines in
`DirectGameRuntimeCommandAdapterTests.cs`. Nothing depended on the `1u`
encoding, so dropping it is inert outside the shape fix itself.
### S5 (blast) — re-seed discarded pending intent but left the module dirty · **CLOSED**
`Replace` now clears `_isDirty` alongside arming the seed
(`RuntimeCharacterState.cs:726-736`), with the decision recorded in the
member's XML doc: a wholesale re-seed has no partial-merge path in retail's
`PlayerModule` either, so the server's words are current and a later flush
would only echo them. Pinned by
`Replace_ClearsDirtyState_ServerTruthSupersedesPendingLocalIntent`. This is
the "record the decision plus a test" outcome the finding asked for.
### S6 (blast) — the same bits defined twice with no cross-check · **CLOSED**
`CharacterOptionTable_AgreesWithPlayerDescriptionParserEnums`
(`CharacterOptionTableTests.cs`) asserts each table entry against
`(uint)PlayerDescriptionParser.CharacterOptions1/2` members directly. I
verified the comment's completeness claim: `CharacterOptions1` has three
non-`None`/`Default` members (`AllowGive`, `HearAllegianceChat`,
`DragItemOnPlayerOpensSecureTrade`) and `CharacterOptions2` has five — all
eight appear in the theory
(`src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:206-236`). Edit
either definition alone and the test fails, which is exactly the CH3 class.
---
## 2. Register bookkeeping
The register diff is **exactly two single-line hunks** (`git show -U0`:
`@@ -349 +349 @@` and `@@ -353 +353 @@`), so nothing else in the file moved.
- **TS-71 retirement — accurate.** The row is deleted, not amended, and the
section-header note reads "both remaining `SetCharacterOptions (0x01A1)`
flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now
wired through `LiveSessionController`'s own tick/stop transaction
(`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by
`GameRuntime`'s constructor)". Every clause is true of the code as landed
(§1 MF-1). The active-row count stays 41 because TS-73 replaces it. Two
narrow paths where the logout half does not fire are R4/R5 — neither is on
a normal shutdown, so the retirement stands.
- **TS-73 — truthful about what remains unported.** It names the four
omitted `OnChanged` cases by id **and** by the retail consumer each drives
(`0x04``SmartBox::EnableWeather`, `0x05``LScape::SetDay`,
`0x07``ClientCombatSystem::TrackTarget`, `0x30``LScape::m_fFogEnabled`);
I confirmed all four against the decomp body quoted in §1 MF-2. It states
correctly that the two ported cases are the only ones mutating
`PlayerModule` state, gives the right file/member pointer, anchors to the
named symbol + address, and describes the user-visible consequence
accurately (bit writes and auto-saves fine, no local presentation change).
Its "pre-anchored to OP4" rationale points at an existing plan section
rather than inventing a deferral reason — the opposite of the defect MF-1
was filed against.
- **AP-193 / AP-194 — untouched and still accurate.** Neither row is in the
diff. Nothing in this commit changes the table data they describe: the
`0x34` `HearPkDeathMessages` row is still the ACE-sourced one (the new
MF-3 theory pins it at O2 `0x02000000` with an inline comment naming the
register row), and the 16-entry `ClientDefaultOnIds` array bounded at
`0x2A` — the source of AP-194's `0x2D`/`0x2F`/`0x32` disagreement — is
unchanged.
---
## 3. New blast radius from the fixes
All three axes named in the brief were walked.
**The new `LiveSessionController` seams.** `ConfigureAutoSaveTick` /
`ConfigurePreLogoffFlush` are `internal`, and `grep` finds no consumer
outside `GameRuntime.cs:339-342` and the new tests. Nothing else observes
`_autoSaveTickHook`/`_preLogoffFlushHook`.
**Can the flush tick fire for a non-current generation?** No. `Tick()` holds
`_gate` for its whole body (`LiveSessionController.cs:453-484`), every
mutation of `_scope`/`_generation` requires `_gate`, and `InvokeAutoSaveTick`
runs after the `IsCurrent(scope, generation)` early-out at `:467` inside the
same lock hold — the scope cannot be retired between the check and the
flush. It also cannot fire pre-`Start` or post-`Stop`: `:455-458` returns
when `_disposed`, `!_inWorld`, `_scope is null`, or `_operationDepth != 0`.
A throwing hook is caught and logged (`:487-500`), pinned by
`Tick_AutoSaveHookThrowing_DoesNotFailTheTickOrTearDownTheSession`.
**`TrySetOption`'s widened signature.** Three production call sites, all
updated to pass `WorldSession.SendSetSingleCharacterOption` as a method
group: `LiveSessionRuntimeFactory.cs:348-354`,
`DirectGameRuntimeCommandAdapter.cs:666-673`, and the router binding they
share. `CurrentGameRuntimeCommandAdapter` reaches it only through the bus,
so it needs no change. No other caller exists in `src/`. The widening cannot
change what any existing caller sends, because every caller now forwards the
callback's own `(id, value)` rather than a closed-over pair.
**S4's result shape.** Covered in §1 S4 — no consumer.
**Two flush bodies, one owner graph.** The graphical closure builds from
`_domain.Character`/`_domain.Inventory` and the new hook from
`CharacterOwner`/`InventoryOwner`;
`src/AcDream.App/Composition/SessionPlayerComposition.cs:1070-1076` passes
the same canonical `d.Character`/`d.Inventory` owners into
`LiveSessionDomainRuntime`, so both bodies read identical state. A double
flush is impossible in either order because `TryFlush` clears `_isDirty`
under the lock.
---
## 4. Residuals
### R1 — SHOULD-FIX: the per-tick allocation pre-check guards the delegate, not the closure
`src/AcDream.Runtime/GameRuntime.cs:380-404`. `SendBlob` captures **two**
things: `this` (for `CharacterOwner`/`InventoryOwner`) *and* the `session`
parameter. Because it is converted to a delegate, its closure environment
must be a class, and Roslyn instantiates a method-scope environment holding
a captured **parameter** in the method prologue — i.e. ahead of the
`if (!options.IsDirty) return;` guard at `:383-384`. The guard therefore
elides the `Action` delegate allocation on a clean tick but not the display
class, so `FlushCharacterOptions` still allocates once per
`LiveSessionController.Tick()` per live session — the exact per-tick
allocation the doc comment at `:370-377` claims it prevents ("the closure
below is only ever allocated on the rare tick where a flush might really
happen"). At 30 sessions that is the dimension the K4 headless
resource-envelope gate measures.
A one-line fix that is genuinely zero-alloc: move the guard up into the two
constructor lambdas, which capture only `this` and are allocated once —
```csharp
context.Session.ConfigureAutoSaveTick(
session => { if (CharacterOwner.Options.IsDirty) FlushCharacterOptions(session, true); });
```
so `FlushCharacterOptions` (and its prologue) is never entered on a clean
tick. Alternatively give `RuntimeCharacterOptionsState` a
`TryFlush<TState>(TState, Action<TState>)` overload fed by a cached static
lambda. **Caveat:** this review is read-only, so I did not compile to
confirm the lowering — verify with a compiled allocation check before
rewriting. Either way the doc comment's absolute claim needs softening.
### R2 — SHOULD-FIX: S2's restructure turns a blocking-but-correct concurrent `MarkDirty` into a silent lost update
The commit message's disposition on mechanism NOTE 6 says the re-entrancy
outcome is "the same whether the callback runs inside or outside the lock".
True for the *same-thread* re-entrant case; false for the *cross-thread*
case. Pre-fix, a concurrent `MarkDirty` blocked on `_dirtyGate` until the
flush finished and `_isDirty` had been cleared, then set it — nothing lost.
Post-fix it runs during `flush()` and is erased by the trailing
`_isDirty = false` at `RuntimeCharacterState.cs:904-907`. If the toggle
landed after `CharacterOptionsBlobSource.Capture` read the words, that
option change is silently dropped until something else dirties the module.
The commit's own new test constructs exactly that interleaving —
`TryFlush_ReleasesTheDirtyGate_DuringTheCallback_SoAConcurrentMarkDirtyDoesNotBlock`
(`tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs:596-628`)
proves the concurrent `MarkDirty` completes mid-flush, then asserts nothing
about `IsDirty` afterwards.
Whichever threading model actually holds, one of the two claims in the
disposition is wrong: if two threads can reach these seams, this lost update
is live; if only Runtime's single update thread can (memory `#368`), then
the `_gate``_dirtyGate` inversion S2 was filed against was never reachable
either. Worth deciding and recording before OP4 puts an Options panel on
this seam. The per-dirty-period generation token the fixer already named
closes this and NOTE 6 together (capture the revision under the first lock;
clear only if it is unchanged under the second).
### R3 — NOTE: the seed latch arms on a truncated `PlayerDescription` too
`onCharacterOptions?.Invoke(p.Value.Options1, p.Value.Options2)`
(`src/AcDream.Core.Net/GameEventWiring.cs:732`) fires for **every** parse
that returns non-null, and `PlayerDescriptionParser.TryParse` yields
`options1 = options2 = 0` when the trailer is shorter than 8 bytes at the
option block (`src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:369-372`)
or when that read throws (`:450-466`, which still returns the record, with
`TrailerTruncated = true`). `LiveSessionEventRouter.cs:212` then calls
`Replace(0, 0)` — zeroing both words *and* arming `HasServerSeed`. The local
zeroing is pre-existing (it would already break the Turbine membership
gate); what is new is that MF-1's timer makes it wire-reaching, so 480 s
later the auto-save would ship `options1 = options2 = 0` to ACE — M1's
failure class through a different door. `TrailerTruncated` already exists
and is unused by the wiring; gating the `Replace` (or at least the latch) on
it is the natural close. Reachability against ACE is low — a real
`PlayerDescription` always carries the trailer.
### R4 — NOTE: a deferred `Stop`/`Dispose` skips the pre-logoff flush
`Schedule` sets `_inWorld = false`
(`src/AcDream.Runtime/Session/LiveSessionController.cs:775`) before the
pending operation later runs `StopCore`, whose flush is gated on `_inWorld`
(`:686`). So a `Stop()`/`Dispose()` requested while `_operationDepth != 0`
i.e. re-entrantly, from inside another operation's callback — silently drops
the logout flush. Not on either host's normal shutdown:
`GameRuntime.StopSession` (`GameRuntime.cs:564-572`) calls
`Session.Dispose()` from outside any operation and *throws* if the shutdown
was deferred, and `adapter.Session.Stop(generation)` likewise reaches
`RunTopLevel(StopCore)` directly. Recorded so the gap is known rather than
rediscovered.
### R5 — NOTE: the "pre-logoff" hook also fires on reconnect and after a failed tick
`StopCore` is reached from `ReconnectCore`
(`LiveSessionController.cs:530`) and `StopAfterFailure` (`:665`) as well as
`Stop`/`DisposeCore`, so the hook fires whenever an in-world scope is torn
down — including a mid-session reconnect and a tick that just threw. Saving
pending options before a session replacement is arguably right, and a send
on a dead socket is caught and logged (`:706-719`), so this is benign; but
the register row and the XML doc both describe the hook purely as retail's
`LogOffCharacter` site, which is narrower than what it actually does.
### R6 — NOTE: the id↔name column still has no id-by-id pin
MF-3 closes the (word, mask) column, but all three transcribed
arrays/theories in `CharacterOptionTableTests.cs` key on `CharacterOptionId`
**members**, not on numeric wire ids. Swap two values in the
`CharacterOptionId` enum
(`src/AcDream.Core.Net/Messages/SocialActions.cs:362-417`) and both the
table registration and every test lookup move together — every test stays
green while the client sends the wrong id to ACE.
`CoversTheCompleteIdSpace` pins contiguity `0x00..0x34` but not the name→id
assignment. A `[InlineData((uint)0x1D, CharacterOptionId.DisplayAge)]`-shaped
theory transcribed from `docs/research/named-retail/acclient.h:4162-4218`
would close it; that column was verified by hand this round and last, so
nothing is wrong today.
### R7 — NOTE (minor): the hook fields are written without `_gate`
`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`
(`LiveSessionController.cs:319, 331`) assign
`_autoSaveTickHook`/`_preLogoffFlushHook` outside `_gate`, while
`Tick`/`StopCore` read them under it. There is no formal happens-before from
the constructing thread to the first ticking thread. Practically safe —
`GameRuntime` construction is fully ordered before any host can tick, and
that handoff carries its own synchronization — but a `lock (_gate)` around
the two assignments would cost nothing.
---
## 5. Verdict
**CLOSED.** Ten findings, ten real closures: M1's latch sits upstream of the
single builder on all three flush triggers; MF-1 reaches both hosts through
`GameRuntime``LiveSessionController` with zero host edits and end-to-end
tests, not just hook mechanics; MF-2 matches `OnChanged @0x0059A8E0` on
which-clears-which, the already-clear short-circuit, and the clear-before-
primary wire order, all read out of the decomp directly; MF-3, S1, S3, S4,
S5 and S6 each close their stated failure scenario and are pinned by a test
that would actually fail on regression. The register is honest: TS-71's
retirement matches the code, TS-73 states precisely what is still unported
and anchors it to an existing plan section, and AP-193/AP-194 are untouched
and still accurate.
Two SHOULD-FIX residuals arise from the fixes themselves — R1 (the per-tick
allocation guard does not cover the closure environment, and the doc comment
overclaims) and R2 (S2's restructure trades a blocking-but-correct
concurrent `MarkDirty` for a silent lost update, and the disposition that
waves it off is only half true). Neither reopens a finding; both should be
settled before OP4 wires the Character tab onto this seam.