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>
This commit is contained in:
parent
09029f9f4b
commit
8a05fda445
2 changed files with 487 additions and 2 deletions
485
docs/research/2026-08-11-op1-rereview.md
Normal file
485
docs/research/2026-08-11-op1-rereview.md
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
# 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, S1–S6).
|
||||
**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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue