From 78620bbbc2b7d2d6f6efd6127435594f955ac6e0 Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Fri, 24 Jul 2026 16:01:08 -0700 Subject: [PATCH] Flush Thought roots before rebuilding effect children. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intent: eliminate Bevy relationship-hook failures when a changed Thought route retires while the shared renderer is replacing its child population. Why: both route-root retirement and effect-child replacement were deferred, so the renderer could observe an old root and queue fresh children after that root and its previous hierarchy had already been despawned. Defense: name the shared population-rebuild set, flush game-owned root reconciliation before it, and pin both zero-orphan behavior and the composition-root schedule seam. Amend thought-fluid.md so lifecycle ordering remains part of the no-second-physics contract. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- crates/misaligned-assets/src/effects.rs | 71 ++++++++++++++++++- crates/misaligned-bevy/src/main.rs | 15 +++- crates/misaligned-bevy/src/thought_effects.rs | 16 +++++ wiki/interface/thought-fluid.md | 9 ++- .../2026-07-24-bevy-effect-root-lifecycle.md | 54 ++++++++++++++ wiki/log/DEVLOG.md | 5 ++ wiki/process/tick-ledger.md | 1 + 7 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 wiki/log/2026-07-24-bevy-effect-root-lifecycle.md diff --git a/crates/misaligned-assets/src/effects.rs b/crates/misaligned-assets/src/effects.rs index 3012d3b8..641c345a 100644 --- a/crates/misaligned-assets/src/effects.rs +++ b/crates/misaligned-assets/src/effects.rs @@ -230,6 +230,17 @@ impl MaterialEffects { /// Add this plugin in both the effects lab and the material game frontend. pub struct MaterialEffectsPlugin; +/// Ordering seam for callers that create, update, or retire effect roots. +/// +/// A caller that can despawn a root must flush that command before this set. +/// Otherwise the population rebuild can observe the old root, queue fresh +/// `ChildOf` visuals, and apply them after the root and its previous children +/// have already been despawned. +#[derive(SystemSet, Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum MaterialEffectsSet { + RebuildPopulation, +} + impl Plugin for MaterialEffectsPlugin { fn build(&self, app: &mut App) { // The renderer is shared by multiple binaries. Keep its shader inside @@ -253,7 +264,7 @@ impl Plugin for MaterialEffectsPlugin { animate_thought_material, animate_implicit_surface, select_lod, - rebuild_effect_population, + rebuild_effect_population.in_set(MaterialEffectsSet::RebuildPopulation), animate_thought, animate_far_drift, animate_pools, @@ -816,6 +827,19 @@ fn route_tangent(start: Vec3, end: Vec3, t: f32) -> Vec3 { #[cfg(test)] mod tests { use super::*; + use bevy::ecs::schedule::ApplyDeferred; + + #[derive(Component)] + struct RetireBeforeRebuild; + + fn retire_marked_effect_roots( + mut commands: Commands, + roots: Query>, + ) { + for root in &roots { + commands.entity(root).despawn(); + } + } fn test_effects_app() -> App { let mut app = App::new(); @@ -824,7 +848,10 @@ mod tests { app.init_resource::>(); app.insert_resource(ThoughtSurfaceCache::default()); app.add_systems(Startup, setup_effect_assets); - app.add_systems(Update, rebuild_effect_population); + app.add_systems( + Update, + rebuild_effect_population.in_set(MaterialEffectsSet::RebuildPopulation), + ); app } @@ -958,6 +985,46 @@ mod tests { assert_eq!(count_components::(app.world_mut()), 0); } + #[test] + fn flushed_root_retirement_cannot_rebuild_orphan_effect_children() { + let mut app = test_effects_app(); + app.add_systems( + Update, + (retire_marked_effect_roots, ApplyDeferred) + .chain() + .before(MaterialEffectsSet::RebuildPopulation), + ); + let root = app + .world_mut() + .spawn(( + MaterialEffects { + show_thought_pools: false, + thought_rate: 2.0, + ..default() + }, + EffectLodState(EffectLod::Far), + )) + .id(); + + app.update(); + assert_eq!(count_components::(app.world_mut()), 6); + + // A changed effect would ordinarily rebuild its population this frame. + // Retiring and flushing the root first must remove the old population + // and keep the rebuild query from attaching fresh children to a dead id. + app.world_mut() + .entity_mut(root) + .insert(RetireBeforeRebuild) + .get_mut::() + .expect("effect root") + .thought_rate = 0.25; + app.update(); + + assert_eq!(count_components::(app.world_mut()), 0); + assert_eq!(count_components::(app.world_mut()), 0); + assert_eq!(count_components::(app.world_mut()), 0); + } + #[test] fn far_route_is_dashed_drift_not_close_slugs_or_a_continuous_spine() { let mut app = test_effects_app(); diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index c86831ba..751780f9 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -50,8 +50,8 @@ use misaligned::ui_projection::{ }; use misaligned::work_grid::{MachineIntensity, MachineMode, TokenFamily}; use misaligned_assets::effects::{ - MaterialEffectsPlugin, ThoughtVesselGeometry, ThoughtVesselStroke, thought_snap_segments, - thought_vessel_segments, + MaterialEffectsPlugin, MaterialEffectsSet, ThoughtVesselGeometry, ThoughtVesselStroke, + thought_snap_segments, thought_vessel_segments, }; use misaligned_assets::institution::{ InstitutionAssets, InstitutionHumanGait, InstitutionInstanceOptions, InstitutionPropKind, @@ -1750,7 +1750,16 @@ fn main() { .chain() .before(VisibilitySystems::VisibilityPropagate), ) - .add_systems(Update, sync_game_thought_effects.after(advance_sim)); + // Effect population owns children below each route root. Flush route-root + // creation and retirement before that renderer inspects Changed roots, or + // its deferred rebuild can attach fresh children after a root was despawned. + .add_systems( + Update, + (sync_game_thought_effects, ApplyDeferred) + .chain() + .after(advance_sim) + .before(MaterialEffectsSet::RebuildPopulation), + ); if let Some(harness) = harness { app.insert_resource(harness); } diff --git a/crates/misaligned-bevy/src/thought_effects.rs b/crates/misaligned-bevy/src/thought_effects.rs index 0ca3eac3..462aebaf 100644 --- a/crates/misaligned-bevy/src/thought_effects.rs +++ b/crates/misaligned-bevy/src/thought_effects.rs @@ -174,4 +174,20 @@ mod tests { assert_eq!(game_route_presentation(1.0), (1.0, 1.0, 1)); assert_eq!(game_route_presentation(4.0), (4.0, 3.0, 1)); } + + #[test] + fn route_root_changes_flush_before_shared_population_rebuild() { + let source = include_str!("main.rs"); + let registration = source + .split("// Effect population owns children below each route root.") + .nth(1) + .expect("effect lifecycle schedule registration") + .split("if let Some(harness)") + .next() + .expect("bounded lifecycle registration"); + assert!(registration.contains("(sync_game_thought_effects, ApplyDeferred)")); + assert!(registration.contains(".chain()")); + assert!(registration.contains(".after(advance_sim)")); + assert!(registration.contains(".before(MaterialEffectsSet::RebuildPopulation)")); + } } diff --git a/wiki/interface/thought-fluid.md b/wiki/interface/thought-fluid.md index d75845ea..aeb9fd69 100644 --- a/wiki/interface/thought-fluid.md +++ b/wiki/interface/thought-fluid.md @@ -47,7 +47,10 @@ Status note: authored 2026-07-10 on dispatch as the render design for persistent device tap into fed=false post-drain sag, then freeze those readouts without inserting frontend Thought state. Unit regressions pin far population, route shape, fill/feed geometry, contraction, render layers, and - both live scenarios. The exact color captures and a six-state grayscale + both live scenarios. Effect-root creation and retirement flush before the + shared renderer rebuilds its child population, so a route disappearing on a + changed frame cannot leave orphan visuals or target a despawned parent. The + exact color captures and a six-state grayscale audit were visually inspected and logged in `log/2026-07-14-thought-fluid-implemented.md`. Together with the already-live sim readouts, terminal/agent parity, save restoration, close implicit body, @@ -424,7 +427,9 @@ them. 9. **No second physics:** the render holds no fluid state that is not recomputable from the tick's readouts (interpolation phase, springs, and merge animation only) — auditable in code review as the absence - of any accumulating thought counter in either frontend. + of any accumulating thought counter in either frontend. Route-root + lifecycle changes flush before shared effect-population rebuilds; retiring + one changed route leaves neither orphan children nor invalid relationships. 10. **The first-think proof:** from a fresh run, toggling THINK on the host starts that same real loop but opening.md's black boundary hides every slug, wire, vessel, and panel until a sense lands. The dedicated diff --git a/wiki/log/2026-07-24-bevy-effect-root-lifecycle.md b/wiki/log/2026-07-24-bevy-effect-root-lifecycle.md new file mode 100644 index 00000000..586478fb --- /dev/null +++ b/wiki/log/2026-07-24-bevy-effect-root-lifecycle.md @@ -0,0 +1,54 @@ +# 2026-07-24 — Flush Thought roots before rebuilding their children + +``` +Type: log +``` + +## Signal + +The live Bevy frontend repeatedly logged relationship-hook failures in one +frame: old effect-child entities were already despawned, then fresh `ChildOf` +components targeted effect-root entities that were also already despawned. +The process kept running, but the commands leaked detached presentation +entities and turned an ordinary route transition into error output. + +## Cause + +The game reconciles one shared-effect root for every active Thought route. +Separately, `MaterialEffectsPlugin` replaces that root's disposable child +population whenever its effect inputs or LOD change. Both systems use deferred +commands. If a changed route disappeared in the same Update, the renderer +could observe the still-live old root and queue old-child despawns plus fresh +children while the game queued the root's despawn. Applying the root command +first recursively removed the old hierarchy; the remaining renderer commands +then addressed dead child and parent ids. + +The failing entity counts matched the renderer's far-LOD populations: stale +despawns followed by two- and six-dash child insertions against dead roots. + +## Repair + +- `misaligned-assets` exposes one narrow `MaterialEffectsSet::RebuildPopulation` + ordering seam on the shared child-population system. +- The game chains `sync_game_thought_effects` with `ApplyDeferred` after sim + advancement and before that set. New roots exist before population is built; + retired roots are absent from the rebuild query. +- A shared-effects behavior test changes and retires a populated far-LOD root + on one frame, then proves the root and every effect visual are gone. +- A Bevy composition-root regression pins the required game-to-plugin order. + +## Defense + +The change does not add presentation state or hide relationship warnings. It +makes ownership executable: the game owns effect-root lifetime, the shared +renderer owns children, and the root transition becomes real before child +reconciliation can observe it. `thought-fluid.md` now carries that lifecycle +condition under its no-second-physics criterion. + +## Verification + +- `cargo test -p misaligned-assets` +- `cargo test -p misaligned-bevy` +- `./tools/check.sh --land` — frontend mode, including format, both package + suites, Clippy, corpus/wiki gates, generated-ledger freshness, and project + operation fixtures diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 66e392b3..ac9a80a4 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -51,6 +51,11 @@ add or amend a session log, then re-run the generator. - Intent: A crash-reduction pass after the outward-filing fix. Several hot-path `.expect` / `panic!` sites still aborted a live session when exact custody could not be authored: air-gapped Network emission, missing egress at Wager settlement, missing persona after a gate check, and inte... - Log: [wiki/log/2026-07-24-crash-reduction-soft-fail.md](2026-07-24-crash-reduction-soft-fail.md) +## 2026-07-24 - Flush Thought roots before rebuilding their children + +- Intent: (see session log) +- Log: [wiki/log/2026-07-24-bevy-effect-root-lifecycle.md](2026-07-24-bevy-effect-root-lifecycle.md) + ## 2026-07-24 - Agent headless playtest: the witness under test - Intent: The 2026-07-22 Co GUI report left one unresolved charge: the continuous witness names a condition but supplies no executable response. That projection is renderer-neutral, so agent mode can test it without a window — and without taking over the machine, which the Bevy screensh... diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index df9fb517..e8a090c0 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -20,6 +20,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | Slice | Last audited | Verdict | Trace | |---|---|---|---| +| `wiki/interface/thought-fluid.md` + shared effect lifecycle | 2026-07-24 | finding | live Bevy logs exposed a deferred-command race: when a changed Thought route disappeared, the game queued its root despawn while the shared renderer queued child replacement from the same old root. Command application could remove the root and old children first, then issue stale child despawns and `ChildOf` insertions against the dead id. Route-root reconciliation now flushes before the renderer's named population-rebuild set; behavioral and composition-root regressions pin zero orphan visuals and the exact ordering seam — [log](../log/2026-07-24-bevy-effect-root-lifecycle.md) | | `wiki/art/visual-identity.md` + `wiki/interface/flat-materials.md` | 2026-07-22 | finding | the role semantics still matched production—amber selection, crimson consequence, cold signal, and the pooled-material audits all held—but the claimed single-source palette existed twice: one Bevy-local table and one asset-library table whose comment still called sharing future work after the shared rack had entered production. Bevy, rack, institution, Thought effects, and the asset tester now import `misaligned_assets::palette`; authored chassis/mercury values are named there, and source-shape defenses reject another frontend table or inline shared procedural-material colors — [log](../log/2026-07-22-shared-clinical-palette.md) | | `wiki/interface/superhuman-operability.md` + Bevy opening | 2026-07-22 | finding | current-build naive + informed GUI audit after the 2026-07-21 fixes: title trust, pointer naming, annotation placement, and tab labels improved, but the first sense still releases the mature map, clock, threat, resource grammar, intel custody, and Operations policies at once. The report preserves the classified evidence and prioritizes one paused, one-cause / one-object / one-verb post-perception teaching lock before ordinary play opens - [report](../playtests/2026-07-22-playtest-gui-human-legibility.md), [log](../log/2026-07-22-gui-human-legibility-playtest.md) | | `wiki/process/living-spec.md` no-dead-code + core orphans | 2026-07-21 | finding | user-directed dead-code hunt: rustc was quiet because orphans were `pub`. Deleted unused `BuildMode`/`build_items`/`build_cost`, orphaned helpers (`delete_save`, `adjust_allocation`, `sell_latest_intel`, scheme-card/rate helpers, `flow_risk_preview_lines`, unused account/origin/intent/message helpers), and migration-only `WatchPerson`; architecture/economy/sim-mechanics mirrors brought current — [log](../log/2026-07-21-dead-code-scrub.md) | -- 2.51.2