Skip to main content

bevy_brink/
replay.rs

1//! Dev-mode replay log: capture per-flow start state + choices so the
2//! plugin can rebuild flows on hot-reload without losing player progress.
3//!
4//! Available only when the `dev` feature is enabled. The plugin
5//! automatically attaches a [`BrinkReplayLog<M>`] to every fulfilled
6//! [`BrinkFlow<M>`](crate::BrinkFlow), and a reload-handler system
7//! reacts to `AssetEvent::Modified<ProgramAsset>` by rebuilding each
8//! tracked flow against the new bytecode + replaying recorded choices.
9//!
10//! To make choices replayable, call [`BrinkFlow::choose_recording`]
11//! (rather than `flow.inner.choose`) — that path appends to the log.
12
13use std::marker::PhantomData;
14
15use bevy_asset::{AssetEvent, Assets, Handle};
16use bevy_ecs::component::Component;
17use bevy_ecs::entity::Entity;
18use bevy_ecs::message::MessageReader;
19use bevy_ecs::resource::Resource;
20use bevy_ecs::system::{Commands, Query, Res, ResMut};
21use bevy_ecs::world::World;
22use bevy_log::{info, warn};
23use brink_format::LineEntry;
24use brink_runtime::{
25    ContextAccess, ExternalFnHandler, FastRng, FlowInstance, FlowLocal, ReplayHandler, ReplayMode,
26    ReplayRecorder, RuntimeError,
27};
28
29use crate::asset::{BrinkProgram, BrinkStoryAsset, LineTablesAsset, ProgramAsset};
30use crate::capability::{CapabilityManifest, CapabilityRegistry, check_load_capability_gate};
31use crate::event::BrinkFlowReset;
32use crate::flow::BrinkFlow;
33use crate::globals::{BrinkContext, BrinkGlobals, flow_context_view};
34use crate::request::FlowStart;
35
36/// Global default [`ReplayMode`] for hot-reload replay (the shared
37/// [`brink_runtime`] primitive). Override on a specific flow with
38/// [`ReplayQueryModeOverride`].
39#[derive(Resource, Clone, Copy, Debug, Default)]
40pub struct BrinkReplayConfig {
41    /// Replay mode used when a flow has no [`ReplayQueryModeOverride`].
42    pub query_mode: ReplayMode,
43}
44
45/// Per-flow override of the global [`BrinkReplayConfig`] replay mode. Insert on
46/// a flow entity to make that flow replay with a specific [`ReplayMode`].
47#[derive(Component, Clone, Copy, Debug)]
48pub struct ReplayQueryModeOverride(pub ReplayMode);
49
50/// Per-flow log used to reconstruct the flow on hot-reload.
51///
52/// Inserted alongside [`BrinkFlow<M>`] by the fulfillment system when
53/// the `dev` feature is enabled. Contains the start address, the story
54/// handle (so we can find the new program after reload), and the running
55/// list of choice selections + recorded external results. There is no
56/// per-flow `World` snapshot to restore — every flow's `FlowLocal` starts
57/// fresh at spawn (see the F6 AMENDMENT in
58/// `docs/scoped-flow-state-spec.md`), so reconstruction resets the entity's
59/// [`BrinkContext`](crate::BrinkContext) to a fresh [`FlowLocal`] and
60/// re-walks against the (unreset, still-live) shared
61/// [`BrinkGlobals`](crate::BrinkGlobals) `World`.
62///
63/// **Known limitation (dev-only):** because the shared `World` is never
64/// reset, a `World`-scoped unit this flow already wrote during its original
65/// play (e.g. a visit count bumped by entering a knot) is *not* undone
66/// before the re-walk — the re-walk bumps it again. Under the all-`World`
67/// default this means repeated hot-reloads can drift `World`-scoped
68/// visit/turn counts upward each time. This is a real consequence of the
69/// shared-by-default model (the pre-F6.2 per-flow-private-`World` replay
70/// could safely reset-then-rewalk because nothing was shared); fully
71/// solving it (e.g. a per-flow world-write journal to undo) is out of scope
72/// for F6.2 — flag it if it becomes a practical problem.
73#[derive(Component)]
74pub struct BrinkReplayLog<M: Send + Sync + 'static = ()> {
75    /// Where this flow began executing.
76    pub start: FlowStart,
77    /// The story this flow is bound to (for re-resolving the program
78    /// asset after reload).
79    pub story: Handle<BrinkStoryAsset>,
80    /// Choices made so far, in order. Populated by
81    /// [`BrinkFlow::choose_recording`].
82    pub choices_made: Vec<usize>,
83    /// External-call results recorded during live play (the shared
84    /// [`ReplayRecorder`]), replayed back during reload reconstruction so
85    /// query-gated branches resolve faithfully instead of via fallback.
86    pub recorder: ReplayRecorder,
87    _marker: PhantomData<fn() -> M>,
88}
89
90impl<M: Send + Sync + 'static> BrinkReplayLog<M> {
91    pub(crate) fn new(start: FlowStart, story: Handle<BrinkStoryAsset>) -> Self {
92        Self {
93            start,
94            story,
95            choices_made: Vec::new(),
96            recorder: ReplayRecorder::new(),
97            _marker: PhantomData,
98        }
99    }
100}
101
102/// Plugin-managed system: when `ProgramAsset` reloads (file watcher saw
103/// a change), rebuild each tracked flow against the new bytecode and
104/// replay any recorded choices to restore approximate position.
105///
106/// Behavior:
107/// - For each entity with both `BrinkFlow<M>` and `BrinkReplayLog<M>`:
108///   1. Reset the entity's [`BrinkContext<M>`] to a fresh, empty
109///      [`FlowLocal`] (the shared [`BrinkGlobals<M>`] `World` is left as-is
110///      — see [`BrinkReplayLog`]'s "known limitation" doc).
111///   2. Resolve `log.start` against the new program; build fresh `FlowInstance`.
112///   3. For each choice in `log.choices_made`: step until a `Choices` line
113///      appears, then call `choose(idx)`. If anything fails (choice index
114///      out of range, runtime error), warn and stop replaying.
115///   4. Replace the entity's `BrinkFlow<M>` component.
116///
117/// If the new program no longer has the start address (e.g. user
118/// renamed the knot), warn and leave the flow in a fresh-start state.
119#[expect(
120    clippy::needless_pass_by_value,
121    clippy::type_complexity,
122    reason = "bevy systems take Res/Query by value and have complex query tuples"
123)]
124#[expect(
125    clippy::too_many_arguments,
126    reason = "bevy system: flow/program/story/line-table state plus the #997 capability manifest+registry gate"
127)]
128#[expect(
129    clippy::too_many_lines,
130    reason = "bevy system: hot-reload reconstruction plus the #997 capability load-boundary gate"
131)]
132pub fn replay_on_reload<M: Send + Sync + 'static>(
133    mut events: MessageReader<AssetEvent<ProgramAsset>>,
134    mut flows: Query<(
135        Entity,
136        &mut BrinkFlow<M>,
137        &BrinkProgram<M>,
138        &mut BrinkContext<M>,
139        &mut BrinkReplayLog<M>,
140    )>,
141    globals: Option<ResMut<BrinkGlobals<M>>>,
142    programs: Res<Assets<ProgramAsset>>,
143    stories: Res<Assets<BrinkStoryAsset>>,
144    line_tables_assets: Res<Assets<LineTablesAsset>>,
145    capability_manifest: Res<CapabilityManifest>,
146    capability_registry: Res<CapabilityRegistry<M>>,
147    mut commands: Commands,
148) {
149    let Some(mut globals) = globals else {
150        return; // no flow has ever been fulfilled for this marker
151    };
152    // Drain events; we only care that *some* program changed. Per-flow
153    // routing is handled by the BrinkProgram handle below.
154    let mut any_modified = false;
155    for event in events.read() {
156        if matches!(event, AssetEvent::Modified { .. }) {
157            any_modified = true;
158        }
159    }
160    if !any_modified {
161        return;
162    }
163
164    for (entity, mut flow, brink_program, mut context, log) in &mut flows {
165        let Some(program_asset) = programs.get(&brink_program.handle) else {
166            continue;
167        };
168
169        // Issue #997 (the #912 load-boundary gate's sibling-path gap): the
170        // reloaded program's capabilities must re-clear this marker's
171        // registry before we reconstruct anything against it — a hot
172        // reload that drops (or never had) a manifest-required capability
173        // must fail exactly as loudly as the initial `fulfill_flow_requests`
174        // load, not silently rebuild a flow that can no longer resolve its
175        // externals. Checked before the `BrinkFlowReset` trigger fires, so
176        // a rejected reload leaves the entity's existing (pre-reload) flow
177        // untouched rather than resetting it and then aborting partway.
178        let story_ident = log
179            .story
180            .path()
181            .map_or_else(|| format!("{:?}", log.story.id()), ToString::to_string);
182        if let Err(err) = check_load_capability_gate(
183            &program_asset.program,
184            &program_asset.effect_rows,
185            &capability_manifest,
186            &capability_registry,
187            story_ident,
188        ) {
189            warn!("replay: {err}; leaving entity {entity:?} on its pre-reload flow");
190            continue;
191        }
192
193        // Look up the new line tables via the story bundle. Without
194        // this, the post-reload walk would read NEW program against
195        // OLD tables and either render stale text or fail to resolve
196        // new string IDs.
197        let line_tables: &[Vec<LineEntry>] = match stories
198            .get(&log.story)
199            .and_then(|bundle| line_tables_assets.get(&bundle.line_tables))
200        {
201            Some(lt_asset) => &lt_asset.tables,
202            None => continue,
203        };
204
205        // Tell consumers a rebuild is starting *before* we fire any
206        // line-delivery events from replay. Triggers process in order,
207        // so observers for BrinkFlowReset run first (typically clearing
208        // UI state), then the per-line events repopulate.
209        commands.trigger(BrinkFlowReset::<M>::new(entity));
210
211        // Resolve start position against the (possibly changed) program.
212        let new_flow_result = match &log.start {
213            FlowStart::Root => Some(FlowInstance::new_at_root(&program_asset.program)),
214            FlowStart::Address(name) => program_asset
215                .program
216                .find_address(name)
217                .map(|(idx, _)| FlowInstance::new_at(&program_asset.program, idx)),
218        };
219
220        let Some((new_flow, _fresh_ctx)) = new_flow_result else {
221            warn!(
222                "replay: knot '{:?}' missing in reloaded program; entity {entity:?} will start at root",
223                log.start
224            );
225            let (root_flow, _) = FlowInstance::new_at_root(&program_asset.program);
226            commands
227                .entity(entity)
228                .insert(BrinkFlow::<M>::new(root_flow));
229            continue;
230        };
231
232        // Reset the per-flow FlowLocal to fresh/empty — every flow's local
233        // layer starts empty at spawn, so a rebuild starts the same way.
234        // The shared World is deliberately left untouched (see
235        // `BrinkReplayLog`'s "known limitation" doc).
236        context.inner = FlowLocal::new();
237
238        // Replace the in-place flow with the freshly-built one.
239        flow.inner = new_flow;
240
241        // Split the log so the recorded externals can drive replay (via a
242        // single `ReplayHandler` over the whole re-walk) while we read the
243        // recorded choices from a disjoint field. Recorded during live play
244        // by `advance_flow`; fed back here so query-gated branches resolve
245        // faithfully instead of via fallback (and recorded effects don't
246        // re-fire — replay re-executes nothing). Uncovered / divergent calls
247        // fall through to the ink fallback body, exactly as before.
248        let log = log.into_inner();
249        let replay = ReplayHandler::new(&mut log.recorder);
250
251        // Replay each recorded choice *silently* — we step the VM
252        // through to each choice point and consume the choice without
253        // firing observer events. The events would mislead consumers
254        // into thinking those intermediate choice points are the
255        // current state, but they're bookkeeping; the actual current
256        // state is whatever comes after the last choose.
257        let mut replay_failed = false;
258        for (i, &choice_idx) in log.choices_made.iter().enumerate() {
259            let mut view = flow_context_view(&mut globals, &mut context);
260            if let Err(err) = step_to_next_choices(
261                &mut flow.inner,
262                &program_asset.program,
263                line_tables,
264                &mut view,
265                &replay,
266            ) {
267                warn!(
268                    "replay: failed to reach choice point {i} for entity {entity:?}: {err}; \
269                     stopping replay"
270                );
271                replay_failed = true;
272                break;
273            }
274            let mut view = flow_context_view(&mut globals, &mut context);
275            if let Err(err) = flow.inner.choose(&mut view, choice_idx) {
276                warn!(
277                    "replay: choose({choice_idx}) at step {i} for entity {entity:?}: {err}; \
278                     stopping replay"
279                );
280                replay_failed = true;
281                break;
282            }
283        }
284
285        if replay_failed {
286            continue;
287        }
288
289        // Now advance until the next terminal *with* events firing, so
290        // the UI sees the user's current page in the new program.
291        let mut view = flow_context_view(&mut globals, &mut context);
292        match flow.advance_until_terminal(
293            &program_asset.program,
294            line_tables,
295            &mut view,
296            &replay,
297            entity,
298            &mut commands,
299        ) {
300            Ok(_) => {
301                info!(
302                    "replay: rebuilt flow on entity {entity:?} from start={:?} +{} choice(s)",
303                    log.start,
304                    log.choices_made.len()
305                );
306            }
307            Err(err) => {
308                warn!("replay: advance after replay failed on entity {entity:?}: {err}");
309            }
310        }
311    }
312}
313
314/// Step the flow forward silently until we land on a terminal line
315/// (`Choices`, `Done`, or `End`).
316///
317/// Used during replay reconstruction: we walk the new bytecode to each
318/// choice point so we can re-apply the recorded selection. No observer
319/// events are fired — these intermediate steps are bookkeeping, not
320/// the user's current state. The post-replay `advance_until_terminal`
321/// in [`replay_on_reload`] is what fires events for the actual current
322/// page.
323///
324/// Delegates to the shared Layer-2 [`FlowInstance::drive_to_terminal`] op
325/// (F6.2) — the produced `Line`s are discarded (this walk is silent by
326/// design), but the shared loop is what gives it the bounded
327/// [`FlowInstance::LINE_LIMIT`] safety cap instead of a hand-rolled one.
328/// `ReplayHandler` (the only handler this is ever called with) never
329/// defers, so `drive_to_terminal`'s "errors instead of pausing on a
330/// deferred external" behavior never actually triggers here.
331fn step_to_next_choices(
332    flow: &mut FlowInstance,
333    program: &brink_runtime::Program,
334    line_tables: &[Vec<LineEntry>],
335    context: &mut (impl ContextAccess + ?Sized),
336    handler: &dyn ExternalFnHandler,
337) -> Result<(), RuntimeError> {
338    flow.drive_to_terminal::<FastRng>(program, line_tables, context, handler, None)?;
339    Ok(())
340}
341
342/// Take the flow's [`ReplayRecorder`] out of its [`BrinkReplayLog<M>`], leaving
343/// an empty one behind. Returns `None` for an entity with no replay log (not a
344/// dev-tracked flow).
345///
346/// Paired with [`put_recorder`]: an exclusive `&mut World` driver
347/// ([`advance_flow`](crate::advance_flow)) takes the recorder, wraps its handler
348/// with a [`RecordingHandler`](brink_runtime::RecordingHandler) (and records
349/// out-of-band query results) for the duration of the pass, then puts it back —
350/// avoiding holding the component borrowed across the `run_system_with`
351/// re-borrows of the World.
352pub(crate) fn take_recorder<M: Send + Sync + 'static>(
353    world: &mut World,
354    entity: Entity,
355) -> Option<ReplayRecorder> {
356    world
357        .get_mut::<BrinkReplayLog<M>>(entity)
358        .map(|mut log| std::mem::take(&mut log.recorder))
359}
360
361/// Restore a recorder taken by [`take_recorder`] into the flow's
362/// [`BrinkReplayLog<M>`]. A no-op if the log is gone (entity despawned or the
363/// component removed mid-pass).
364pub(crate) fn put_recorder<M: Send + Sync + 'static>(
365    world: &mut World,
366    entity: Entity,
367    recorder: ReplayRecorder,
368) {
369    if let Some(mut log) = world.get_mut::<BrinkReplayLog<M>>(entity) {
370        log.recorder = recorder;
371    }
372}
373
374/// Record one out-of-band external result into the flow's [`BrinkReplayLog<M>`]
375/// recorder, if the entity has one. Used by the world-access / async / task
376/// resolve sites — which resolve *after* the VM parks (`ExternalResult::Pending`)
377/// and so supply their value here rather than through the inline
378/// [`RecordingHandler`](brink_runtime::RecordingHandler).
379///
380/// Always recording (for any dev-tracked flow) is safe even when the flow is
381/// driven by a non-recording `step_one`: a partial recording simply diverges to
382/// the ink fallback body earlier during replay (never feeding a misaligned
383/// value), so it is never worse than recording nothing.
384pub(crate) fn record_external<M: Send + Sync + 'static>(
385    world: &mut World,
386    entity: Entity,
387    name: &str,
388    args: &[brink_format::Value],
389    result: &brink_format::Value,
390) {
391    if let Some(mut log) = world.get_mut::<BrinkReplayLog<M>>(entity) {
392        log.recorder.record(name, args, result);
393    }
394}
395
396// ── Issue #997: the #912 load-boundary capability gate must also cover ────
397// this dev-only hot-reload reconstruction path, not just the initial
398// `fulfill_flow_requests` load.
399#[cfg(test)]
400mod tests {
401    use bevy_app::App;
402    use bevy_asset::Assets;
403    use bevy_ecs::component::Component;
404    use bevy_ecs::prelude::*;
405
406    use crate::capability::{
407        BrinkCapabilityAppExt as _, CapabilityEffects, CapabilityManifest,
408        CapabilityManifestExternal,
409    };
410    use crate::request::BrinkFlowRequest;
411    use crate::test_support::{add_story_assets, compile_test_story};
412
413    /// Counts `BrinkFlowReset<()>` triggers. `replay_on_reload` fires this
414    /// *after* the capability gate clears (see the gate's placement ahead of
415    /// the trigger in the function body) — so a count of 0 after a simulated
416    /// reload means the gate refused the rebuild before touching the entity
417    /// at all; a count of 1 means the reload proceeded normally.
418    #[derive(Resource, Default)]
419    struct ResetCount(u32);
420
421    fn install_reset_counter(app: &mut App) {
422        app.insert_resource(ResetCount::default());
423        app.add_observer(
424            |_: On<crate::event::BrinkFlowReset<()>>, mut count: ResMut<ResetCount>| {
425                count.0 += 1;
426            },
427        );
428    }
429
430    /// Compile `source` and return `(Program, line tables, EffectRowEntry
431    /// rows)` — unlike `test_support::compile_test_story`, this keeps the
432    /// real compiled effect rows (rather than discarding them) so a test can
433    /// exercise `missing_capabilities`/`check_load_capability_gate` against a
434    /// program that actually calls a manifest-declared external.
435    fn compile_with_effect_rows(
436        source: &str,
437    ) -> (
438        brink_runtime::Program,
439        Vec<Vec<brink_format::LineEntry>>,
440        Vec<brink_format::EffectRowEntry>,
441    ) {
442        let source = source.to_string();
443        let out = brink_compiler::compile("t.ink", move |p| {
444            if p == "t.ink" {
445                Ok(source.clone())
446            } else {
447                Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
448            }
449        })
450        .expect("test fixture should compile");
451        let mut inkb = Vec::new();
452        brink_format::write_inkb(&out.data, &mut inkb);
453        let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
454        let (program, tables) = brink_runtime::link(&loaded).expect("link");
455        (program, tables, loaded.effect_rows)
456    }
457
458    /// A manifest declaring that the `get_position` external reads the
459    /// `Transform` capability — shared by both tests below. Whether the
460    /// reload is admitted or refused depends solely on whether the
461    /// marker's `CapabilityRegistry` has `Transform` registered.
462    fn install_transform_manifest(app: &mut App) {
463        let mut manifest = CapabilityManifest::default();
464        manifest.externals.push(CapabilityManifestExternal {
465            name: "get_position".to_string(),
466            effects: CapabilityEffects {
467                reads: vec!["Transform".to_string()],
468                writes: vec![],
469                detect: std::collections::BTreeMap::new(),
470            },
471        });
472        app.insert_resource(manifest);
473    }
474
475    #[derive(Component)]
476    struct Transform;
477
478    const V1_SOURCE: &str = "=== start ===\nhello\n-> END\n";
479    const V2_SOURCE_CALLS_GET_POSITION: &str = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nBRAND NEW WORDS\n-> END\n";
480
481    /// Hot-reload with a missing capability fails loudly: reloading to a
482    /// program version that calls an external requiring a capability this
483    /// marker's registry never registered must refuse to rebuild the flow
484    /// (no `BrinkFlowReset` fires), exactly as the initial load boundary
485    /// (#912) already refuses to admit such a story in the first place.
486    #[test]
487    fn hot_reload_missing_capability_refuses_rebuild() {
488        let mut app = App::new();
489        app.add_plugins(bevy_asset::AssetPlugin::default());
490        app.add_plugins(crate::BrinkPlugin::<()>::default());
491        install_transform_manifest(&mut app);
492        install_reset_counter(&mut app);
493        // Deliberately never call `register_capability::<(), Transform>` —
494        // this marker's registry has no `Transform` entry.
495
496        let (program_v1, tables_v1, ctx_v1) = compile_test_story(V1_SOURCE);
497        let story = add_story_assets(&mut app, program_v1, tables_v1, ctx_v1);
498        app.world_mut().spawn(
499            BrinkFlowRequest::<()>::builder()
500                .story(story.clone())
501                .build(),
502        );
503        app.update(); // fulfill
504
505        let (program_v2, tables_v2, effect_rows_v2) =
506            compile_with_effect_rows(V2_SOURCE_CALLS_GET_POSITION);
507
508        let program_handle = {
509            let stories = app.world().resource::<Assets<crate::BrinkStoryAsset>>();
510            stories.get(&story).expect("story bundle").program.clone()
511        };
512        let line_tables_handle = {
513            let stories = app.world().resource::<Assets<crate::BrinkStoryAsset>>();
514            stories
515                .get(&story)
516                .expect("story bundle")
517                .line_tables
518                .clone()
519        };
520        {
521            let mut programs = app
522                .world_mut()
523                .resource_mut::<Assets<crate::asset::ProgramAsset>>();
524            if let Some(mut slot) = programs.get_mut(&program_handle) {
525                slot.program = program_v2;
526                slot.effect_rows = effect_rows_v2;
527            }
528        }
529        {
530            let mut tables = app
531                .world_mut()
532                .resource_mut::<Assets<crate::asset::LineTablesAsset>>();
533            if let Some(mut slot) = tables.get_mut(&line_tables_handle) {
534                slot.tables = tables_v2;
535            }
536        }
537
538        // Two ticks: propagate the asset event, then flush any deferred
539        // triggers — mirrors the pattern the existing hot-reload tests use.
540        app.update();
541        app.update();
542
543        assert_eq!(
544            app.world().resource::<ResetCount>().0,
545            0,
546            "a reload that would drop below the manifest-required Transform \
547             capability must be refused before BrinkFlowReset fires — the \
548             #912 hard-error boundary must hold on the replay path too"
549        );
550    }
551
552    /// Normal reload is unaffected: the exact same reload as above, but with
553    /// `Transform` registered on this marker's registry, must proceed and
554    /// rebuild the flow exactly as before this gate was added to the replay
555    /// path — proving the fix doesn't regress the ordinary hot-reload case.
556    #[test]
557    fn hot_reload_with_satisfied_capability_rebuilds_normally() {
558        let mut app = App::new();
559        app.add_plugins(bevy_asset::AssetPlugin::default());
560        app.add_plugins(crate::BrinkPlugin::<()>::default());
561        install_transform_manifest(&mut app);
562        install_reset_counter(&mut app);
563        app.register_capability::<(), Transform>("Transform");
564
565        let (program_v1, tables_v1, ctx_v1) = compile_test_story(V1_SOURCE);
566        let story = add_story_assets(&mut app, program_v1, tables_v1, ctx_v1);
567        app.world_mut().spawn(
568            BrinkFlowRequest::<()>::builder()
569                .story(story.clone())
570                .build(),
571        );
572        app.update(); // fulfill
573
574        let (program_v2, tables_v2, effect_rows_v2) =
575            compile_with_effect_rows(V2_SOURCE_CALLS_GET_POSITION);
576
577        let program_handle = {
578            let stories = app.world().resource::<Assets<crate::BrinkStoryAsset>>();
579            stories.get(&story).expect("story bundle").program.clone()
580        };
581        let line_tables_handle = {
582            let stories = app.world().resource::<Assets<crate::BrinkStoryAsset>>();
583            stories
584                .get(&story)
585                .expect("story bundle")
586                .line_tables
587                .clone()
588        };
589        {
590            let mut programs = app
591                .world_mut()
592                .resource_mut::<Assets<crate::asset::ProgramAsset>>();
593            if let Some(mut slot) = programs.get_mut(&program_handle) {
594                slot.program = program_v2;
595                slot.effect_rows = effect_rows_v2;
596            }
597        }
598        {
599            let mut tables = app
600                .world_mut()
601                .resource_mut::<Assets<crate::asset::LineTablesAsset>>();
602            if let Some(mut slot) = tables.get_mut(&line_tables_handle) {
603                slot.tables = tables_v2;
604            }
605        }
606
607        app.update();
608        app.update();
609
610        assert_eq!(
611            app.world().resource::<ResetCount>().0,
612            1,
613            "a reload whose required capabilities are all registered must \
614             proceed exactly as before — the gate must not block a \
615             legitimate reload"
616        );
617    }
618}