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) => <_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}