brink_runtime/save.rs
1//! `save_state` / `load_state`: produce and reconcile the durable,
2//! name-keyed [`SaveState`] game-state save.
3//!
4//! [`SaveState`] (defined in `brink-format`) is distinct from the in-memory
5//! [`StorySnapshot`](crate::StorySnapshot), which captures full execution
6//! position and is locked to one exact program build. `SaveState` captures
7//! only *game state* — globals, visit/turn counts, turn index, RNG — keyed by
8//! stable identities (variable name; scope `DefinitionId`), so a save survives
9//! a story recompile/patch as long as the relevant names/paths are unchanged.
10//! Execution position is deliberately not captured; the host re-enters a
11//! conversation at a known knot. See `docs/external-binding-foundation.md`.
12//!
13//! **F6.1b:** the logic lives as free functions over `&Program` +
14//! `&impl ContextAccess`, not as `Story` methods, so any holder of a flow's
15//! context — `Story`'s own `default_context`, or a `bevy-brink` `ContextView`
16//! over a shared `World` plus an entity's `FlowLocal` — can save/load without
17//! going through `Story` at all. [`Story::save_state`]/[`Story::load_state`]
18//! now delegate to these, unchanged in observable behavior.
19//!
20//! **Enumeration.** `ContextAccess` has no iteration surface (a `ContextView`
21//! can't hand back "every visited id" — it only answers point queries routed
22//! by scope), so the candidate id set for visits/turns comes from the
23//! `Program`'s own container definitions rather than map iteration. Every
24//! container the VM ever visit/turn-counts carries `CountingFlags::VISITS`:
25//! `vm.rs`'s `EnterContainer`/goto paths only ever call
26//! `increment_visit`/`set_turn_count` when that flag is set on the target
27//! container. (The converter *does* set `CountingFlags::TURNS` independently,
28//! mirroring inklecate's container flags — but since every VM counting site
29//! gates on `VISITS` alone, a TURNS-only container can never accrue a runtime
30//! entry.) So containers with `VISITS` set are exactly the superset of ids
31//! that could have a visit *or* turn entry. Iterating `Program::containers` (a `Vec`,
32//! not a hash map) keeps enumeration order deterministic independent of
33//! `Program`'s internal id tables.
34//!
35//! For each candidate id: `ContextAccess::visit_count` returns `0` for an id
36//! the context has never visited (`World::increment_visit` only ever inserts
37//! on the first increment, `or_insert(0) += 1`), so a `0` here means "never
38//! visited" and is skipped — that reproduces the old code's
39//! present-entries-only output, which iterated `World`'s
40//! `visit_counts: HashMap` directly and so only ever saw ids that had
41//! actually been inserted. `turn_count` returns `Option<u32>`, so absence is
42//! directly distinguishable from an explicit `0` without a sentinel value.
43//! Output is sorted by id explicitly (`Vec::sort_by_key`) rather than relying
44//! on `Program::containers`' container-index order — byte-identical save
45//! output is a hard requirement independent of container layout.
46
47use brink_format::{CountingFlags, LoadReport, SAVE_FORMAT_VERSION, SaveState, VisitEntry};
48
49use crate::StoryRng;
50use crate::debug::NameResolver;
51use crate::program::Program;
52use crate::state::ContextAccess;
53use crate::story::Story;
54
55/// Capture a flow's game state as a durable, name-keyed [`SaveState`]. Does
56/// not capture execution position — see the module docs.
57///
58/// `ctx` can be any [`ContextAccess`] implementor: `World` directly, a
59/// `ContextView` routing over `(World, FlowLocal)` (in which case every value
60/// captured is the **effective** value for that flow — a `Local` override
61/// where present, else `World`'s value on a read-through miss), or an
62/// `ObservedContext` wrapping either.
63#[must_use]
64pub fn save_state<C: ContextAccess + ?Sized>(program: &Program, ctx: &C) -> SaveState {
65 let resolver = NameResolver::new(program);
66
67 let globals = (0..program.global_count())
68 .filter_map(|idx| {
69 program
70 .global_slot_name(idx as usize)
71 .map(|name| (name.to_owned(), ctx.global(idx).clone()))
72 })
73 .collect();
74
75 let mut visits = Vec::new();
76 let mut turns = Vec::new();
77 for container in &program.containers {
78 if !container.counting_flags.contains(CountingFlags::VISITS) {
79 continue;
80 }
81 let id = container.id;
82
83 let count = ctx.visit_count(id);
84 if count > 0 {
85 visits.push(VisitEntry {
86 id,
87 path: resolver.def_path(id).map(str::to_owned),
88 count,
89 });
90 }
91
92 if let Some(turn) = ctx.turn_count(id) {
93 turns.push(VisitEntry {
94 id,
95 path: resolver.def_path(id).map(str::to_owned),
96 count: turn,
97 });
98 }
99 }
100 visits.sort_by_key(|e| e.id.to_raw());
101 turns.sort_by_key(|e| e.id.to_raw());
102
103 SaveState {
104 version: SAVE_FORMAT_VERSION,
105 globals,
106 visits,
107 turns,
108 turn_index: ctx.turn_index(),
109 rng_seed: ctx.rng_seed(),
110 previous_random: ctx.previous_random(),
111 }
112}
113
114/// Reconcile a [`SaveState`] into a flow's context, returning a
115/// [`LoadReport`] of anything that couldn't be applied. Globals are matched
116/// by name; visit/turn counts by id. Tolerant of story patches: unknown
117/// globals are reported, scopes the program no longer has retain their saved
118/// counts harmlessly in the live context. Note one deliberate change from the
119/// pre-F6.1b `Story` methods: such stale entries are **not re-emitted by a
120/// subsequent [`save_state`]** (which enumerates the *current* program's
121/// containers, not the live maps) — ghost counts from older program versions
122/// no longer round-trip through saves indefinitely.
123///
124/// Writes go through [`ContextAccess`], so on a `ContextView` they route by
125/// scope exactly like any other write: a `World`-scoped unit lands in the
126/// shared `World`, a `Local`-scoped unit in the flow's own `FlowLocal`
127/// override layer.
128pub fn load_state<C: ContextAccess + ?Sized>(
129 program: &Program,
130 ctx: &mut C,
131 save: &SaveState,
132) -> LoadReport {
133 let mut report = LoadReport::default();
134
135 for (name, value) in &save.globals {
136 match program.global_index(name) {
137 Some(idx) => ctx.set_global(idx, value.clone()),
138 None => report.unknown_globals.push(name.clone()),
139 }
140 }
141
142 ctx.set_turn_index(save.turn_index);
143 ctx.set_rng_seed(save.rng_seed);
144 ctx.set_previous_random(save.previous_random);
145 for e in &save.visits {
146 ctx.set_visit_count(e.id, e.count);
147 }
148 for e in &save.turns {
149 ctx.set_turn_count(e.id, e.count);
150 }
151
152 report
153}
154
155impl<R: StoryRng> Story<R> {
156 /// Capture the default flow's game state as a durable, name-keyed
157 /// [`SaveState`]. Does not capture execution position. Thin delegating
158 /// wrapper over the free [`save_state`] function — see the module docs.
159 #[must_use]
160 pub fn save_state(&self) -> SaveState {
161 save_state(self.program(), &self.default_context)
162 }
163
164 /// Reconcile a [`SaveState`] into the default flow's context. Thin
165 /// delegating wrapper over the free [`load_state`] function — see the
166 /// module docs.
167 pub fn load_state(&mut self, save: &SaveState) -> LoadReport {
168 let program = self.program_arc();
169 load_state(&program, &mut self.default_context, save)
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use std::sync::Arc;
176
177 use super::*;
178 use crate::link;
179 use crate::rng::FastRng;
180
181 /// Compile a small ink story with the brink compiler and link it.
182 fn compile_for_flow(src: &str) -> (Program, Vec<Vec<brink_format::LineEntry>>) {
183 let out = brink_compiler::compile("t.ink", |p| {
184 if p == "t.ink" {
185 Ok(src.to_string())
186 } else {
187 Err(std::io::Error::new(
188 std::io::ErrorKind::NotFound,
189 "no such include",
190 ))
191 }
192 })
193 .expect("compile");
194 link(&out.data).expect("link")
195 }
196
197 /// `DefinitionId`s are content-hash-based (`brink_format::id`), not an
198 /// incrementing counter tied to declaration order — so visiting knots in
199 /// declaration order already scrambles hash order, and `Program`'s
200 /// container `Vec` (declaration order) doesn't coincidentally match id
201 /// order either. `save_state`'s explicit `sort_by_key` is what
202 /// guarantees `SaveState::visits`/`turns` come out id-sorted regardless
203 /// of visit order or container layout — this locks that invariant down.
204 #[test]
205 fn visits_are_sorted_by_id_regardless_of_visit_order() {
206 let (program, tables) = compile_for_flow(
207 "-> alpha\n\
208 === alpha ===\n\
209 Alpha.\n\
210 -> DONE\n\
211 === beta ===\n\
212 Beta.\n\
213 -> DONE\n\
214 === gamma ===\n\
215 Gamma.\n\
216 -> DONE\n\
217 === reader ===\n\
218 {READ_COUNT(-> alpha)} {READ_COUNT(-> beta)} {READ_COUNT(-> gamma)}\n\
219 -> DONE\n",
220 // `reader` is never entered at runtime — it exists only so the
221 // compiler's counting-flags pass (`apply_counting_flags` in
222 // brink-ir) sees a `READ_COUNT` reference to each knot and sets
223 // `CountingFlags::VISITS` on it. Without a visit-count *read*
224 // somewhere in the program, the compiler leaves counting
225 // disabled for a knot (an optimization) and the VM never calls
226 // `increment_visit`/`set_turn_count` for it at all.
227 );
228 let program = Arc::new(program);
229 let mut story = crate::Story::<FastRng>::new(Arc::clone(&program), tables);
230
231 // Visit alpha (root divert), then gamma, then beta — an order that
232 // matches neither declaration order nor (necessarily) id order.
233 story.continue_maximally().expect("continue");
234 story.choose_path_string("gamma").expect("jump");
235 story.continue_maximally().expect("continue");
236 story.choose_path_string("beta").expect("jump");
237 story.continue_maximally().expect("continue");
238
239 let save = story.save_state();
240 assert_eq!(
241 save.visits.len(),
242 3,
243 "alpha/beta/gamma should each have a visit entry: {:?}",
244 save.visits
245 );
246
247 let ids: Vec<u64> = save.visits.iter().map(|e| e.id.to_raw()).collect();
248 let mut sorted = ids.clone();
249 sorted.sort_unstable();
250 assert_eq!(ids, sorted, "SaveState::visits must be sorted by id");
251 }
252}