Guard editor shutdown and track clean savepoints

This commit is contained in:
Rbanh 2026-07-13 05:12:43 -04:00
parent 62b538999d
commit 4b33f32357
19 changed files with 1944 additions and 129 deletions

View File

@ -0,0 +1,62 @@
# Guarded Shutdown And History Savepoints
Date: 2026-07-13
Issue: BS-PR-709 / Gitea #55
Milestone: M7 - Production Readiness
## Goal
Route every editor-exit surface through one non-blocking dirty-document decision and make scene
dirtiness follow the authored undo timeline. Closing the native window, choosing File > Quit, and
requesting an editor exit programmatically must never silently discard dirty scene tabs.
## Decisions
- The editor disables Bevy's automatic close-and-exit systems and owns primary-window close
requests. A guarded-shutdown resource is the single authority that may emit `AppExit`.
- The final dirty-state recheck and `AppExit` authorization run in a dedicated schedule after
Bevy's `Last`; session clean-marker persistence runs after that finalizer.
- Native window close, File > Quit, and internal editor quit requests enqueue the same shutdown
intent. Repeated requests while confirmation or Save As is pending are coalesced.
- Clean sessions exit immediately. Dirty sessions use the shared `NativeDialogBroker` and a single
Save All / Discard / Cancel decision. Cancel preserves all tabs and live editor state; Discard
exits without rewriting authored files; Save All exits only after every dirty tab is saved.
- Untitled tabs and other asynchronous Save As work keep shutdown pending until their dialog result
is applied on the main thread. Cancellation or any failed write returns the editor to an idle,
dirty, recoverable state and does not emit `AppExit`.
- Each scene tab retains a canonical authored-content checkpoint established only by a successful
load or save. The checkpoint is keyed by stable actor identity, canonical component order, stable
parent identity, and scene composition rather than transient Bevy entity numbers.
- Push marks the active document dirty. Undo and redo serialize the authoritative authored
projection and compare it with that tab's checkpoint, so saving at nonzero history depth,
returning to that point, branching, tab switches, and direct non-history mutations remain exact.
## Implementation
1. Add per-tab canonical clean checkpoints to scene I/O and reconcile `SceneIo::dirty` after push,
undo, redo, save, load, and document switches without persisting Entity-ID-based history stacks.
2. Add focused history tests for undo-to-clean, redo-away-from-clean, save at nonzero depth,
branching before/after the clean point, and tab isolation.
3. Add a guarded-shutdown plugin/resource that intercepts `WindowCloseRequested`, accepts menu and
programmatic requests, coordinates non-blocking confirmation/save completion, and emits the only
final `AppExit` for the full editor.
4. Make Save All report complete, pending, cancelled, or failed explicitly. Resume pending shutdown
after untitled Save As completion and preserve the originating tab plus dirty state on failure.
5. Add File > Quit and route Switch Project's exit half through the same guard without spawning a
replacement process until the dirty-document decision succeeds.
6. Update ADR 0023, native-dialog/session-recovery documentation, editor architecture guidance,
root README controls/checklist, and production-readiness evidence.
7. Run formatting, strict Clippy, workspace tests, and focused headless state-machine tests. Keep
packaged tests deferred by project-owner direction.
8. Launch the exact editor commit under Hyprland. Exercise Cancel, Discard, Save All, clean close,
File > Quit, and the compositor close button; verify the editor remains responsive during native
dialogs, no state is lost on cancel/failure, and no process or warning remains after exit.
## Acceptance
- Native close, File > Quit, and programmatic editor exit share one guarded implementation.
- One or many dirty tabs cannot be lost without explicit Discard; failed or cancelled saves do not
exit and preserve the session.
- Undoing exactly to the saved state clears the dirty marker; redo or a divergent edit restores it.
- Saving at nonzero history depth establishes a new clean point without deleting useful history.
- Headless state-machine tests and exact-commit native Linux QA pass with clean logs.

View File

@ -57,8 +57,9 @@ cargo run -p editor --bin project_launcher --features dev
```
The installed **Blacksite Editor** desktop entry also exposes **Open Project Browser** from its
desktop action menu. In the editor, **File > Switch Project...** performs a clean shutdown and
opens the same browser; choosing a project starts a fresh editor process with that root.
desktop action menu. In the editor, **File > Switch Project...** uses the guarded Save All / Discard /
Cancel shutdown path before opening the same browser; choosing a project starts a fresh editor
process with that root.
### Hot reload (gameplay iteration)
@ -136,7 +137,7 @@ deep-stale variants.
| Click empty viewport / `Esc` | Deselect |
| `Delete` / `Backspace` | Delete selection |
| `Ctrl+D` | Duplicate selection |
| `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo |
| `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo; returning exactly to the last loaded or saved authored state clears that scene tab's dirty marker |
| `F2` in Hierarchy | Rename selection |
| `W` / `E` / `R` | Translate / rotate / scale gizmo; multi-selection uses one grouped gizmo and undo step |
| `X` | Toggle world/local gizmo orientation |
@ -165,7 +166,8 @@ deep-stale variants.
| Drag actor between rows / onto Scene Root (Hierarchy, Manual sort) | Reorder siblings / unparent to the root |
| Hierarchy lock | Excludes the actor from selection, gizmos, multi-drag, structural drops, and mutating context actions |
| Hierarchy context | Group selection, create authored local children below linked prefab roots, remove/reparent generated members through same-layer structural overrides, or unparent |
| File menu | New, non-blocking native Open/Save As, **Open Sample** for the five-area regression pack, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes |
| File menu | New, non-blocking native Open/Save As, **Open Sample** for the five-area regression pack, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes, Switch Project, and Quit |
| Window close / **File > Quit** | Clean sessions exit immediately; dirty scene tabs use one non-blocking **Save All / Discard / Cancel** decision, and cancel or failed saves keep the editor open |
| Status strip / Asset Details source-state chip | Inspect compact clean, modified, untracked, conflicted, read-only, and optional ownership status; hover for the source path and provider details |
| Authored File Not Saved dialog | Resolve an external edit, read-only target, or ownership lock with Reload, Compare Metadata, Save As, or Cancel; the editor never offers force overwrite |
| Main toolbar, right side | Switch or close independent scene tabs, create an untitled tab, and manage loaded/locked composed subscenes |
@ -410,6 +412,7 @@ crates/
- [x] Transactional physics placement with real Avian gravity/colliders, paused Edit-mode physics, prerequisite diagnostics, isolated non-selected bodies, exact cancel, and grouped transform undo ([ADR 0041](docs/adr/0041-transactional-editor-physics-placement.md), [workflow guide](docs/editor/physics-placement.md), [Gitea #25](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/25))
- [x] Collider authoring health shared by the inspector, Collider viewport, Diagnostics panel, and physics placement, with scaled shape overlays, cooked mesh bounds, missing/stale/invalid/oversized findings, and undoable dimension-preserving shape switching ([collider guide](docs/editor/collider-authoring.md), [Gitea #26](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/26))
- [x] Non-blocking native file/folder/confirmation broker across scene, asset, prefab, composition, collaboration, and Project Browser workflows ([ADR 0038](docs/adr/0038-non-blocking-native-dialog-broker.md), [workflow guide](docs/editor/native-dialogs.md), [Gitea #52](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/52))
- [x] Guarded native/menu/programmatic editor shutdown with asynchronous multi-tab Save All / Discard / Cancel and canonical per-tab clean checkpoints for exact undo/redo dirtiness ([ADR 0042](docs/adr/0042-guarded-editor-shutdown-and-document-savepoints.md), [Gitea #55](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/55))
- [x] Asset import, static mesh/prefab placement, texture assignment, and selection export
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)
- [x] Unified viewport render-to-texture target + Play session bootstrap

View File

@ -1631,7 +1631,7 @@ pub fn apply_command_undo(world: &mut World) {
undo_command(world, &mut command);
world.resource_mut::<EditorHistory>().push_redo(command);
world.resource_mut::<EditorHistory>().set_redo_status(label);
mark_dirty(world);
crate::scene_io::reconcile_active_dirty_with_checkpoint(world);
}
pub fn apply_command_redo(world: &mut World) {
@ -1652,7 +1652,7 @@ pub fn apply_command_redo(world: &mut World) {
redo_command(world, &mut command);
world.resource_mut::<EditorHistory>().push_undo(command);
world.resource_mut::<EditorHistory>().set_undo_status(label);
mark_dirty(world);
crate::scene_io::reconcile_active_dirty_with_checkpoint(world);
}
fn prepare_prefab_source_history(

View File

@ -30,6 +30,7 @@ pub use project::project_io;
pub use project::samples;
pub use project::session;
pub use project::settings_ui;
pub use project::shutdown;
pub use scene::scene_io;
pub use scene::scene_schema;
pub use scene::scene_view;
@ -76,6 +77,7 @@ use play::PlaySessionPlugin;
use project::collaboration::CollaborationPlugin;
use project::native_dialog::NativeDialogPlugin;
use project::samples::SampleCatalogPlugin;
use project::shutdown::ShutdownPlugin;
use project_io::ProjectIoPlugin;
use render_view::RenderViewPlugin;
use scene_io::SceneIoPlugin;
@ -102,6 +104,7 @@ impl PluginGroup for EditorPluginGroup {
.add(ProjectIoPlugin)
.add(SampleCatalogPlugin)
.add(NativeDialogPlugin)
.add(ShutdownPlugin)
.add(scene_schema::SceneSchemaPlugin)
.add(net_editor::NetEditorPlugin)
.add(AssetDbPlugin)
@ -143,7 +146,7 @@ impl PluginGroup for EditorPluginGroup {
/// Shared Bevy app wiring for the in-process editor (game sim + egui shell).
pub fn configure_editor_app(app: &mut App) {
app.add_plugins(launch::default_plugins("Bevy FPS Editor"))
app.add_plugins(launch::editor_plugins("Bevy FPS Editor"))
.insert_resource(GameInputEnabled(false))
.insert_resource(GameInputFocused(false))
.insert_resource(SimEnabled(false))

View File

@ -8,3 +8,4 @@ pub mod project_io;
pub mod samples;
pub mod session;
pub mod settings_ui;
pub mod shutdown;

View File

@ -11,6 +11,7 @@ use crate::project_io::{ProjectWorkspace, UserPreferences};
use crate::scene::recovery::{atomic_write, default_state_root};
use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::settings_ui::ProjectSettingsPanel;
use crate::shutdown::ShutdownCoordinator;
use crate::ui::{BrushDiagnosticsPanel, DiagnosticsPanel};
use crate::viewport::rendering_diagnostics::RenderingDiagnosticsPanel;
use crate::viewport::CameraBookmarks;
@ -109,8 +110,8 @@ impl Plugin for EditorSessionPlugin {
.add_systems(Startup, initialize_session)
.add_systems(Update, persist_running_session)
.add_systems(
Last,
persist_clean_session_on_exit.after(bevy::window::ExitSystems),
crate::shutdown::EditorShutdownFinalize,
persist_clean_session_on_exit.after(crate::shutdown::finalize_authorized_shutdown),
);
}
}
@ -311,6 +312,7 @@ fn persist_running_session(world: &mut World) {
#[allow(clippy::too_many_arguments)]
fn persist_clean_session_on_exit(
mut exits: MessageReader<AppExit>,
shutdown: Option<Res<ShutdownCoordinator>>,
scene_io: Res<SceneIo>,
workspace: Res<ProjectWorkspace>,
prefs: Res<UserPreferences>,
@ -323,6 +325,10 @@ fn persist_clean_session_on_exit(
if exits.read().next().is_none() {
return;
}
if !shutdown.is_some_and(|shutdown| shutdown.authorized_exit_sent()) {
warn!("Ignoring an unauthorized AppExit for clean-session persistence");
return;
}
let Some(path) = session_path() else {
return;
};

View File

@ -0,0 +1,752 @@
//! Guarded editor shutdown and the handoff to scene persistence.
use bevy::app::{AppExit, MainScheduleOrder};
use bevy::ecs::schedule::ScheduleLabel;
use bevy::prelude::*;
use bevy::window::{PrimaryWindow, WindowCloseRequested};
use crate::native_dialog::NativeDialogBroker;
use crate::scene_io::SceneIo;
const SAVE_ALL_LABEL: &str = "Save All";
const DISCARD_LABEL: &str = "Discard";
const CANCEL_LABEL: &str = "Cancel";
/// The user or editor surface that initiated a guarded shutdown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownSource {
NativeWindow,
FileMenu,
Programmatic,
SwitchProject,
}
/// Observable state of the single editor shutdown flow.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ShutdownIntent {
#[default]
Idle,
WaitingForBroker {
source: ShutdownSource,
},
Confirming {
source: ShutdownSource,
},
Saving {
source: ShutdownSource,
},
Authorized {
source: ShutdownSource,
require_clean_scenes: bool,
},
ExitSent {
source: ShutdownSource,
},
}
impl ShutdownIntent {
pub fn source(self) -> Option<ShutdownSource> {
match self {
Self::Idle => None,
Self::WaitingForBroker { source }
| Self::Confirming { source }
| Self::Saving { source }
| Self::Authorized { source, .. }
| Self::ExitSent { source } => Some(source),
}
}
}
/// Coordinates all editor exit requests so only an authorized flow emits [`AppExit`].
#[derive(Resource, Debug, Default)]
pub struct ShutdownCoordinator {
intent: ShutdownIntent,
coalesced_requests: u32,
last_blocker: Option<String>,
}
impl ShutdownCoordinator {
pub fn intent(&self) -> ShutdownIntent {
self.intent
}
pub fn coalesced_requests(&self) -> u32 {
self.coalesced_requests
}
pub fn last_blocker(&self) -> Option<&str> {
self.last_blocker.as_deref()
}
pub fn authorized_exit_sent(&self) -> bool {
matches!(self.intent, ShutdownIntent::ExitSent { .. })
}
/// Queue a shutdown if no shutdown flow is already active.
///
/// Repeated window, menu, or programmatic requests are coalesced until the
/// current flow is cancelled, fails to save, or sends the authorized exit.
pub fn request(&mut self, source: ShutdownSource) -> bool {
if self.intent != ShutdownIntent::Idle {
self.coalesced_requests = self.coalesced_requests.saturating_add(1);
return false;
}
self.intent = ShutdownIntent::WaitingForBroker { source };
self.coalesced_requests = 0;
self.last_blocker = None;
true
}
#[cfg(test)]
pub(crate) fn set_intent_for_test(&mut self, intent: ShutdownIntent) {
self.intent = intent;
}
}
/// Emitted once when the guarded dialog chooses `Save All`.
///
/// Scene persistence owns the actual save sequence and must finish it with
/// [`complete_shutdown_save`].
#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShutdownSaveAllRequested {
pub source: ShutdownSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShutdownSaveOutcome {
Saved,
Cancelled,
Failed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownDecision {
SaveAll,
Discard,
Cancel,
}
/// Runs after Bevy's `Last` schedule so no editor authoring system can mutate
/// a document after the final dirty-state check in the same frame.
#[derive(ScheduleLabel, Debug, Clone, PartialEq, Eq, Hash)]
pub struct EditorShutdownFinalize;
/// Queue a guarded shutdown from an exclusive-world UI or workflow callback.
pub fn request_editor_shutdown(world: &mut World, source: ShutdownSource) -> bool {
world
.get_resource_mut::<ShutdownCoordinator>()
.is_some_and(|mut coordinator| coordinator.request(source))
}
/// Queue a project-browser handoff through the same dirty-document guard as editor exit.
pub fn request_project_switch(world: &mut World) -> bool {
request_editor_shutdown(world, ShutdownSource::SwitchProject)
}
/// Resolve the dirty-scene confirmation through the shared coordinator.
pub fn resolve_shutdown_decision(world: &mut World, decision: ShutdownDecision) -> bool {
let (save_request, status) = {
let Some(mut coordinator) = world.get_resource_mut::<ShutdownCoordinator>() else {
return false;
};
let ShutdownIntent::Confirming { source } = coordinator.intent else {
return false;
};
match decision {
ShutdownDecision::SaveAll => {
coordinator.intent = ShutdownIntent::Saving { source };
(
Some(source),
Some("Saving all modified scene tabs before shutdown"),
)
}
ShutdownDecision::Discard => {
coordinator.intent = ShutdownIntent::Authorized {
source,
require_clean_scenes: false,
};
(None, None)
}
ShutdownDecision::Cancel => {
coordinator.intent = ShutdownIntent::Idle;
(
None,
Some("Shutdown cancelled; unsaved scene tabs retained"),
)
}
}
};
if let Some(status) = status {
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
scene_io.set_status(status);
}
}
if let Some(source) = save_request {
world.write_message(ShutdownSaveAllRequested { source });
}
true
}
/// Finish the save-all handoff. A reported success is authorized only after
/// every scene tab is observably clean.
pub fn complete_shutdown_save(world: &mut World, outcome: ShutdownSaveOutcome) -> bool {
let scenes_are_clean = !scene_has_unsaved_changes(world);
let Some(mut coordinator) = world.get_resource_mut::<ShutdownCoordinator>() else {
return false;
};
let ShutdownIntent::Saving { source } = coordinator.intent else {
return false;
};
match outcome {
ShutdownSaveOutcome::Saved if scenes_are_clean => {
coordinator.intent = ShutdownIntent::Authorized {
source,
require_clean_scenes: true,
};
coordinator.last_blocker = None;
true
}
ShutdownSaveOutcome::Saved => {
coordinator.intent = ShutdownIntent::Idle;
coordinator.last_blocker =
Some("Save All completed while unsaved scene tabs remain".into());
false
}
ShutdownSaveOutcome::Cancelled => {
coordinator.intent = ShutdownIntent::Idle;
coordinator.last_blocker = None;
false
}
ShutdownSaveOutcome::Failed(error) => {
coordinator.intent = ShutdownIntent::Idle;
coordinator.last_blocker = Some(error);
false
}
}
}
pub struct ShutdownPlugin;
impl Plugin for ShutdownPlugin {
fn build(&self, app: &mut App) {
app.init_schedule(EditorShutdownFinalize);
app.world_mut()
.resource_mut::<MainScheduleOrder>()
.insert_after(Last, EditorShutdownFinalize);
app.init_resource::<ShutdownCoordinator>()
.add_message::<ShutdownSaveAllRequested>()
.add_systems(
Update,
(capture_native_close_requests, drive_shutdown).chain(),
)
.add_systems(EditorShutdownFinalize, finalize_authorized_shutdown);
}
}
fn capture_native_close_requests(
mut commands: Commands,
mut close_requests: MessageReader<WindowCloseRequested>,
primary_window: Query<Entity, With<PrimaryWindow>>,
mut coordinator: ResMut<ShutdownCoordinator>,
) {
let Ok(primary_window) = primary_window.single() else {
return;
};
for request in close_requests.read() {
if request.window == primary_window {
coordinator.request(ShutdownSource::NativeWindow);
} else {
commands.entity(request.window).despawn();
}
}
}
fn drive_shutdown(world: &mut World) {
let intent = world.resource::<ShutdownCoordinator>().intent;
match intent {
ShutdownIntent::Idle
| ShutdownIntent::Confirming { .. }
| ShutdownIntent::Saving { .. }
| ShutdownIntent::ExitSent { .. } => {}
ShutdownIntent::WaitingForBroker { source } => {
let unsaved_count = unsaved_scene_count(world);
if unsaved_count == 0 {
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::Authorized {
source,
require_clean_scenes: true,
};
return;
}
if world.resource::<NativeDialogBroker>().is_pending() {
return;
}
let action = if source == ShutdownSource::SwitchProject {
"switching projects"
} else {
"quitting"
};
let description = if unsaved_count == 1 {
format!("One scene tab has unsaved changes. Save it before {action}?")
} else {
format!(
"{unsaved_count} scene tabs have unsaved changes. Save all before {action}?"
)
};
let title = if source == ShutdownSource::SwitchProject {
"Switch Project"
} else {
"Quit Blacksite Editor"
};
let request = world.resource::<NativeDialogBroker>().request(
move || {
rfd::MessageDialog::new()
.set_title(title)
.set_description(description)
.set_level(rfd::MessageLevel::Warning)
.set_buttons(rfd::MessageButtons::YesNoCancelCustom(
SAVE_ALL_LABEL.into(),
DISCARD_LABEL.into(),
CANCEL_LABEL.into(),
))
.show()
},
|world, result| {
resolve_shutdown_decision(world, decision_from_dialog(result));
},
);
if request.is_ok() {
world.resource_mut::<ShutdownCoordinator>().intent =
ShutdownIntent::Confirming { source };
}
}
ShutdownIntent::Authorized { .. } => {}
}
}
/// Final dirty-state recheck after editor UI and authoring schedules have completed.
pub(crate) fn finalize_authorized_shutdown(world: &mut World) {
let (source, require_clean_scenes) = {
let coordinator = world.resource::<ShutdownCoordinator>();
let ShutdownIntent::Authorized {
source,
require_clean_scenes,
} = coordinator.intent
else {
return;
};
(source, require_clean_scenes)
};
if require_clean_scenes && scene_has_unsaved_changes(world) {
world.resource_mut::<ShutdownCoordinator>().intent =
ShutdownIntent::WaitingForBroker { source };
return;
}
if source == ShutdownSource::SwitchProject {
if let Err(error) = crate::launcher::spawn_project_launcher_process() {
let message = format!("Switch project failed: {error}");
{
let mut coordinator = world.resource_mut::<ShutdownCoordinator>();
coordinator.intent = ShutdownIntent::Idle;
coordinator.last_blocker = Some(message.clone());
}
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
scene_io.set_status(message);
}
return;
}
}
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::ExitSent { source };
debug!(?source, "Editor shutdown authorized");
world.write_message(AppExit::Success);
}
fn scene_has_unsaved_changes(world: &World) -> bool {
world
.get_resource::<SceneIo>()
.is_some_and(SceneIo::has_unsaved_tabs)
}
fn unsaved_scene_count(world: &World) -> usize {
let Some(scene_io) = world.get_resource::<SceneIo>() else {
return 0;
};
let tab_count = scene_io.tabs.iter().filter(|tab| tab.dirty).count();
if scene_io.dirty
&& scene_io
.tabs
.get(scene_io.active_tab)
.is_none_or(|tab| !tab.dirty)
{
tab_count + 1
} else {
tab_count
}
}
fn decision_from_dialog(result: rfd::MessageDialogResult) -> ShutdownDecision {
match result {
rfd::MessageDialogResult::Custom(label) if label == SAVE_ALL_LABEL => {
ShutdownDecision::SaveAll
}
rfd::MessageDialogResult::Custom(label) if label == DISCARD_LABEL => {
ShutdownDecision::Discard
}
rfd::MessageDialogResult::Yes => ShutdownDecision::SaveAll,
rfd::MessageDialogResult::No => ShutdownDecision::Discard,
_ => ShutdownDecision::Cancel,
}
}
#[cfg(test)]
mod tests {
use std::sync::mpsc;
use bevy::ecs::message::MessageCursor;
use super::*;
use crate::native_dialog::NativeDialogPlugin;
fn confirming_world() -> World {
let mut world = World::new();
world.init_resource::<Messages<ShutdownSaveAllRequested>>();
world.insert_resource(ShutdownCoordinator {
intent: ShutdownIntent::Confirming {
source: ShutdownSource::FileMenu,
},
..default()
});
world
}
#[test]
fn repeated_requests_are_coalesced_behind_the_first_source() {
let mut coordinator = ShutdownCoordinator::default();
assert!(coordinator.request(ShutdownSource::NativeWindow));
assert!(!coordinator.request(ShutdownSource::FileMenu));
assert_eq!(coordinator.coalesced_requests(), 1);
assert_eq!(
coordinator.intent(),
ShutdownIntent::WaitingForBroker {
source: ShutdownSource::NativeWindow
}
);
}
#[test]
fn cancel_returns_to_idle_and_discard_authorizes_exit() {
let mut cancel_world = confirming_world();
assert!(resolve_shutdown_decision(
&mut cancel_world,
ShutdownDecision::Cancel
));
assert_eq!(
cancel_world.resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::Idle
);
let mut discard_world = confirming_world();
assert!(resolve_shutdown_decision(
&mut discard_world,
ShutdownDecision::Discard
));
assert_eq!(
discard_world.resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::Authorized {
source: ShutdownSource::FileMenu,
require_clean_scenes: false,
}
);
}
#[test]
fn dirty_cancel_preserves_the_session_and_discard_emits_one_exit() {
let mut app = App::new();
app.add_message::<AppExit>()
.init_resource::<SceneIo>()
.init_resource::<ShutdownCoordinator>();
app.world_mut().resource_mut::<SceneIo>().mark_dirty();
app.world_mut()
.resource_mut::<ShutdownCoordinator>()
.set_intent_for_test(ShutdownIntent::Confirming {
source: ShutdownSource::Programmatic,
});
assert!(resolve_shutdown_decision(
app.world_mut(),
ShutdownDecision::Cancel
));
drive_shutdown(app.world_mut());
finalize_authorized_shutdown(app.world_mut());
assert_eq!(app.should_exit(), None);
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
app.world_mut()
.resource_mut::<ShutdownCoordinator>()
.set_intent_for_test(ShutdownIntent::Confirming {
source: ShutdownSource::Programmatic,
});
assert!(resolve_shutdown_decision(
app.world_mut(),
ShutdownDecision::Discard
));
drive_shutdown(app.world_mut());
finalize_authorized_shutdown(app.world_mut());
assert_eq!(app.should_exit(), Some(AppExit::Success));
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
assert!(app
.world()
.resource::<ShutdownCoordinator>()
.authorized_exit_sent());
}
#[test]
fn file_menu_and_programmatic_clean_exit_use_the_same_coordinator_path() {
for source in [ShutdownSource::FileMenu, ShutdownSource::Programmatic] {
let mut app = App::new();
app.add_message::<AppExit>()
.init_resource::<SceneIo>()
.init_resource::<ShutdownCoordinator>()
.init_resource::<NativeDialogBroker>();
assert!(request_editor_shutdown(app.world_mut(), source));
drive_shutdown(app.world_mut());
finalize_authorized_shutdown(app.world_mut());
assert_eq!(app.should_exit(), Some(AppExit::Success));
assert_eq!(
app.world().resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::ExitSent { source }
);
}
}
#[test]
fn save_all_emits_one_persistence_handoff() {
let mut world = confirming_world();
let mut cursor = MessageCursor::<ShutdownSaveAllRequested>::default();
assert!(resolve_shutdown_decision(
&mut world,
ShutdownDecision::SaveAll
));
assert!(!resolve_shutdown_decision(
&mut world,
ShutdownDecision::SaveAll
));
let messages = world.resource::<Messages<ShutdownSaveAllRequested>>();
let requests: Vec<_> = cursor.read(messages).copied().collect();
assert_eq!(
requests,
vec![ShutdownSaveAllRequested {
source: ShutdownSource::FileMenu
}]
);
}
#[test]
fn clean_native_close_emits_the_authorized_exit() {
let mut app = App::new();
app.add_message::<WindowCloseRequested>()
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
.init_resource::<SceneIo>();
let window = app.world_mut().spawn(PrimaryWindow).id();
app.world_mut()
.write_message(WindowCloseRequested { window });
app.update();
assert_eq!(app.should_exit(), Some(AppExit::Success));
assert_eq!(
app.world().resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::ExitSent {
source: ShutdownSource::NativeWindow
}
);
}
#[test]
fn secondary_window_close_does_not_request_editor_shutdown() {
let mut app = App::new();
app.add_message::<WindowCloseRequested>()
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
.init_resource::<SceneIo>();
app.world_mut().spawn(PrimaryWindow);
let secondary = app.world_mut().spawn_empty().id();
app.world_mut()
.write_message(WindowCloseRequested { window: secondary });
app.update();
assert_eq!(app.should_exit(), None);
assert_eq!(
app.world().resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::Idle
);
assert!(app.world().get_entity(secondary).is_err());
}
#[test]
fn occupied_dialog_broker_keeps_shutdown_waiting() {
let mut app = App::new();
app.add_message::<WindowCloseRequested>()
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
.init_resource::<SceneIo>();
app.world_mut().resource_mut::<SceneIo>().mark_dirty();
let (release_sender, release_receiver) = mpsc::channel();
app.world()
.resource::<NativeDialogBroker>()
.request(
move || release_receiver.recv().expect("release sender dropped"),
|_, _: ()| {},
)
.unwrap();
assert!(request_editor_shutdown(
app.world_mut(),
ShutdownSource::Programmatic
));
app.update();
assert_eq!(
app.world().resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::WaitingForBroker {
source: ShutdownSource::Programmatic
}
);
release_sender.send(()).unwrap();
}
#[test]
fn successful_save_requires_every_scene_tab_to_be_clean() {
let mut world = World::new();
world.init_resource::<SceneIo>();
world.insert_resource(ShutdownCoordinator {
intent: ShutdownIntent::Saving {
source: ShutdownSource::Programmatic,
},
..default()
});
assert!(complete_shutdown_save(
&mut world,
ShutdownSaveOutcome::Saved
));
assert_eq!(
world.resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::Authorized {
source: ShutdownSource::Programmatic,
require_clean_scenes: true,
}
);
world.resource_mut::<SceneIo>().mark_dirty();
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::Saving {
source: ShutdownSource::Programmatic,
};
assert!(!complete_shutdown_save(
&mut world,
ShutdownSaveOutcome::Saved
));
assert_eq!(
world.resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::Idle
);
assert!(world
.resource::<ShutdownCoordinator>()
.last_blocker()
.is_some());
}
#[test]
fn a_new_edit_after_save_reopens_the_guard_instead_of_exiting() {
let mut world = World::new();
world.init_resource::<Messages<AppExit>>();
world.init_resource::<SceneIo>();
world.insert_resource(ShutdownCoordinator {
intent: ShutdownIntent::Authorized {
source: ShutdownSource::Programmatic,
require_clean_scenes: true,
},
..default()
});
world.resource_mut::<SceneIo>().mark_dirty();
finalize_authorized_shutdown(&mut world);
assert_eq!(
world.resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::WaitingForBroker {
source: ShutdownSource::Programmatic
}
);
let messages = world.resource::<Messages<AppExit>>();
assert_eq!(
MessageCursor::<AppExit>::default().read(messages).count(),
0
);
}
#[test]
fn last_schedule_edit_is_seen_before_final_exit_authorization() {
let mut app = App::new();
app.add_message::<WindowCloseRequested>()
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
.init_resource::<SceneIo>()
.add_systems(Last, |mut scene_io: ResMut<SceneIo>| {
scene_io.mark_dirty();
});
assert!(request_editor_shutdown(
app.world_mut(),
ShutdownSource::Programmatic
));
app.update();
assert_eq!(app.should_exit(), None);
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
assert_eq!(
app.world().resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::WaitingForBroker {
source: ShutdownSource::Programmatic
}
);
}
#[test]
fn custom_dialog_labels_map_to_shutdown_decisions() {
assert_eq!(
decision_from_dialog(rfd::MessageDialogResult::Custom(SAVE_ALL_LABEL.into())),
ShutdownDecision::SaveAll
);
assert_eq!(
decision_from_dialog(rfd::MessageDialogResult::Custom(DISCARD_LABEL.into())),
ShutdownDecision::Discard
);
assert_eq!(
decision_from_dialog(rfd::MessageDialogResult::Custom(CANCEL_LABEL.into())),
ShutdownDecision::Cancel
);
}
#[test]
fn only_the_final_coordinator_state_authorizes_clean_session_persistence() {
let mut coordinator = ShutdownCoordinator::default();
assert!(!coordinator.authorized_exit_sent());
coordinator.intent = ShutdownIntent::Authorized {
source: ShutdownSource::Programmatic,
require_clean_scenes: true,
};
assert!(!coordinator.authorized_exit_sent());
coordinator.intent = ShutdownIntent::ExitSent {
source: ShutdownSource::Programmatic,
};
assert!(coordinator.authorized_exit_sent());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ use crate::history::{apply_command_redo, apply_command_undo, EditorHistory};
use crate::project::samples::{SampleCatalog, SampleCatalogEntry};
use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel};
use crate::shutdown::{request_editor_shutdown, ShutdownSource};
use crate::state::{EditorMode, PlayPaused};
use super::diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
@ -148,6 +149,11 @@ pub fn top_menu_bar(
}
});
}
ui.separator();
if menu_item(ui, "Quit", None, true).clicked() {
request_editor_shutdown(world, ShutdownSource::FileMenu);
ui.close();
}
});
ui.menu_button("Edit", |ui| {

View File

@ -6,7 +6,7 @@ use bevy::app::PluginGroupBuilder;
use bevy::asset::AssetPlugin;
use bevy::log::{LogPlugin, DEFAULT_FILTER};
use bevy::prelude::*;
use bevy::window::{CompositeAlphaMode, PresentMode, WindowMode, WindowPlugin};
use bevy::window::{CompositeAlphaMode, ExitCondition, PresentMode, WindowMode, WindowPlugin};
/// Runtime switch for the HDR camera pass.
///
@ -40,6 +40,27 @@ fn find_assets_directory(start: Option<PathBuf>) -> Option<String> {
}
pub fn default_plugins(title: impl Into<String>) -> PluginGroupBuilder {
configured_plugins(WindowPlugin {
primary_window: Some(primary_window(title)),
..default()
})
}
/// Default plugins for the editor, whose shutdown coordinator owns window-close handling.
pub fn editor_plugins(title: impl Into<String>) -> PluginGroupBuilder {
configured_plugins(editor_window_plugin(title))
}
fn editor_window_plugin(title: impl Into<String>) -> WindowPlugin {
WindowPlugin {
primary_window: Some(primary_window(title)),
exit_condition: ExitCondition::DontExit,
close_when_requested: false,
..default()
}
}
fn configured_plugins(window_plugin: WindowPlugin) -> PluginGroupBuilder {
DefaultPlugins
.set(LogPlugin {
// Blacksite intentionally replaces Bevy's `.scn.ron` loader with a
@ -48,10 +69,7 @@ pub fn default_plugins(title: impl Into<String>) -> PluginGroupBuilder {
filter: format!("{DEFAULT_FILTER}bevy_asset::server::loaders=error"),
..default()
})
.set(WindowPlugin {
primary_window: Some(primary_window(title)),
..default()
})
.set(window_plugin)
.set(AssetPlugin {
file_path: resolve_assets_directory(),
..default()
@ -86,8 +104,8 @@ pub fn hdr_enabled_from_env() -> bool {
#[cfg(test)]
mod tests {
use super::{primary_window, resolve_assets_directory};
use bevy::window::CompositeAlphaMode;
use super::{editor_window_plugin, primary_window, resolve_assets_directory};
use bevy::window::{CompositeAlphaMode, ExitCondition};
#[test]
fn primary_window_is_opaque() {
@ -97,6 +115,14 @@ mod tests {
assert_eq!(window.composite_alpha_mode, CompositeAlphaMode::Opaque);
}
#[test]
fn editor_window_defers_close_and_exit_to_the_editor() {
let plugin = editor_window_plugin("test");
assert!(!plugin.close_when_requested);
assert!(matches!(plugin.exit_condition, ExitCondition::DontExit));
}
#[test]
fn resolve_assets_directory_finds_workspace_assets() {
let cwd = std::env::current_dir().expect("cwd");

View File

@ -56,6 +56,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [0039](adr/0039-inline-height-grid-terrain-foundation.md) | Inline authored height grids with deterministic runtime-only chunk hydration |
| [0040](adr/0040-terrain-material-layer-weights.md) | Four-channel terrain material layers, compact normalized weights, and raster transport |
| [0041](adr/0041-transactional-editor-physics-placement.md) | Paused editor physics ownership and transactional gravity placement |
| [0042](adr/0042-guarded-editor-shutdown-and-document-savepoints.md) | Guarded native editor exit and canonical per-document clean checkpoints |
## Editor framework
@ -119,6 +120,7 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
| `terrain_material_layers_*.plan.md` | Terrain shared-material layers, normalized weights, blended hydration, and modal painting |
| `operator_invariants_completion_*.plan.md` | Production operator dispatch, interruption, rollback, cleanup, and undo/redo acceptance |
| `editor_sample_regression_pack_*.plan.md` | Five-area sample manifest, editor catalog, deterministic validation, and native regression acceptance |
| `guarded_shutdown_savepoints_*.plan.md` | Native close coordination, asynchronous Save All, and canonical history clean points |
## Crate responsibilities (quick reference)

View File

@ -34,12 +34,19 @@ assets, mark a scene clean, enter source control, or silently replace authored w
or Discard removes that scene's recovery generations. Save Recovery Copy As writes the snapshot
to a separate transactional scene file without changing the active scene or recovery lifecycle.
- A successful manual save retires recovery generations for that scene.
- A successful load or manual save establishes that tab's canonical authored-content checkpoint.
Undo and redo compare the current stable authored projection with this checkpoint to derive dirty
state. Failed writes, cancelled Save As, and recovery restores do not advance it.
- Native editor exit is authorized only after all dirty scene tabs save successfully or the user
explicitly discards them. See [ADR 0042](0042-guarded-editor-shutdown-and-document-savepoints.md).
## Consequences
- Interrupted manual writes retain the previous complete destination on filesystems with atomic
same-directory rename semantics.
- Recovery is machine-local and does not pollute builds or source control.
- Undoing exactly to the last loaded or saved authored state clears the dirty marker without
weakening recovery after direct edits or failed saves.
- Autosave still performs authored serialization and hydration rebuild work. Performance
instrumentation and background snapshotting remain follow-up work under the production roadmap.
- Unsaved new scenes do not yet have a stable recovery identity; they require a later session-ID

View File

@ -20,8 +20,10 @@ untrusted serialized ECS state.
- Session schema v1 allowlists metadata only: project root, active scene path, saved dock-layout
text, hierarchy expansion paths, non-destructive panel visibility, and finite camera bookmark
transforms.
- Write the session transactionally. Startup immediately writes `clean_shutdown: false`; normal
`AppExit` writes the final document with `clean_shutdown: true`.
- Write the session transactionally. Startup immediately writes `clean_shutdown: false`; only an
editor-shutdown-coordinator-authorized `AppExit` writes the final document with
`clean_shutdown: true`. Direct `AppExit`, forced termination, and process failure retain the
abnormal marker. See [ADR 0042](0042-guarded-editor-shutdown-and-document-savepoints.md).
- A clean prior session may restore its active scene and non-destructive UI state automatically.
An abnormal prior session starts from the normal safe scene and requires explicit confirmation
before opening the prior scene.

View File

@ -0,0 +1,57 @@
# ADR 0042: Guarded Editor Shutdown And Document Savepoints
## Status
Accepted
## Context
Bevy's default native-window policy closes a requested window and exits after the final window is
gone. That policy bypasses Blacksite's scene-tab and project-switch confirmations, so a compositor
close request could silently discard dirty authored work. Reading `WindowCloseRequested` in another
system is insufficient because Bevy's default close system has its own message cursor and still
despawns the window.
Scene dirtiness was also independent of editor history. Every undo or redo marked the active scene
dirty, even when undo restored the exact state that had last loaded or saved. Undo depth alone is
not a valid savepoint: editor commands contain runtime entity IDs and are intentionally cleared when
tabs switch, while saves may establish a clean point in the middle of a timeline.
## Decision
- The full editor disables Bevy's automatic close-on-request behavior. The game and Project Browser
keep their existing native-window policy.
- One editor shutdown coordinator owns native window close, **File > Quit**, project switching, and
programmatic editor-exit requests. It coalesces duplicate requests and is the only normal path
authorized to emit `AppExit`.
- Final authorization runs in a dedicated main schedule after Bevy's `Last` schedule. The guard
rechecks dirty scene state after every editor authoring system, then clean-session persistence
runs after that finalizer in the same post-`Last` schedule.
- A clean editor exits immediately. Dirty scene tabs enter one non-blocking native decision with
explicit **Save All**, **Discard**, and **Cancel** actions. Save completion, including an untitled
tab's Save As picker, is applied on the main thread. The coordinator exits only after every dirty
tab saves or the user explicitly discards; cancel or failure retains the live session.
- Clean-session persistence accepts only an exit authorized by the coordinator. Crashes, forced
termination, and unguarded `AppExit` retain the abnormal-session marker.
- Every scene tab owns a canonical authored-content checkpoint established by a successful load or
save. The projection uses stable actor IDs, stable parent actor IDs, sorted component identities,
and scene composition; it excludes transient Bevy entity numbers and runtime hydration.
- A committed edit marks the active tab dirty. Successful undo or redo compares the current
canonical authored projection with that tab's checkpoint. Equality clears the dirty marker;
divergence sets it. Failed saves and recovery restores do not advance the checkpoint.
- Editor command stacks remain active-document runtime state and are still cleared on tab switches.
Checkpoint ownership belongs to the document tab, so dirty truth survives those clears.
## Consequences
- Native close can no longer silently discard authored scene tabs, and the editor remains responsive
while confirmation or Save As dialogs are open.
- A normal exit requires an explicit editor-owned authorization. New code must request shutdown
through the coordinator instead of writing `AppExit` directly.
- Undoing to the last saved authored state clears title and tab dirty indicators, including after a
save at nonzero history depth. Redo or a divergent branch marks the document dirty again.
- Reconciliation serializes the authored projection after undo and redo. This is more work than a
depth comparison, but it is bounded to explicit history navigation and remains correct across
direct non-history repairs, entity respawns, and tab switches.
- Operating-system process kill and power loss cannot be confirmed; recovery and abnormal-session
handling remain the safety boundary for those cases.

View File

@ -111,6 +111,10 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
components on load and component transactions, and does not prohibit compatible composition such
as renderer plus light. Component validation owns requirements and geometry-source conflicts.
- **Scene persistence and recovery** use transactional same-directory writes for authored scenes and bounded user-local recovery snapshots for dirty saved scenes. Recovery runs every 120 seconds by default, keeps five generations, never marks the scene clean, and appears as a status/File-menu restore or discard action when newer than the authored file. Restore leaves the original scene dirty until manually saved. See [ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).
- **Guarded shutdown and clean savepoints** route native close, **File > Quit**, project switching,
and programmatic exit through one non-blocking Save All / Discard / Cancel coordinator. Each tab's
dirty marker is reconciled against its canonical last-loaded-or-saved authored projection after
undo/redo. See [ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
- **Collaborative file safety** records exact content revisions for loaded scenes, prefab source operations, editable materials, and Project Settings, then rechecks them immediately before atomic publication. External changes, create races, read-only files, and optional ownership locks keep the existing bytes intact and open Reload/Compare Metadata/Save As/Cancel recovery. Git status is asynchronous, observational, and absent without noise outside a repository. See [collaborative-file-safety.md](collaborative-file-safety.md) and [ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
- **Scene visualizers** expose actor root icons, colliders, lights, player spawns, gameplay markers, volumes, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Spot lights show inner/outer cones and cameras show near/far frusta. Viewport options independently gate visualizer categories and include actor icon and transform-gizmo size sliders.
- **Clean game-view overlay** (`G`) hides editor chrome, grid, selection outlines, transform gizmos, actor root icons, visualizers, and selectable proxies without changing camera ownership; `Ctrl+G` toggles grid.

View File

@ -87,11 +87,20 @@ or Cancel. Generated artifacts remain under their subsystem-owned regeneration p
[collaborative-file-safety.md](collaborative-file-safety.md) and
[ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
`project/shutdown.rs` owns native close, **File > Quit**, project switching, and programmatic editor
exit. The editor launch configuration leaves native close requests open; the coordinator coalesces
them, retries the shared native-dialog broker when busy, and authorizes `AppExit` only after clean
state, successful Save All, or explicit Discard. Scene I/O owns the asynchronous per-tab save
transaction and reports completion back to the coordinator. Final authorization and clean-session
persistence run in an editor schedule inserted after Bevy's `Last`, so material drops, brush/gizmo
finalizers, and every other authoring system finish before the last dirty-state check. See
[ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
`project/session.rs` owns the independent versioned restart document under the user state
directory. It snapshots allowlisted paths, panel visibility, dock/hierarchy metadata, and camera
bookmarks, writes a running marker at startup, and records clean `AppExit`. Abnormal sessions do
not auto-open the prior scene; the explicit safe-resume prompt is the only path back. See
[ADR 0024](../adr/0024-versioned-editor-session-state.md).
bookmarks, writes a running marker at startup, and records clean state only for a coordinator-
authorized `AppExit`. Abnormal sessions do not auto-open the prior scene; the explicit safe-resume
prompt is the only path back. See [ADR 0024](../adr/0024-versioned-editor-session-state.md).
`project/diagnostics_bundle.rs` exports a separate, transactional support report from an explicit
metadata allowlist. It summarizes build/renderer identity, project paths, dirty state, aggregate
@ -200,7 +209,7 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See
1. User edits entities with `LevelObject` + reflectable components from `shared`.
2. `EditorOnly` entities (cameras, helpers) are filtered from hierarchy and save; visualizer proxies can be picked but resolve back to source entities.
3. Player placement is stored as `PlayerSpawn`; the runtime `Player` is never serialized.
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through `scene::document::SceneDocument`. The active tab is materialized in the ECS world; inactive tabs retain normalized authored snapshots and independent dirty/recovery state. `SceneComposition` resources reference validated project-relative subscenes by stable IDs, and runtime-only `ComposedSceneMember` ownership prevents child actors from being flattened into the owner save. See [multi-scene-composition.md](multi-scene-composition.md) and ADR 0026.
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through `scene::document::SceneDocument`. The active tab is materialized in the ECS world; inactive tabs retain normalized authored snapshots, canonical clean checkpoints, and independent dirty/recovery state. Undo/redo reconciles stable authored content rather than transient ECS entity IDs. `SceneComposition` resources reference validated project-relative subscenes by stable IDs, and runtime-only `ComposedSceneMember` ownership prevents child actors from being flattened into the owner save. See [multi-scene-composition.md](multi-scene-composition.md), ADR 0026, and [ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
5. Hydration systems in `game` spawn meshes, colliders, lights, static mesh renderer parts, and
dedicated skinned-model hierarchies at runtime. Level-object roots are initialized with Bevy
visibility hierarchy components before generated children are attached, so authoring

View File

@ -6,6 +6,11 @@ save/discard/cancel prompt when needed. **File > Open Scene...** opens another t
discarding the active document. Save and Save As affect the active tab, while project switching
offers to save every modified tab.
Each tab also retains a canonical checkpoint of the last successfully loaded or saved authored
projection. History commands remain active-tab runtime state, but undo and redo reconcile against
that document checkpoint. Returning exactly to the checkpoint clears only that tab's dirty marker;
redo, a divergent edit, failed save, or restored recovery remains dirty.
## Composition workflow
The **Composition** menu beside the tabs edits the active scene's persisted `SceneComposition`:

View File

@ -16,6 +16,13 @@ loop or editor rendering.
- Collaborative Save As performs the same revision and destination checks after selection.
- Close or project switch never treats an opened Save As picker as a completed save. When a save
remains pending, the transition pauses and can be retried after saving.
- Native window close, **File > Quit**, project switch, and programmatic editor exit share the
guarded-shutdown coordinator. Dirty scene tabs present exact **Save All**, **Discard**, and
**Cancel** actions. Save All remains pending through each untitled tab's Save As result and exits
only after every dirty tab is clean; cancellation or failure preserves the live session.
- A close request received while another native dialog owns the broker remains queued. The editor
rechecks dirty state and retries acquisition after that workflow completes instead of dropping
the request or opening a competing dialog.
## Covered Surfaces
@ -24,6 +31,8 @@ subscene selection, collaborative conflict copies, Project Browser folder select
confirmations. New workflows must use the broker instead of calling `rfd` from an egui/Bevy system.
See [ADR 0038](../adr/0038-non-blocking-native-dialog-broker.md).
Guarded exit ownership and document savepoints are defined by
[ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
Live native-Wayland acceptance is recorded in the
[native-dialog responsiveness evaluation](evaluations/native-dialog-responsiveness/).

View File

@ -13,10 +13,14 @@ or credentials.
## Restart behavior
- **Clean shutdown:** the last authored scene and non-destructive UI state are restored.
- **Clean shutdown:** after the guarded exit path saves all dirty scene tabs or receives explicit
Discard confirmation, the last authored scene and non-destructive UI state are restored.
- **Abnormal shutdown:** the editor opens its normal safe scene and displays **Recover Editor
Session**. **Resume Last Scene** opens only the previous authored scene. **Continue Safe** keeps
the startup scene. Modal tools and dirty preview state are never restored.
- Native window close, **File > Quit**, project switching, and programmatic editor exit use one
non-blocking Save All / Discard / Cancel workflow. Cancel and failed saves keep the session open.
Direct `AppExit`, process kill, and crashes are not recorded as clean shutdowns.
- Dirty scene data remains governed by the independent scene recovery workflow in
[ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).