0005: Sync handshake between the SKSE shim and the service¶
Context¶
0004-timeline-branching.md establishes what Chronicle's state model
looks like across saves/reloads. This ADR covers how the SKSE-side shim
and the external Python service stay synchronized in real time, given two
concrete, documented race conditions from research
(docs/research/05-save-reload-sync-protocol.md,
docs/research/06-save-reload-timeline-sync.md):
kPostLoadGame(native, fires early) and Papyrus'sOnPlayerLoadGame(fires later, and never on a brand-new game) are two non-synchronized "load done" signals — neither alone means the game is actually ready to be queried.- A stale-write race: an in-flight async operation (an LLM call, a network round trip) started before a reload can complete after the reload and write its result into the wrong timeline if nothing fences it.
- Documented crash evidence (SkyrimNet issue #465): querying Papyrus form
properties immediately on
kMessage_PostLoadGame, before the engine finishes populating property caches, null-pointer-dereferences.
Decision¶
Writes are gated on watermark receipt plus an explicit game_ready
ping — never on kPostLoadGame alone.
- New-game vs. reload detection uses both hooks. The shim runs its
init routine from both
OnInit()(fires on brand-new game only) andOnPlayerLoadGame()(fires on every subsequent load, never on new game) — this is the standard idiom for covering both cases, since neither hook alone fires in all situations. - The shim owns a
g_isLoadingflag. Set true onkMessage_PreLoadGame; while true, all event-generation hooks (dialogue, location transitions, relationship shifts) are suppressed and queued locally, not transmitted. - Watermark handshake, not a bare load signal. On
kPostLoadGame, once the co-save's timeline record (save_uuid,generation,event_seq/gamets) has been parsed, the shim sends it to the service as aSYNC_TIMELINEmessage. The service compares it against its branch head (per ADR-0004: equal → continue, in the past → fork, unknownsave_uuid→ switch/create that character's branch), then repliesTIMELINE_READYwith an epoch fencing token. - Epoch fencing gates every subsequent write. Every load or new game
increments an
epoch_id. EveryMUTATION_EVENTthe shim sends must carry the epoch it was issued under. The service discards any mutation whose epoch is older than its current active epoch — this is what prevents a stale async response from a pre-reload operation landing in the post-reload timeline. g_isLoadingclears only after the full handshake completes —kPostLoadGamefired, the timeline record was transmitted, andTIMELINE_READYwas received. Only then does the shim resume sending live events, and only under the new epoch.- Papyrus-derived triggers get an extra guard. To avoid the
property-cache-warmup crash class (SkyrimNet #465), the shim delays
firing any event trigger that reads Papyrus form properties until a
minimum delay after
kPostLoadGame(research suggests ~200ms as a starting point, not a verified constant) or until an explicit Papyrus init heartbeat, whichever comes first. - Idempotency and back-pressure. The service dedupes on
(save_uuid, generation, event_seq)— necessary because network retries or rapid quicksave/quickload can plausibly redeliver or reorder messages. SkyrimNet's own bug tracker documents it "cannot keep up with game load" during bursts; the service must tolerate a burst of buffered events arriving right afterTIMELINE_READY, not assume steady-state throughput. - Uncommitted state between saves is volatile. Events since the last
save live in a buffer keyed to the active
(save_uuid, generation). ASAVE_CREATEDnotification commits the buffer. A reload/death without saving discards it — those events never happened in any surviving timeline, so they must not be folded into any branch's derived state.
The co-save manifest schema¶
docs/research/09-save-sync-forensics.md specifies a concrete, minimal
record for the co-save chunk this ADR's watermark handshake reads and
writes. Adopted as-is, with field names aligned to ADR-0004's
(save_uuid, generation) branch key:
| Field | Type | Purpose |
|---|---|---|
format_version |
uint16 | schema evolution — SKSE co-save records carry a version natively; bump this, never reinterpret an old layout |
save_uuid |
16 bytes | which playthrough/timeline (ADR-0004) |
generation |
16 bytes or uint64 | which branch of that timeline (ADR-0004's fork counter) |
parent_generation |
16 bytes or uint64 | fork ancestry; null/zero for the root |
head_seq |
uint64 | the last service-ACKed event sequence on this branch — not "last attempted," so a save can never reference uncommitted state |
gamets |
float64 | in-game clock at save time — bitemporal valid time (ADR-0004) |
wall_ts |
int64 | real time at save — bitemporal transaction time (ADR-0004) |
char_name_hash |
uint64 | display/debug only, never a key — Mantella's name-keyed identity is the documented counterexample (its community resources page maintains a manual list of vanilla NPCs who change name/refID mid-game, precisely because name-keyed identity ages badly) |
Keep this record small — a manifest, not a database. SKSE's unbuffered per-call co-save writes are slow for large payloads (the S.L.A.C.K. plugin exists solely to fix this for other mods); everything bulky stays server-side, and this record should cost well under a hundred bytes.
The HELLO/RESOLVE/ACK handshake¶
This is the concrete protocol underlying the watermark handshake in the
Decision section above — SYNC_TIMELINE below is the HELLO, the
service's branch-head comparison is RESOLVE, and TIMELINE_READY is the
ACK. Three messages, initiated only after the load has already succeeded:
- Manifest capture — during the co-save Load callback (after Revert has cleared the prior session's copy), read the manifest into memory. No manifest present means a legacy/first-run save.
- HELLO (
SYNC_TIMELINE) — onkPostLoadGame(success=true), the shim asynchronously posts{save_uuid, generation, head_seq, gamets, wall_ts}to the service. Non-blocking — gameplay continues regardless of the answer (see "never block," below). - RESOLVE → ACK (
TIMELINE_READY) — the service classifies the manifest against its branch head and responds with a decision, per the six-way table below; the shim applies it and only then clearsg_isLoadingand starts tagging outbound events with the resolved(save_uuid, generation)(and epoch, per this ADR's fencing rule).
| Condition at RESOLVE | Decision | Service action |
|---|---|---|
Same branch, head_seq ≥ manifest's, gamets equal/newer |
CONTINUE | Resume branch; replay any un-ACKed gap events |
Same save_uuid, gamets older than branch head |
FORK | Create child generation at the nearest checkpoint ≤ loaded gamets (ADR-0004); mark old branch orphaned + start its grace timer; new events route to the child |
save_uuid known, generation unknown (e.g. a copied/cloud-restored save) |
ADOPT | Treat as a fork from the manifest's head_seq; link ancestry via parent_generation |
save_uuid unknown, character/profile seen before |
NEW TIMELINE | Open a fresh branch; optionally offer import of that character's public knowledge |
| No manifest present | LEGACY IMPORT | Bootstrap from heuristics (save filename's embedded save ID, character name), then write a manifest on the next save |
| Service unreachable at HELLO | DEGRADED | See "never block," below |
Fork resolution is automatic for small jumps, confirmed only for large
ones. Dying and reloading a save from seconds-to-minutes ago on the same
branch resolves as a silent CONTINUE or small FORK — no player-facing
prompt. A confirmation prompt (mirroring SkyrimNet's own
ClearTimelineMessage/msgClearHistory, which is exactly this UX for its
own rollback — see open-questions.md's now-closed SkyrimNet-reload-
behavior item) is reserved for jumps large enough to plausibly represent
the player deliberately returning to an old save, not every death-retry. A
prompt on every death is, per report 09, "the fast path to users disabling
the system."
The never-block rule (DEGRADED mode)¶
The game side is optimistic and never blocks; the service side is pessimistic and validates every event against the currently-ACKed branch head. Concretely: if the service is unreachable at HELLO time, or slow, the shim does not stall the loading screen or gameplay — it buffers outbound events in a bounded local queue (spilling to disk if the queue fills) and reconciles on reconnect. This is a stronger, explicit version of this ADR's existing back-pressure tolerance requirement (item 7, below): DEGRADED is what happens before a connection exists at all, not just under load. On the service side, every incoming event is still validated against the ACKed branch head/epoch regardless of how it arrived (live, or replayed from a DEGRADED buffer) — the asymmetry is deliberate: the game must never choose correctness over responsiveness, and the service must never choose responsiveness over correctness.
Rationale¶
All four save/reload research reports converged on gating writes on an
explicit handshake rather than trusting the native load message alone, and
all four independently proposed a fencing-token-style mechanism (report
06 names it explicitly as "epoch fencing"; report 05 describes the same
effect as "gate writes on receipt of the watermark"; report 07, arrived at
while researching SkyrimNet's platform risk rather than save/reload
directly, proposes its own SYNC_READY/mute-the-pipeline handshake with
the same shape; report 09 names it HELLO/RESOLVE/ACK and is the one this
ADR's manifest schema and decision table are adopted from directly, since
it grounds the same mechanism in actual CHIM/SkyrimNet bug history rather
than architecture alone). Treating these as one mechanism avoids building
several overlapping race-prevention systems, and the four-way independent
convergence is stronger evidence for the design than any single report
would be alone.
Consequences¶
- The wire protocol between
adapters/skyrim/and the Python service needs at minimum four message types:CLIENT_INIT(handshake),SYNC_TIMELINE(shim → service, post-load — the HELLO),TIMELINE_READY(service → shim, carries the epoch token — the ACK),MUTATION_EVENT(shim → service, carries the epoch). Exact transport (HTTP vs. WebSocket) is not decided by this ADR — see0001-external-service-architecture.md; per ADR-0003 the choice is a property of which SAL provider (SkyrimNet vs. the standalone bridge) is active, not a separate open question. chronicle/'s event-derivation logic must reject (or the adapter layer must never forward) events carrying a stale epoch — this is an adapter concern, not achronicle/concern, sincechronicle/stays engine- agnostic and doesn't know what an "epoch" is, only what branch an event belongs to.- The 200ms Papyrus-property-cache delay is a starting tunable, not a verified constant — needs empirical tuning once the shim exists against a real game process.
Implementation-risk notes¶
See docs/decisions/open-questions.md — the .skse/.ess pairing is
atomic by convention only, so a crash between the co-save write and the
.ess write remains a residual risk this handshake doesn't fully close;
a scenario test for that case is worth adding once the shim exists
(scenarios/sync/crash-mid-save, see below).
Report 09's failure matrix and six-pattern race catalog are converted
directly into named regression scenarios under scenarios/sync/ — see
that directory for the full list. Each scenario stub names a specific
race or failure this ADR's handshake must survive, so "does the handshake
actually work" stays a testable claim rather than a design-doc assertion.