Compare commits

..

No commits in common. "f2ad88fd03a025d9b4c00482f398a4a83f9e1f9a" and "b4aa61e39459c12b81d86575eac62c50b4efbedd" have entirely different histories.

32 changed files with 89 additions and 3098 deletions

View File

@ -21,23 +21,9 @@ performance, soak, regression, and first-hour workflows must pass against a repr
`#43`; M6 implementation is complete. `#43`; M6 implementation is complete.
4. Completed: authoritative content validation `#45` and the validation-gated packaging/Build 4. Completed: authoritative content validation `#45` and the validation-gated packaging/Build
workflow `#44`. workflow `#44`.
5. Completed: audio `#47`, animation `#46`, and navigation `#48` passed production acceptance. 5. Completed: audio source/listener/bus authoring and spatial preview passed production acceptance
Source-control safety `#49` has complete implementation and source/headless acceptance; live in `#47`. Next: animation `#46`, navigation `#48`, source-control safety `#49`, then readiness
visual acceptance remains before the readiness gate `#50`. gate `#50`.
## Implemented Slice - Collaborative Authored-File Safety
- Exact BLAKE3 baselines for scene tabs, staged Material/Material Instance documents, and Project
Settings.
- One guarded atomic publication path for scene save/export, prefab source Apply/history, and
editable materials, including missing-file and immediate pre-rename race protection.
- Non-destructive background Git porcelain scanning with optional off-thread ownership providers;
Git/provider absence is quiet.
- Compact active-scene and selected-asset state plus Reload, metadata comparison, Save As, and
Cancel conflict recovery. No force-overwrite action exists.
- Focused race, read-only, provider-lock, parser, non-repository, modal-render, scene, and material
tests pass. Live debug-editor visual acceptance remains pending for a desktop-capable QA session;
packaged acceptance remains owner-deferred.
## M6 - Reliability, Recovery, And Project Workflow ## M6 - Reliability, Recovery, And Project Workflow

View File

@ -1,84 +0,0 @@
# Material Library And Targeted Viewport Drop
Working implementation plan for Gitea issues
[`#16`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/16) and
[`#18`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/18).
The shared Material/Material Instance and stable renderer-slot foundation from `#51` is already
present; this slice completes the production-facing catalog and drag/drop workflow.
## Status
Source audit complete. Implementation starts on a clean branch after collaborative-safety `#49` is
published, because both slices touch the Asset Browser and should remain independently reviewable.
## Current Gaps
- Material assets are edited inside generic Asset Browser Details; no dedicated Material Library or
scene-usage view exists.
- Viewport material/texture drops act on the existing selection instead of the surface under the
pointer.
- Multi-slot renderers silently assign all slots; the hydrated draw hit already carries
`HydratedRendererMaterialBinding`, but the drop path does not use it.
- Primitive and brush-face targeting is not explicit, and unsupported authored actors can receive a
generic `MaterialDesc` instead of an actionable rejection.
- Drag UI describes an eventual action but does not preview the actual affected surface or restore a
preview on cancel/target change.
## Material Library
1. Add a dockable `Material Library` panel, opened from Window and placed in the existing bottom dock
without adding another permanent toolbar.
2. Present project Materials and Material Instances with search, Material/Instance filter, thumbnail
grid/list modes, base/dependency health, and source-control status.
3. Add a scene-usage section that counts renderer-slot, primitive, and brush-face references and can
select/locate users. Do not revive ambiguous hidden scene-local material clones.
4. Reuse the existing guarded Material/Instance drafts for Apply, Revert, Create Instance, and
texture-parameter editing. Shared-file edits remain explicit; scene assignment changes use
history.
5. Material and Instance cells are first-class drag sources and preserve the current pointer-following
visual identity.
## Targeted Drop Contract
Introduce a frame-updated `ViewportAssetDropTarget` resolved by the existing mesh-picking path:
- A hydrated static/skinned draw maps through `HydratedRendererMaterialBinding` to the authored actor
and exact stable slot ID.
- A primitive maps to its authored actor-level `MaterialDesc`.
- A brush performs authored face intersection and identifies the stable face ID.
- An authored but unsupported target remains a visible invalid target with a specific reason.
- Empty space remains placement-only for placeable assets and invalid for Material/Texture payloads.
Payload matrix:
| Payload | Renderer slot | Primitive | Brush face |
|---------|---------------|-----------|------------|
| Material / Material Instance | Assign exact hit slot; one-slot actors may assign directly | Replace actor material reference/values | Replace the hit face material reference |
| Texture | Reject with guidance to create/edit a Material Instance | Set base-color texture | Set hit-face base-color texture/material binding |
An explicit `Apply All Slots` command remains available from the renderer inspector/library usage
menu; a viewport hit never silently broadens from one slot to all slots.
## Preview, Commit, And Cancel
1. Start a persistent drop-preview session when a supported Material/Texture payload enters the
viewport. Snapshot only the target's affected authored state.
2. Apply a transient visual preview without pushing history or marking the scene dirty.
3. When the pointer changes target, restore the previous snapshot before previewing the new target.
4. On release, restore the transient state first, then commit exactly one typed history/operator
transaction to the identified slot, primitive, or face.
5. On Escape, drag cancellation, leaving the viewport, invalid target, or missing source, restore the
exact snapshot and remove every preview/helper marker.
6. The drag card and target outline name the payload, actor, slot/face, action, and invalid reason.
## Verification
- Unit-test ray-hit to authored target/slot mapping, primitive/face targeting, and invalid reasons.
- Use `OperatorInvariantHarness` for preview target changes, release commit, Escape/outside cancel,
dirty-state preservation, helper cleanup, and one-step undo/redo.
- Cover static one/multi-slot, skinned multi-slot, primitive, brush face, Texture rejection on a
renderer slot, missing material, and linked/locked prefab boundaries.
- Verify Material Library filtering, scene-usage counts, dependency diagnostics, and drag sources in
headless egui tests.
- Run full source/headless checks. Packaged testing remains deferred until the project owner requests
it again; live visual acceptance is still required before closing `#16`/`#18`.

View File

@ -1,37 +0,0 @@
# Production-Readiness Acceptance Gate
Working plan for Gitea issue
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50).
This gate is broader than the M7 feature list: it proves the complete daily authoring, content,
recovery, performance, and release workflow against one exact release-candidate commit.
## Status
Audit in progress. The versioned acceptance matrix exists, but no release candidate is nominated and
the gate is not signed off. Packaged/release testing remains explicitly deferred until the project
owner requests it again.
## Sequence
1. Keep one evidence matrix under `docs/editor/evaluations/production-readiness/`; historical H1-H6
notes remain context only.
2. Close implementation blockers before nominating a candidate: collaborative safety `#49`, the
remaining Material/drag-drop contract in `#16`, `#18`, and `#51`, terrain `#22`-`#24`, physics
placement/diagnostics `#25`-`#26`, and their regression fixtures.
3. Complete the representative regression project, mutation-invariant coverage, performance budgets,
and first-hour workflow tracked by `#32`-`#36`.
4. Nominate one exact commit, validate it from a clean checkout, and record source/headless results.
5. When packaged testing is re-enabled, run the candidate's package/build and packaged-runtime
matrix without substituting older artifacts.
6. Run the eight-hour soak and measured performance protocol, consolidate limitations by severity
and workaround, and obtain independent first-hour/recovery sign-off.
7. Post the signed Gitea milestone comment linking the exact commit and immutable evidence. Close
`#50` only when every required row passes and no P0 blocker remains.
## Gate Rules
- Feature presence is not acceptance evidence.
- A local dirty worktree is not a release candidate.
- Results from another commit or an old package do not transfer to the candidate.
- Deferred, missing, partial, and implementer-only evidence are not passes.
- Every failure records an owner ticket or a concrete rerun condition.

View File

@ -1,58 +0,0 @@
# Source-Control Status And Collaborative File Safety
Working implementation plan for Gitea issue
[`#49`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/49).
This is the final M7 implementation slice before the production-readiness acceptance gate.
## Status
Implementation, focused source tests, full editor tests, strict editor clippy, and headless checks
are complete. Live debug-editor visual acceptance remains pending for a desktop-capable QA session.
Packaged/release acceptance is explicitly deferred until the project owner requests another
packaged pass.
## Outcome
External edits cannot be silently overwritten by the editor, common Git/read-only/ownership states
are visible without dominating the shell, and projects without Git or an ownership provider behave
normally.
## Implementation Sequence
1. Add exact file revisions, comparison metadata, guarded atomic replacement, and deterministic
pre-rename race coverage.
2. Capture baselines for scene tabs, material/material-instance drafts, and Project Settings;
protect prefab source Apply and exact-file undo/redo with the same guard.
3. Add a non-destructive asynchronous Git scanner and optional ownership-provider registry.
4. Add compact active-scene and selected-asset status plus a recovery modal with reload, compare
metadata, save-as, and cancel.
5. Cover missing/present/read-only/external-change races, porcelain parsing, provider absence, and
document-specific conflict recovery with focused source tests.
6. Run format, strict affected-crate clippy, source tests, headless validation, and live debug-editor
QA. Do not run packaged tests until requested by the project owner.
## Acceptance Gates
- A loaded authored file changed by another process is never replaced by Save, Apply to source, or
prefab source history.
- File creation is also conditional: a file that appears after a save-as baseline is captured is
not overwritten.
- Conflict and read-only UI identifies the path and recovery choices without a force-overwrite
escape hatch.
- Active-scene and selected-asset Git status remain compact and explain themselves on hover.
- Git absence, a non-repository project, and an empty provider registry are quiet normal states.
- Git commands are read-only and never alter index, worktree, commits, branches, or locks.
- Provider state can block an authored write without coupling core editor code to a vendor.
- Source tests simulate a writer changing the target immediately before rename and prove the old or
external content survives.
## Deliberate Boundaries
- Generated registries, imports, thumbnails, navigation bakes, recovery snapshots, and packages are
regenerative/system-owned and do not open authored-file recovery UI.
- V1 compares metadata rather than rendering a text diff; external diff-tool integration is future
work.
- V1 observes ownership/locks. Acquiring, releasing, or stealing locks belongs in a provider-specific
follow-up.
- Git status is project-local and advisory. The content revision guard remains authoritative even
when Git is absent or stale.

View File

@ -161,8 +161,6 @@ deep-stale variants.
| Hierarchy lock | Excludes the actor from selection, gizmos, multi-drag, structural drops, and mutating context actions | | 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 | | 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, Open, transactional Save/Save As, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes | | File menu | New, Open, transactional Save/Save As, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes |
| 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 | | Main toolbar, right side | Switch or close independent scene tabs, create an untitled tab, and manage loaded/locked composed subscenes |
| Prefab Instance inspector | Inspect/recover source health, Apply/Revert overrides by scope, Apply overrides to source, create a variant, **Unpack Layer**, or recursively **Convert to Local** | | Prefab Instance inspector | Inspect/recover source health, Apply/Revert overrides by scope, Apply overrides to source, create a variant, **Unpack Layer**, or recursively **Convert to Local** |
| Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions | | Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions |
@ -263,13 +261,6 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
without changing the scene. without changing the scene.
- Runtime-only handles/colliders are not serialized directly, keeping scenes stable and portable. - Runtime-only handles/colliders are not serialized directly, keeping scenes stable and portable.
- Editor-only cameras and helper roots are filtered from selection, hierarchy, and scene save. - Editor-only cameras and helper roots are filtered from selection, hierarchy, and scene save.
- Loaded scenes, prefab source Apply/history, Materials, Material Instances, and Project Settings
retain exact BLAKE3 disk revisions. Save verifies the revision again immediately before atomic
replacement; an external edit, create race, read-only target, or provider lock leaves the
existing file untouched and opens the collaborative recovery dialog. Git status is observational
and optional. See the
[collaborative file-safety guide](docs/editor/collaborative-file-safety.md) and
[ADR 0037](docs/adr/0037-collaborative-authored-file-safety.md).
- **PIE restores player sim only** (transform, velocity, jump state) when you stop Play; authored - **PIE restores player sim only** (transform, velocity, jump state) when you stop Play; authored
`LevelObject` edits made during PIE **remain** in the scene (the level may show as dirty). `LevelObject` edits made during PIE **remain** in the scene (the level may show as dirty).
- Play mode swaps the unified viewport between the player camera (possessed) and editor fly camera - Play mode swaps the unified viewport between the player camera (possessed) and editor fly camera
@ -332,16 +323,6 @@ identity, project and active-scene paths, dirty flags, aggregate validation coun
state, and the bounded Scene I/O log. It excludes scene and asset contents, environment values, state, and the bounded Scene I/O log. It excludes scene and asset contents, environment values,
host/user identity, credentials, access tokens, and modal tool state. host/user identity, credentials, access tokens, and modal tool state.
### External file changes and read-only assets
The active scene status strip and selected asset header show compact Git/read-only/ownership state
when available. If Save or Apply detects a different disk revision, Blacksite leaves that revision
untouched and opens **Authored File Not Saved**. Use **Compare Metadata** to inspect both revisions,
**Save As** to preserve the editor copy elsewhere, **Reload** to take the disk version, or **Cancel**
to keep the local draft unsaved. Git and ownership providers are optional; the status scanner never
stages, commits, resets, checks out, restores, or discards files. See
[collaborative-file-safety.md](docs/editor/collaborative-file-safety.md).
## Cursor / VSCode Setup ## Cursor / VSCode Setup
The `.vscode/` folder is preconfigured: The `.vscode/` folder is preconfigured:
@ -365,7 +346,6 @@ The `.vscode/` folder is preconfigured:
- [ADR 0016: Unified Rendering Contract](docs/adr/0016-unified-rendering-contract.md) - [ADR 0016: Unified Rendering Contract](docs/adr/0016-unified-rendering-contract.md)
- [ADR 0017: Normalized Static Mesh Assets](docs/adr/0017-normalized-static-mesh-assets.md) - [ADR 0017: Normalized Static Mesh Assets](docs/adr/0017-normalized-static-mesh-assets.md)
- [ADR 0034: Registry-driven Authoring Components](docs/adr/0034-registry-driven-authoring-components.md) - [ADR 0034: Registry-driven Authoring Components](docs/adr/0034-registry-driven-authoring-components.md)
- [ADR 0037: Collaborative Authored-File Safety](docs/adr/0037-collaborative-authored-file-safety.md)
## Project Layout ## Project Layout
@ -413,7 +393,6 @@ crates/
- [x] Audio source/listener authoring, non-dirty spatial audition, viewport icons/range gizmos, stable buses, PIE/runtime parity, device diagnostics, and shared release validation ([ADR 0030](docs/adr/0030-audio-authoring-and-bus-schema.md); production acceptance completed in [Gitea #47](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47)) - [x] Audio source/listener authoring, non-dirty spatial audition, viewport icons/range gizmos, stable buses, PIE/runtime parity, device diagnostics, and shared release validation ([ADR 0030](docs/adr/0030-audio-authoring-and-bus-schema.md); production acceptance completed in [Gitea #47](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47))
- [x] glTF/GLB skeletal animation manifests, stable controller states, non-dirty preview, PIE/runtime hydration, and exact-signature compatibility validation ([ADR 0031](docs/adr/0031-animation-authoring-runtime-contract.md); production acceptance completed in [Gitea #46](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/46)) - [x] glTF/GLB skeletal animation manifests, stable controller states, non-dirty preview, PIE/runtime hydration, and exact-signature compatibility validation ([ADR 0031](docs/adr/0031-animation-authoring-runtime-contract.md); production acceptance completed in [Gitea #46](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/46))
- [x] Navigation bounds/obstacles/areas/links, persisted validation samples, composed-source resolution, deterministic stale-checked bake artifacts, viewport path preview, headless bake, and shared game/runtime query API ([ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.md); [evaluation](docs/editor/evaluations/navigation-authoring/); [Gitea #48](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/48)) - [x] Navigation bounds/obstacles/areas/links, persisted validation samples, composed-source resolution, deterministic stale-checked bake artifacts, viewport path preview, headless bake, and shared game/runtime query API ([ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.md); [evaluation](docs/editor/evaluations/navigation-authoring/); [Gitea #48](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/48))
- [x] Exact authored-file revision guards for scenes, prefab source Apply/history, Materials, Material Instances, and Project Settings; compact observational Git/read-only/ownership state; and explicit Reload/Compare Metadata/Save As/Cancel recovery without force overwrite ([ADR 0037](docs/adr/0037-collaborative-authored-file-safety.md), [collaboration guide](docs/editor/collaborative-file-safety.md), [Gitea #49](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/49))
- [x] Prefab instances (`PrefabInstance`) + save-as-prefab + unpack - [x] Prefab instances (`PrefabInstance`) + save-as-prefab + unpack
- [x] Independent dirty-tab close confirmation and all-tab save guard when switching projects - [x] Independent dirty-tab close confirmation and all-tab save guard when switching projects
- [x] Transactional scene writes + bounded user-local recovery snapshots ([ADR 0023](docs/adr/0023-transactional-scene-persistence-and-recovery.md)) - [x] Transactional scene writes + bounded user-local recovery snapshots ([ADR 0023](docs/adr/0023-transactional-scene-persistence-and-recovery.md))

View File

@ -1248,10 +1248,6 @@ fn apply_prefab_scope_to_source(
return; return;
} }
}; };
let before_snapshot = crate::collaboration::FileSnapshot::from_loaded_bytes(
&source_path,
before_source.as_bytes(),
);
let mut document = match SceneDocument::from_ron_text(&before_source) { let mut document = match SceneDocument::from_ron_text(&before_source) {
Ok(document) => document, Ok(document) => document,
Err(error) => { Err(error) => {
@ -1365,13 +1361,7 @@ fn apply_prefab_scope_to_source(
} }
let after_source = document.to_ron_text()?; let after_source = document.to_ron_text()?;
scene::validate_prefab_graph_text(&after_source, &source_path, &project_root)?; scene::validate_prefab_graph_text(&after_source, &source_path, &project_root)?;
crate::collaboration::publish_authored_file( crate::scene::recovery::atomic_write(&source_path, after_source.as_bytes())?;
world,
&source_path,
after_source.as_bytes(),
&before_snapshot,
crate::collaboration::FileWriteIntent::PrefabSource { instance_root },
)?;
Ok::<_, String>(after_source) Ok::<_, String>(after_source)
})(); })();
let after_source = match apply_result { let after_source = match apply_result {
@ -2617,22 +2607,6 @@ fn retry_prefab_source(world: &mut World, entity: Entity) {
"Prefab source queued for reload".into(); "Prefab source queued for reload".into();
} }
pub(crate) fn reload_prefab_source_after_file_conflict(
world: &mut World,
instance_root: Entity,
) -> Result<String, String> {
if world.get::<PrefabInstance>(instance_root).is_none() {
return Err("the prefab instance no longer exists".into());
}
retry_prefab_source(world, instance_root);
let status = world.resource::<crate::scene_io::SceneIo>().status.clone();
if status.starts_with("Prefab retry failed") {
Err(status)
} else {
Ok(status)
}
}
pub fn prefab_member_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { pub fn prefab_member_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some((instance_root, target)) = prefab_member_override_target(world, entity) else { let Some((instance_root, target)) = prefab_member_override_target(world, entity) else {
ui.label(egui::RichText::new("Prefab target unavailable").color(ERROR)); ui.label(egui::RichText::new("Prefab target unavailable").color(ERROR));
@ -3284,38 +3258,6 @@ mod tests {
assert_eq!(read_overrides(&instance), PrefabOverrides::default()); assert_eq!(read_overrides(&instance), PrefabOverrides::default());
} }
#[test]
fn collaboration_reload_restarts_prefab_hydration_without_dropping_overrides() {
let root = std::env::temp_dir().join(format!(
"blacksite-prefab-collaboration-reload-{}",
uuid::Uuid::new_v4()
));
let source_path = root.join("assets/prefabs/base.scn.ron");
std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
std::fs::write(&source_path, prefab_source("mesh", None)).unwrap();
let mut world = prefab_test_world(&root);
let mut instance = PrefabInstance::new("base", "assets/prefabs/base.scn.ron");
instance.overrides_ron = encode_prefab_overrides(&PrefabOverrides {
source_revision: Some("previous".into()),
..Default::default()
})
.unwrap();
let instance_root = world.spawn((instance.clone(), HydratedPrefabReady)).id();
let status = reload_prefab_source_after_file_conflict(&mut world, instance_root).unwrap();
assert_eq!(status, "Prefab source queued for reload");
assert_eq!(
world
.get::<PrefabInstance>(instance_root)
.map(|current| &current.overrides_ron),
Some(&instance.overrides_ron)
);
assert!(world.get::<HydratedPrefabReady>(instance_root).is_none());
assert!(world.get::<PrefabRef>(instance_root).is_some());
std::fs::remove_dir_all(root).unwrap();
}
#[test] #[test]
fn nested_unpack_fold_is_transactional_when_a_later_link_is_invalid() { fn nested_unpack_fold_is_transactional_when_a_later_link_is_invalid() {
let mut world = World::new(); let mut world = World::new();

View File

@ -1570,7 +1570,7 @@ pub fn set_scene_composition_with_history(world: &mut World, new: SceneCompositi
pub fn apply_command_undo(world: &mut World) { pub fn apply_command_undo(world: &mut World) {
let candidate = world.resource::<EditorHistory>().undo_stack.last().cloned(); let candidate = world.resource::<EditorHistory>().undo_stack.last().cloned();
if let Some(candidate) = candidate { if let Some(candidate) = candidate {
if let Err(error) = prepare_prefab_source_history(world, &candidate, true) { if let Err(error) = prepare_prefab_source_history(&candidate, true) {
world.resource_mut::<EditorHistory>().status = format!("Undo blocked: {error}"); world.resource_mut::<EditorHistory>().status = format!("Undo blocked: {error}");
world world
.resource_mut::<SceneIo>() .resource_mut::<SceneIo>()
@ -1591,7 +1591,7 @@ pub fn apply_command_undo(world: &mut World) {
pub fn apply_command_redo(world: &mut World) { pub fn apply_command_redo(world: &mut World) {
let candidate = world.resource::<EditorHistory>().redo_stack.last().cloned(); let candidate = world.resource::<EditorHistory>().redo_stack.last().cloned();
if let Some(candidate) = candidate { if let Some(candidate) = candidate {
if let Err(error) = prepare_prefab_source_history(world, &candidate, false) { if let Err(error) = prepare_prefab_source_history(&candidate, false) {
world.resource_mut::<EditorHistory>().status = format!("Redo blocked: {error}"); world.resource_mut::<EditorHistory>().status = format!("Redo blocked: {error}");
world world
.resource_mut::<SceneIo>() .resource_mut::<SceneIo>()
@ -1609,13 +1609,8 @@ pub fn apply_command_redo(world: &mut World) {
mark_dirty(world); mark_dirty(world);
} }
fn prepare_prefab_source_history( fn prepare_prefab_source_history(command: &EditorCommand, undo: bool) -> Result<(), String> {
world: &mut World,
command: &EditorCommand,
undo: bool,
) -> Result<(), String> {
let EditorCommand::ApplyPrefabToSource { let EditorCommand::ApplyPrefabToSource {
entity,
source_path, source_path,
before_source, before_source,
after_source, after_source,
@ -1629,18 +1624,15 @@ fn prepare_prefab_source_history(
} else { } else {
(before_source, after_source) (before_source, after_source)
}; };
let expected = let current = std::fs::read_to_string(source_path)
crate::collaboration::FileSnapshot::from_loaded_bytes(source_path, expected.as_bytes()); .map_err(|error| format!("could not read {}: {error}", source_path.display()))?;
crate::collaboration::publish_authored_file( if &current != expected {
world, return Err(format!(
source_path, "{} changed outside this history command",
replacement.as_bytes(), source_path.display()
&expected, ));
crate::collaboration::FileWriteIntent::PrefabSourceHistory { }
instance_root: *entity, crate::scene::recovery::atomic_write(source_path, replacement.as_bytes())
},
)
.map(|_| ())
} }
fn undo_command(world: &mut World, command: &mut EditorCommand) { fn undo_command(world: &mut World, command: &mut EditorCommand) {

View File

@ -22,7 +22,6 @@ pub use ext::extensibility;
pub use ext::hot_reload; pub use ext::hot_reload;
pub use play::net_editor; pub use play::net_editor;
pub use play::state; pub use play::state;
pub use project::collaboration;
pub use project::diagnostics_bundle; pub use project::diagnostics_bundle;
pub use project::launcher; pub use project::launcher;
pub use project::project_io; pub use project::project_io;
@ -65,7 +64,6 @@ use infra::EditorInfraPlugin;
use operators::OperatorPlugin; use operators::OperatorPlugin;
use play::audio_preview::AudioPreviewPlugin; use play::audio_preview::AudioPreviewPlugin;
use play::PlaySessionPlugin; use play::PlaySessionPlugin;
use project::collaboration::CollaborationPlugin;
use project_io::ProjectIoPlugin; use project_io::ProjectIoPlugin;
use render_view::RenderViewPlugin; use render_view::RenderViewPlugin;
use scene_io::SceneIoPlugin; use scene_io::SceneIoPlugin;
@ -113,7 +111,6 @@ impl PluginGroup for EditorPluginGroup {
.add(EditorVisualizersPlugin) .add(EditorVisualizersPlugin)
.add(EditorHistoryPlugin) .add(EditorHistoryPlugin)
.add(SceneIoPlugin) .add(SceneIoPlugin)
.add(CollaborationPlugin)
.add(EditorUiPlugin) .add(EditorUiPlugin)
.add(EditorSessionPlugin) .add(EditorSessionPlugin)
.add(brp::BrpPlugin); .add(brp::BrpPlugin);

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
//! Project workspace I/O, settings UI, and recent-level sync. //! Project workspace I/O, settings UI, and recent-level sync.
pub mod collaboration;
pub mod diagnostics_bundle; pub mod diagnostics_bundle;
pub mod launcher; pub mod launcher;
pub mod project_io; pub mod project_io;

View File

@ -1,28 +1,18 @@
//! Editor-only Project Settings panel (egui). Data lives in the `settings` crate. //! Editor-only Project Settings panel (egui). Data lives in the `settings` crate.
use std::path::Path;
use bevy::prelude::*; use bevy::prelude::*;
use bevy_egui::egui; use bevy_egui::egui;
use game_hot::sync_project_sun_from_settings; use game_hot::sync_project_sun_from_settings;
use settings::{ use settings::{
save_project_settings_to_string, sync_sim_tuning, ExposureMode, ProjectSettings, load_project_settings_from_path, save_project_settings, sync_sim_tuning, ExposureMode,
ProjectSettingsChanged, ProjectSettingsIo, ProjectSettings, ProjectSettingsChanged, ProjectSettingsIo,
}; };
use crate::project::collaboration::{
file_status_indicator_ui, publish_authored_file, CollaborationState, FileSnapshot,
FileWriteIntent,
};
use crate::scene_io::SceneIo;
/// Whether the Project Settings window is visible (editor UI state only). /// Whether the Project Settings window is visible (editor UI state only).
#[derive(Resource, Debug, Default)] #[derive(Resource, Debug, Default)]
pub struct ProjectSettingsPanel { pub struct ProjectSettingsPanel {
pub open: bool, pub open: bool,
pub draft: Option<ProjectSettings>, pub draft: Option<ProjectSettings>,
disk_snapshot: Option<FileSnapshot>,
save_error: Option<String>,
} }
pub struct SettingsUiPlugin; pub struct SettingsUiPlugin;
@ -49,23 +39,11 @@ pub fn project_settings_window(
) { ) {
if !panel.open { if !panel.open {
panel.draft = None; panel.draft = None;
panel.disk_snapshot = None;
panel.save_error = None;
return; return;
} }
if panel.draft.is_none() { if panel.draft.is_none() {
panel.draft = Some(world.resource::<ProjectSettings>().clone()); panel.draft = Some(world.resource::<ProjectSettings>().clone());
let io = world.resource::<ProjectSettingsIo>();
panel.disk_snapshot = Some(
io.loaded_source
.as_deref()
.map(|source| {
FileSnapshot::from_loaded_bytes(Path::new(&io.path), source.as_bytes())
})
.unwrap_or_else(FileSnapshot::missing),
);
panel.save_error = None;
} }
let mut open = panel.open; let mut open = panel.open;
@ -73,33 +51,17 @@ pub fn project_settings_window(
.open(&mut open) .open(&mut open)
.default_width(420.0) .default_width(420.0)
.show(ctx, |ui| { .show(ctx, |ui| {
let ProjectSettingsPanel { let Some(draft) = panel.draft.as_mut() else {
draft: Some(draft),
disk_snapshot,
save_error,
..
} = panel
else {
return; return;
}; };
let io_path = world.resource::<ProjectSettingsIo>().path.clone(); let io_path = world.resource::<ProjectSettingsIo>().path.clone();
let mut io_dirty = world.resource::<ProjectSettingsIo>().dirty; let mut io_dirty = world.resource::<ProjectSettingsIo>().dirty;
draw_project_settings_form( draw_project_settings_form(world, ui, draft, &io_path, &mut io_dirty);
world,
ui,
draft,
&io_path,
&mut io_dirty,
disk_snapshot,
save_error,
);
world.resource_mut::<ProjectSettingsIo>().dirty = io_dirty; world.resource_mut::<ProjectSettingsIo>().dirty = io_dirty;
}); });
panel.open = open; panel.open = open;
if !open { if !open {
panel.draft = None; panel.draft = None;
panel.disk_snapshot = None;
panel.save_error = None;
} }
} }
@ -109,26 +71,12 @@ fn draw_project_settings_form(
draft: &mut ProjectSettings, draft: &mut ProjectSettings,
io_path: &str, io_path: &str,
io_dirty: &mut bool, io_dirty: &mut bool,
disk_snapshot: &mut Option<FileSnapshot>,
save_error: &mut Option<String>,
) { ) {
let caps = world let caps = world
.get_resource::<settings::RenderingCapabilities>() .get_resource::<settings::RenderingCapabilities>()
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.small(egui::RichText::new(io_path).monospace());
if let Some(state) = world.get_resource::<CollaborationState>() {
let status = state.file_status(Path::new(io_path));
let _ = file_status_indicator_ui(ui, &status, Path::new(io_path));
}
});
if let Some(error) = save_error.as_deref() {
ui.colored_label(crate::ui::theme::ERROR, error);
}
ui.separator();
ui.collapsing("Global Illumination", |ui| { ui.collapsing("Global Illumination", |ui| {
egui::ComboBox::from_label("GiMode") egui::ComboBox::from_label("GiMode")
.selected_text(format!("{:?}", draft.rendering.gi_mode)) .selected_text(format!("{:?}", draft.rendering.gi_mode))
@ -387,116 +335,25 @@ fn draw_project_settings_form(
.clicked() .clicked()
{ {
commit_project_settings(world, draft); commit_project_settings(world, draft);
*io_dirty = true;
let settings = world.resource::<ProjectSettings>().clone(); let settings = world.resource::<ProjectSettings>().clone();
let Some(expected) = disk_snapshot.clone() else {
let error = "Project settings disk baseline is unavailable; reload before saving";
*save_error = Some(error.into());
world.resource_mut::<SceneIo>().set_status(error);
return;
};
match persist_project_settings(world, &settings, &expected) {
Ok((snapshot, source)) => {
let mut io = world.resource_mut::<ProjectSettingsIo>(); let mut io = world.resource_mut::<ProjectSettingsIo>();
io.dirty = false; if let Err(error) = save_project_settings(&settings, &mut io) {
io.loaded_source = Some(source);
*disk_snapshot = Some(snapshot);
*save_error = None;
*io_dirty = false;
world
.resource_mut::<SceneIo>()
.set_status(format!("Saved project settings to {io_path}"));
}
Err(error) => {
warn!("Save project settings failed: {error}"); warn!("Save project settings failed: {error}");
*save_error = Some(error.clone()); } else {
world *io_dirty = false;
.resource_mut::<SceneIo>()
.set_status(format!("Save project settings failed: {error}"));
}
} }
} }
if ui.button("Revert from disk").clicked() { if ui.button("Revert from disk").clicked() {
match read_project_settings_document(Path::new(io_path)) { *draft = load_project_settings_from_path(io_path);
Ok((settings, source, snapshot)) => {
*draft = settings;
commit_project_settings(world, draft); commit_project_settings(world, draft);
let mut io = world.resource_mut::<ProjectSettingsIo>(); world.resource_mut::<ProjectSettingsIo>().dirty = false;
io.dirty = false;
io.loaded_source = Some(source);
*disk_snapshot = Some(snapshot);
*save_error = None;
*io_dirty = false; *io_dirty = false;
} }
Err(error) => {
*save_error = Some(error.clone());
world
.resource_mut::<SceneIo>()
.set_status(format!("Revert project settings failed: {error}"));
}
}
}
if *io_dirty { if *io_dirty {
ui.label(egui::RichText::new("Unsaved changes").color(egui::Color32::YELLOW)); ui.label(egui::RichText::new("Unsaved changes").color(egui::Color32::YELLOW));
} }
} }
fn persist_project_settings(
world: &mut World,
settings: &ProjectSettings,
expected: &FileSnapshot,
) -> Result<(FileSnapshot, String), String> {
let path = world.resource::<ProjectSettingsIo>().path.clone();
let source = save_project_settings_to_string(settings).map_err(|error| error.to_string())?;
let snapshot = publish_authored_file(
world,
Path::new(&path),
source.as_bytes(),
expected,
FileWriteIntent::ProjectSettings,
)?;
Ok((snapshot, source))
}
fn read_project_settings_document(
path: &Path,
) -> Result<(ProjectSettings, String, FileSnapshot), String> {
let source = std::fs::read_to_string(path)
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
let settings: ProjectSettings = ron::from_str(&source)
.map_err(|error| format!("invalid project settings in {}: {error}", path.display()))?;
let snapshot = FileSnapshot::from_loaded_bytes(path, source.as_bytes());
Ok((settings, source, snapshot))
}
pub(crate) fn reload_project_settings_after_file_conflict(
world: &mut World,
path: &Path,
) -> Result<String, String> {
let active_path = world.resource::<ProjectSettingsIo>().path.clone();
if Path::new(&active_path) != path {
return Err("the project settings conflict no longer matches the active manifest".into());
}
let (settings, source, snapshot) = read_project_settings_document(path)?;
commit_project_settings(world, &settings);
{
let mut io = world.resource_mut::<ProjectSettingsIo>();
io.dirty = false;
io.loaded_source = Some(source);
}
{
let mut panel = world.resource_mut::<ProjectSettingsPanel>();
panel.draft = Some(settings);
panel.disk_snapshot = Some(snapshot);
panel.save_error = None;
}
Ok(format!("Reloaded project settings from {}", path.display()))
}
pub(crate) fn project_settings_conflict_copy_saved(world: &mut World) {
world.resource_mut::<ProjectSettingsPanel>().save_error = None;
}
fn commit_project_settings(world: &mut World, draft: &ProjectSettings) { fn commit_project_settings(world: &mut World, draft: &ProjectSettings) {
let previous = world.resource::<ProjectSettings>().rendering.gi_mode; let previous = world.resource::<ProjectSettings>().rendering.gi_mode;
*world.resource_mut::<ProjectSettings>() = draft.clone(); *world.resource_mut::<ProjectSettings>() = draft.clone();
@ -549,35 +406,3 @@ fn apply_project_settings_changes(
// Full stack refresh runs next frame via `render_view::sync_project_render_view` // Full stack refresh runs next frame via `render_view::sync_project_render_view`
// when `ProjectSettings` changes. // when `ProjectSettings` changes.
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_settings_save_refuses_an_external_revision() {
let root = std::env::temp_dir().join(format!(
"blacksite-project-settings-external-write-{}",
uuid::Uuid::new_v4()
));
let path = root.join("assets/project.ron");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let loaded = save_project_settings_to_string(&ProjectSettings::default()).unwrap();
std::fs::write(&path, &loaded).unwrap();
let expected = FileSnapshot::from_loaded_bytes(&path, loaded.as_bytes());
let mut world = World::new();
world.insert_resource(ProjectSettingsIo {
path: path.to_string_lossy().into_owned(),
loaded_source: Some(loaded),
..Default::default()
});
world.insert_resource(SceneIo::default());
std::fs::write(&path, b"external").unwrap();
let result = persist_project_settings(&mut world, &ProjectSettings::default(), &expected);
assert!(result.unwrap_err().contains("changed outside Blacksite"));
assert_eq!(std::fs::read(&path).unwrap(), b"external");
std::fs::remove_dir_all(root).unwrap();
}
}

View File

@ -13,20 +13,17 @@ pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
atomic_write_with_pre_rename(path, bytes, || Ok(())) atomic_write_with_pre_rename(path, bytes, || Ok(()))
} }
pub(crate) fn atomic_write_with_pre_rename<E>( fn atomic_write_with_pre_rename(
path: &Path, path: &Path,
bytes: &[u8], bytes: &[u8],
before_rename: impl FnOnce() -> Result<(), E>, before_rename: impl FnOnce() -> Result<(), String>,
) -> Result<(), E> ) -> Result<(), String> {
where
E: From<String>,
{
let parent = path let parent = path
.parent() .parent()
.filter(|parent| !parent.as_os_str().is_empty()) .filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new(".")); .unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent) fs::create_dir_all(parent)
.map_err(|error| E::from(format!("could not create {}: {error}", parent.display())))?; .map_err(|error| format!("could not create {}: {error}", parent.display()))?;
let file_name = path let file_name = path
.file_name() .file_name()
@ -43,21 +40,18 @@ where
.create_new(true) .create_new(true)
.write(true) .write(true)
.open(&temporary) .open(&temporary)
.map_err(|error| { .map_err(|error| format!("could not create {}: {error}", temporary.display()))?;
E::from(format!("could not create {}: {error}", temporary.display())) file.write_all(bytes)
})?; .map_err(|error| format!("could not write {}: {error}", temporary.display()))?;
file.write_all(bytes).map_err(|error| {
E::from(format!("could not write {}: {error}", temporary.display()))
})?;
file.sync_all() file.sync_all()
.map_err(|error| E::from(format!("could not sync {}: {error}", temporary.display())))?; .map_err(|error| format!("could not sync {}: {error}", temporary.display()))?;
before_rename()?; before_rename()?;
fs::rename(&temporary, path).map_err(|error| { fs::rename(&temporary, path).map_err(|error| {
E::from(format!( format!(
"could not replace {} with {}: {error}", "could not replace {} with {}: {error}",
path.display(), path.display(),
temporary.display() temporary.display()
)) )
})?; })?;
sync_parent_directory(parent); sync_parent_directory(parent);
Ok(()) Ok(())

View File

@ -23,9 +23,8 @@ use shared::{
use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS}; use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS};
use crate::history::{clear_level_objects, snapshot_entity, EditorHistory}; use crate::history::{clear_level_objects, snapshot_entity, EditorHistory};
use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent};
use crate::scene::recovery::{ use crate::scene::recovery::{
default_state_root, discard_recovery_snapshots, latest_recovery_snapshot, atomic_write, default_state_root, discard_recovery_snapshots, latest_recovery_snapshot,
write_recovery_snapshot, write_recovery_snapshot,
}; };
use crate::selection::SelectedEntity; use crate::selection::SelectedEntity;
@ -61,7 +60,6 @@ pub struct SceneTab {
pub dirty: bool, pub dirty: bool,
snapshot: String, snapshot: String,
recovery_snapshot: Option<PathBuf>, recovery_snapshot: Option<PathBuf>,
disk_snapshot: Option<FileSnapshot>,
} }
impl SceneTab { impl SceneTab {
@ -128,7 +126,6 @@ impl Default for SceneIo {
dirty: false, dirty: false,
snapshot: String::new(), snapshot: String::new(),
recovery_snapshot: None, recovery_snapshot: None,
disk_snapshot: None,
}], }],
active_tab: 0, active_tab: 0,
change_revision: 0, change_revision: 0,
@ -255,14 +252,9 @@ fn load_startup_scene(world: &mut World) {
let path = active_path.unwrap_or(default_path); let path = active_path.unwrap_or(default_path);
match load_level(world, &path) { match load_level(world, &path) {
Ok(disk_snapshot) => { Ok(()) => {
{ world.resource_mut::<SceneIo>().active_path = Some(path.clone());
let mut io = world.resource_mut::<SceneIo>(); world.resource_mut::<SceneIo>().mark_clean();
io.active_path = Some(path.clone());
let active_tab = io.active_tab;
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
io.mark_clean();
}
remember_path(world, path.clone()); remember_path(world, path.clone());
world.resource_mut::<EditorHistory>().clear(); world.resource_mut::<EditorHistory>().clear();
refresh_recovery_notice(world, &path); refresh_recovery_notice(world, &path);
@ -404,7 +396,7 @@ fn open_path(world: &mut World, path: PathBuf) -> String {
return format!("Open failed: could not preserve active scene: {error}"); return format!("Open failed: could not preserve active scene: {error}");
} }
match load_level(world, &path) { match load_level(world, &path) {
Ok(disk_snapshot) => { Ok(()) => {
let id = world.resource_mut::<SceneIo>().allocate_tab_id(); let id = world.resource_mut::<SceneIo>().allocate_tab_id();
{ {
let mut io = world.resource_mut::<SceneIo>(); let mut io = world.resource_mut::<SceneIo>();
@ -414,7 +406,6 @@ fn open_path(world: &mut World, path: PathBuf) -> String {
dirty: false, dirty: false,
snapshot: String::new(), snapshot: String::new(),
recovery_snapshot: None, recovery_snapshot: None,
disk_snapshot: Some(disk_snapshot),
}); });
io.active_tab = io.tabs.len() - 1; io.active_tab = io.tabs.len() - 1;
io.active_path = Some(path.clone()); io.active_path = Some(path.clone());
@ -451,7 +442,6 @@ fn new_scene_tab(world: &mut World) -> String {
dirty: false, dirty: false,
snapshot: String::new(), snapshot: String::new(),
recovery_snapshot: None, recovery_snapshot: None,
disk_snapshot: None,
}); });
io.active_tab = io.tabs.len() - 1; io.active_tab = io.tabs.len() - 1;
io.active_path = None; io.active_path = None;
@ -560,7 +550,6 @@ fn close_scene_tab(world: &mut World, index: usize) -> String {
dirty: false, dirty: false,
snapshot: String::new(), snapshot: String::new(),
recovery_snapshot: None, recovery_snapshot: None,
disk_snapshot: None,
}]; }];
io.active_tab = 0; io.active_tab = 0;
io.active_path = None; io.active_path = None;
@ -702,24 +691,12 @@ fn save_selection_as_prefab(world: &mut World) -> String {
else { else {
return "Save prefab cancelled".to_string(); return "Save prefab cancelled".to_string();
}; };
let expected = match FileSnapshot::capture(&path) {
Ok(expected) => expected,
Err(error) => return format!("Save prefab failed: {error}"),
};
if ensure_unique_actor_ids(world, &selection) > 0 { if ensure_unique_actor_ids(world, &selection) > 0 {
world.resource_mut::<SceneIo>().mark_dirty(); world.resource_mut::<SceneIo>().mark_dirty();
} }
match save_prefab_entities( match save_prefab_entities(world, &path, selection) {
world,
&path,
selection,
SceneWriteContext::Standalone {
expected,
description: "prefab copy",
},
) {
Ok(count) => { Ok(count) => {
world.resource_mut::<EditorAssets>().refresh(); world.resource_mut::<EditorAssets>().refresh();
format!( format!(
@ -798,7 +775,6 @@ fn save_prefab_entities(
world: &mut World, world: &mut World,
path: &Path, path: &Path,
entities: Vec<Entity>, entities: Vec<Entity>,
write_context: SceneWriteContext,
) -> Result<usize, String> { ) -> Result<usize, String> {
let count = entities.len(); let count = entities.len();
let original_actor_ids: Vec<_> = entities let original_actor_ids: Vec<_> = entities
@ -815,7 +791,7 @@ fn save_prefab_entities(
.clone(), .clone(),
); );
scene::validate_prefab_graph_text(&text, path, &project_root)?; scene::validate_prefab_graph_text(&text, path, &project_root)?;
publish_scene_text(world, path, text.as_bytes(), count, write_context)?; atomic_write(path, text.as_bytes())?;
Ok(count) Ok(count)
})(); })();
if result.is_err() { if result.is_err() {
@ -845,7 +821,7 @@ pub fn save_active_or_prompt_world(world: &mut World) -> String {
fn save_active_or_prompt(world: &mut World) -> String { fn save_active_or_prompt(world: &mut World) -> String {
let path = world.resource::<SceneIo>().active_path.clone(); let path = world.resource::<SceneIo>().active_path.clone();
match path { match path {
Some(path) => match save_level(world, &path, SceneWriteContext::Active) { Some(path) => match save_level(world, &path) {
Ok(count) => { Ok(count) => {
world.resource_mut::<SceneIo>().mark_clean(); world.resource_mut::<SceneIo>().mark_clean();
remember_path(world, path.clone()); remember_path(world, path.clone());
@ -867,12 +843,8 @@ fn save_with_dialog(world: &mut World) -> String {
else { else {
return "Save cancelled".to_string(); return "Save cancelled".to_string();
}; };
let expected = match FileSnapshot::capture(&path) {
Ok(expected) => expected,
Err(error) => return format!("Save failed: {error}"),
};
match save_level(world, &path, SceneWriteContext::ActiveSaveAs { expected }) { match save_level(world, &path) {
Ok(count) => { Ok(count) => {
world.resource_mut::<SceneIo>().active_path = Some(path.clone()); world.resource_mut::<SceneIo>().active_path = Some(path.clone());
world.resource_mut::<SceneIo>().mark_clean(); world.resource_mut::<SceneIo>().mark_clean();
@ -929,20 +901,8 @@ fn export_selection_with_dialog(world: &mut World) -> String {
else { else {
return "Export cancelled".to_string(); return "Export cancelled".to_string();
}; };
let expected = match FileSnapshot::capture(&path) {
Ok(expected) => expected,
Err(error) => return format!("Export failed: {error}"),
};
match save_standalone_entities( match save_standalone_entities(world, &path, vec![entity]) {
world,
&path,
vec![entity],
SceneWriteContext::Standalone {
expected,
description: "selection export",
},
) {
Ok(count) => format!("Exported {count} selected entity to {}", path.display()), Ok(count) => format!("Exported {count} selected entity to {}", path.display()),
Err(err) => format!("Export failed: {err}"), Err(err) => format!("Export failed: {err}"),
} }
@ -974,16 +934,12 @@ fn clear_scene_world(world: &mut World) {
} }
} }
fn save_level( fn save_level(world: &mut World, path: &Path) -> Result<usize, String> {
world: &mut World,
path: &Path,
write_context: SceneWriteContext,
) -> Result<usize, String> {
let entities = authored_scene_entities(world); let entities = authored_scene_entities(world);
if is_prefab_document(path) { if is_prefab_document(path) {
save_prefab_entities(world, path, entities, write_context) save_prefab_entities(world, path, entities)
} else { } else {
save_entities(world, path, entities, write_context) save_entities(world, path, entities)
} }
} }
@ -1175,15 +1131,10 @@ fn format_actor_validation(err: ActorValidationError) -> String {
} }
} }
fn save_entities( fn save_entities(world: &mut World, path: &Path, entities: Vec<Entity>) -> Result<usize, String> {
world: &mut World,
path: &Path,
entities: Vec<Entity>,
write_context: SceneWriteContext,
) -> Result<usize, String> {
let count = entities.len(); let count = entities.len();
let text = serialize_entities(world, entities)?; let text = serialize_entities(world, entities)?;
publish_scene_text(world, path, text.as_bytes(), count, write_context)?; atomic_write(path, text.as_bytes())?;
Ok(count) Ok(count)
} }
@ -1191,108 +1142,13 @@ fn save_standalone_entities(
world: &mut World, world: &mut World,
path: &Path, path: &Path,
entities: Vec<Entity>, entities: Vec<Entity>,
write_context: SceneWriteContext,
) -> Result<usize, String> { ) -> Result<usize, String> {
let count = entities.len(); let count = entities.len();
let text = serialize_standalone_entities(world, entities)?; let text = serialize_standalone_entities(world, entities)?;
publish_scene_text(world, path, text.as_bytes(), count, write_context)?; atomic_write(path, text.as_bytes())?;
Ok(count) Ok(count)
} }
#[derive(Clone)]
enum SceneWriteContext {
Active,
ActiveSaveAs {
expected: FileSnapshot,
},
Standalone {
expected: FileSnapshot,
description: &'static str,
},
}
fn publish_scene_text(
world: &mut World,
path: &Path,
bytes: &[u8],
entity_count: usize,
context: SceneWriteContext,
) -> Result<(), String> {
match context {
SceneWriteContext::Active | SceneWriteContext::ActiveSaveAs { .. } => {
let (tab_id, expected) = match context {
SceneWriteContext::Active => active_scene_write_baseline(world, path)?,
SceneWriteContext::ActiveSaveAs { expected } => {
(active_scene_tab_id(world)?, expected)
}
SceneWriteContext::Standalone { .. } => unreachable!(),
};
let snapshot = publish_authored_file(
world,
path,
bytes,
&expected,
FileWriteIntent::Scene {
tab_id,
entity_count,
},
)?;
let mut io = world.resource_mut::<SceneIo>();
let Some(tab) = io.tabs.iter_mut().find(|tab| tab.id == tab_id) else {
return Err("saved scene tab no longer exists".into());
};
tab.disk_snapshot = Some(snapshot);
}
SceneWriteContext::Standalone {
expected,
description,
} => {
publish_authored_file(
world,
path,
bytes,
&expected,
FileWriteIntent::Standalone {
description: description.into(),
},
)?;
}
}
Ok(())
}
fn active_scene_write_baseline(
world: &World,
destination: &Path,
) -> Result<(u64, FileSnapshot), String> {
let io = world.resource::<SceneIo>();
let tab = io
.tabs
.get(io.active_tab)
.ok_or_else(|| "active scene tab is missing".to_string())?;
if io.active_path.as_deref() != Some(destination) || tab.path.as_deref() != Some(destination) {
return Err(format!(
"{} is not the active scene destination; use Save As",
destination.display()
));
}
let expected = tab.disk_snapshot.clone().ok_or_else(|| {
format!(
"scene disk baseline is unavailable for {}; reload it or use Save As",
destination.display()
)
})?;
Ok((tab.id, expected))
}
fn active_scene_tab_id(world: &World) -> Result<u64, String> {
let io = world.resource::<SceneIo>();
io.tabs
.get(io.active_tab)
.map(|tab| tab.id)
.ok_or_else(|| "active scene tab is missing".to_string())
}
fn serialize_entities(world: &mut World, entities: Vec<Entity>) -> Result<String, String> { fn serialize_entities(world: &mut World, entities: Vec<Entity>) -> Result<String, String> {
serialize_entities_inner_with_resources(world, entities, true) serialize_entities_inner_with_resources(world, entities, true)
} }
@ -1574,7 +1430,7 @@ fn restore_recovery(world: &mut World) -> String {
return "No scene recovery snapshot is available".to_string(); return "No scene recovery snapshot is available".to_string();
}; };
match load_level(world, &snapshot) { match load_level(world, &snapshot) {
Ok(_) => { Ok(()) => {
mark_recovery_restored(&mut world.resource_mut::<SceneIo>(), active_path); mark_recovery_restored(&mut world.resource_mut::<SceneIo>(), active_path);
world.resource_mut::<EditorHistory>().clear(); world.resource_mut::<EditorHistory>().clear();
format!( format!(
@ -1615,12 +1471,8 @@ fn save_recovery_copy_with_dialog(world: &mut World) -> String {
else { else {
return "Save recovery copy cancelled".to_string(); return "Save recovery copy cancelled".to_string();
}; };
let expected = match FileSnapshot::capture(&destination) {
Ok(expected) => expected,
Err(error) => return format!("Save recovery copy failed: {error}"),
};
match write_recovery_copy(world, &snapshot, &destination, &expected) { match write_recovery_copy(&snapshot, &destination) {
Ok(()) => format!( Ok(()) => format!(
"Saved recovery copy {} from {}", "Saved recovery copy {} from {}",
destination.display(), destination.display(),
@ -1630,24 +1482,10 @@ fn save_recovery_copy_with_dialog(world: &mut World) -> String {
} }
} }
fn write_recovery_copy( fn write_recovery_copy(snapshot: &Path, destination: &Path) -> Result<(), String> {
world: &mut World,
snapshot: &Path,
destination: &Path,
expected: &FileSnapshot,
) -> Result<(), String> {
let bytes = std::fs::read(snapshot) let bytes = std::fs::read(snapshot)
.map_err(|error| format!("could not read {}: {error}", snapshot.display()))?; .map_err(|error| format!("could not read {}: {error}", snapshot.display()))?;
publish_authored_file( atomic_write(destination, &bytes)
world,
destination,
&bytes,
expected,
FileWriteIntent::Standalone {
description: "recovery scene copy".into(),
},
)
.map(|_| ())
} }
fn mark_recovery_restored(io: &mut SceneIo, active_path: Option<PathBuf>) { fn mark_recovery_restored(io: &mut SceneIo, active_path: Option<PathBuf>) {
@ -1684,79 +1522,14 @@ fn discard_recovery(world: &mut World) -> String {
} }
} }
fn load_level(world: &mut World, path: &Path) -> Result<FileSnapshot, String> { fn load_level(world: &mut World, path: &Path) -> Result<(), String> {
if !path.exists() { if !path.exists() {
return Err(format!("{} does not exist yet", path.display())); return Err(format!("{} does not exist yet", path.display()));
} }
let text = std::fs::read_to_string(path) let text = std::fs::read_to_string(path)
.map_err(|err| format!("could not read {}: {err}", path.display()))?; .map_err(|err| format!("could not read {}: {err}", path.display()))?;
let disk_snapshot = FileSnapshot::from_loaded_bytes(path, text.as_bytes()); load_level_text(world, Some(path), &text)
load_level_text(world, Some(path), &text)?;
Ok(disk_snapshot)
}
pub(crate) fn reload_scene_after_file_conflict(
world: &mut World,
tab_id: u64,
path: &Path,
) -> Result<String, String> {
let active_matches = {
let io = world.resource::<SceneIo>();
io.tabs
.get(io.active_tab)
.is_some_and(|tab| tab.id == tab_id)
};
if !active_matches {
return Err("the scene conflict no longer belongs to the active tab".into());
}
let disk_snapshot = load_level(world, path)?;
{
let mut io = world.resource_mut::<SceneIo>();
io.active_path = Some(path.to_path_buf());
io.recovery_snapshot = None;
let active_tab = io.active_tab;
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
io.mark_clean();
}
world.resource_mut::<EditorHistory>().clear();
remember_path(world, path.to_path_buf());
refresh_recovery_notice(world, path);
capture_active_tab(world)?;
Ok(format!("Reloaded {} from disk", path.display()))
}
pub(crate) fn adopt_scene_conflict_save_as(
world: &mut World,
tab_id: u64,
path: PathBuf,
disk_snapshot: FileSnapshot,
entity_count: usize,
) -> Result<String, String> {
let active_matches = {
let io = world.resource::<SceneIo>();
io.tabs
.get(io.active_tab)
.is_some_and(|tab| tab.id == tab_id)
};
if !active_matches {
return Err("the scene conflict no longer belongs to the active tab".into());
}
{
let mut io = world.resource_mut::<SceneIo>();
io.active_path = Some(path.clone());
let active_tab = io.active_tab;
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
io.mark_clean();
}
remember_path(world, path.clone());
retire_scene_recovery(world, &path);
Ok(format!(
"Saved {entity_count} level entities to {}",
path.display()
))
} }
fn load_level_text(world: &mut World, path: Option<&Path>, text: &str) -> Result<(), String> { fn load_level_text(world: &mut World, path: Option<&Path>, text: &str) -> Result<(), String> {
@ -2206,7 +1979,6 @@ mod tests {
dirty: true, dirty: true,
snapshot: "dirty inactive tab".to_string(), snapshot: "dirty inactive tab".to_string(),
recovery_snapshot: None, recovery_snapshot: None,
disk_snapshot: None,
}); });
assert!( assert!(
io.has_unsaved_tabs(), io.has_unsaved_tabs(),
@ -2221,61 +1993,6 @@ mod tests {
); );
} }
#[test]
fn active_scene_write_refuses_an_external_revision() {
let root = std::env::temp_dir().join(format!(
"blacksite-scene-external-write-{}",
uuid::Uuid::new_v4()
));
let path = root.join("assets/levels/main.scn.ron");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"loaded").unwrap();
let disk_snapshot = FileSnapshot::capture(&path).unwrap();
let mut io = SceneIo {
active_path: Some(path.clone()),
..Default::default()
};
io.tabs[0].path = Some(path.clone());
io.tabs[0].disk_snapshot = Some(disk_snapshot);
let mut world = World::new();
world.insert_resource(io);
std::fs::write(&path, b"external").unwrap();
let result = publish_scene_text(&mut world, &path, b"editor", 1, SceneWriteContext::Active);
assert!(result.unwrap_err().contains("changed outside Blacksite"));
assert_eq!(std::fs::read(&path).unwrap(), b"external");
assert!(world.resource::<SceneIo>().dirty);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn scene_save_as_refuses_a_file_created_after_destination_selection() {
let root = std::env::temp_dir().join(format!(
"blacksite-scene-save-as-race-{}",
uuid::Uuid::new_v4()
));
let path = root.join("assets/levels/new.scn.ron");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let expected = FileSnapshot::missing();
let mut world = World::new();
world.init_resource::<SceneIo>();
std::fs::write(&path, b"external").unwrap();
let result = publish_scene_text(
&mut world,
&path,
b"editor",
1,
SceneWriteContext::ActiveSaveAs { expected },
);
assert!(result.unwrap_err().contains("changed outside Blacksite"));
assert_eq!(std::fs::read(&path).unwrap(), b"external");
assert!(world.resource::<SceneIo>().dirty);
std::fs::remove_dir_all(root).unwrap();
}
#[test] #[test]
fn composition_edits_participate_in_undo_and_redo() { fn composition_edits_participate_in_undo_and_redo() {
let mut app = App::new(); let mut app = App::new();
@ -3053,13 +2770,8 @@ mod tests {
.insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default()); .insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());
app.world_mut().init_resource::<SceneIo>(); app.world_mut().init_resource::<SceneIo>();
let expected = load_level(app.world_mut(), &prefab_path).unwrap(); load_level(app.world_mut(), &prefab_path).unwrap();
save_level( save_level(app.world_mut(), &prefab_path).unwrap();
app.world_mut(),
&prefab_path,
SceneWriteContext::ActiveSaveAs { expected },
)
.unwrap();
scene::validate_prefab_graph(&prefab_path, &root).unwrap(); scene::validate_prefab_graph(&prefab_path, &root).unwrap();
let document = let document =
@ -3101,11 +2813,8 @@ mod tests {
let destination = root.join("kept.scn.ron"); let destination = root.join("kept.scn.ron");
std::fs::create_dir_all(&root).unwrap(); std::fs::create_dir_all(&root).unwrap();
std::fs::write(&snapshot, b"recovered scene bytes").unwrap(); std::fs::write(&snapshot, b"recovered scene bytes").unwrap();
let expected = FileSnapshot::missing();
let mut world = World::new();
world.init_resource::<SceneIo>();
write_recovery_copy(&mut world, &snapshot, &destination, &expected).unwrap(); write_recovery_copy(&snapshot, &destination).unwrap();
assert_eq!( assert_eq!(
std::fs::read(&destination).unwrap(), std::fs::read(&destination).unwrap(),

View File

@ -20,10 +20,6 @@ use crate::assets::{
AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets, ASSETS_ROOT, AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets, ASSETS_ROOT,
BUILTINS_FOLDER, BUILTINS_FOLDER,
}; };
use crate::project::collaboration::{
file_status_indicator_ui, publish_authored_file, CollaborationState, FileSnapshot,
FileWriteIntent,
};
use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::scene_io::{SceneIo, SceneIoRequest};
use super::state::{ use super::state::{
@ -1861,7 +1857,7 @@ fn top_level_asset_details_panel(
selected_entities: &SelectedEntities, selected_entities: &SelectedEntities,
asset: &EditorAsset, asset: &EditorAsset,
) { ) {
asset_details_header(world, ui, asset); asset_details_header(ui, asset);
ui.add_space(8.0); ui.add_space(8.0);
detail_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in")); detail_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in"));
detail_row(ui, "Folder", asset.folder_path.as_str()); detail_row(ui, "Folder", asset.folder_path.as_str());
@ -1950,7 +1946,7 @@ fn top_level_asset_details_panel(
asset_action_buttons(world, ui, selected_entities, asset); asset_action_buttons(world, ui, selected_entities, asset);
} }
fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) { fn asset_details_header(ui: &mut egui::Ui, asset: &EditorAsset) {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.label( ui.label(
egui::RichText::new(kind_icon(&asset.kind).as_str()) egui::RichText::new(kind_icon(&asset.kind).as_str())
@ -1963,13 +1959,6 @@ fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.strong(asset.label.as_str()); ui.strong(asset.label.as_str());
ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM)); ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM));
if let (Some(state), Some(path)) = (
world.get_resource::<CollaborationState>(),
asset.path.as_deref(),
) {
let status = state.file_status(Path::new(path));
let _ = file_status_indicator_ui(ui, &status, Path::new(path));
}
}); });
}); });
} }
@ -2445,19 +2434,10 @@ fn ensure_material_draft(world: &mut World, path: &str, fallback_label: &str) {
if !needs_load { if !needs_load {
return; return;
} }
let loaded = fs::read_to_string(path) let draft = match MaterialAsset::load_from_path(path) {
.map_err(|error| format!("could not read {path}: {error}")) Ok(asset) => MaterialAssetDraft {
.and_then(|text| {
let snapshot = FileSnapshot::from_loaded_bytes(Path::new(path), text.as_bytes());
ron::from_str::<MaterialAsset>(&text)
.map(|asset| (asset, snapshot))
.map_err(|error| format!("invalid material RON in {path}: {error}"))
});
let draft = match loaded {
Ok((asset, disk_snapshot)) => MaterialAssetDraft {
path: path.to_string(), path: path.to_string(),
asset, asset,
disk_snapshot,
error: None, error: None,
}, },
Err(error) => MaterialAssetDraft { Err(error) => MaterialAssetDraft {
@ -2470,8 +2450,6 @@ fn ensure_material_draft(world: &mut World, path: &str, fallback_label: &str) {
render_state: shared::MaterialRenderState::default(), render_state: shared::MaterialRenderState::default(),
material: MaterialDesc::default(), material: MaterialDesc::default(),
}, },
disk_snapshot: FileSnapshot::capture(Path::new(path))
.unwrap_or_else(|_| FileSnapshot::missing()),
error: Some(error), error: Some(error),
}, },
}; };
@ -2489,23 +2467,17 @@ fn save_material_draft(world: &mut World) {
let result = ron::ser::to_string_pretty(&draft.asset, ron::ser::PrettyConfig::default()) let result = ron::ser::to_string_pretty(&draft.asset, ron::ser::PrettyConfig::default())
.map_err(|error| format!("could not serialize material {}: {error}", draft.path)) .map_err(|error| format!("could not serialize material {}: {error}", draft.path))
.and_then(|text| { .and_then(|text| {
publish_authored_file( fs::write(&draft.path, text)
world, .map_err(|error| format!("could not write material {}: {error}", draft.path))
Path::new(&draft.path),
text.as_bytes(),
&draft.disk_snapshot,
FileWriteIntent::Material,
)
}); });
match result { match result {
Ok(disk_snapshot) => { Ok(()) => {
if let Some(current) = world if let Some(current) = world
.resource_mut::<AssetBrowserUiState>() .resource_mut::<AssetBrowserUiState>()
.material_draft .material_draft
.as_mut() .as_mut()
{ {
current.error = None; current.error = None;
current.disk_snapshot = disk_snapshot;
} }
world.resource_mut::<EditorAssets>().refresh(); world.resource_mut::<EditorAssets>().refresh();
invalidate_on_catalog_refresh(world); invalidate_on_catalog_refresh(world);
@ -2584,13 +2556,8 @@ fn create_material_instance_from_base(world: &mut World, base_path: &str, base_l
let result = ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()) let result = ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default())
.map_err(|error| format!("could not serialize material instance: {error}")) .map_err(|error| format!("could not serialize material instance: {error}"))
.and_then(|text| { .and_then(|text| {
publish_authored_file( fs::write(&path, text)
world, .map_err(|error| format!("could not write {}: {error}", path.display()))
&path,
text.as_bytes(),
&FileSnapshot::missing(),
FileWriteIntent::MaterialInstance,
)
}); });
if let Err(error) = result { if let Err(error) = result {
world.resource_mut::<SceneIo>().status = error; world.resource_mut::<SceneIo>().status = error;
@ -2878,19 +2845,10 @@ fn ensure_material_instance_draft(world: &mut World, path: &str, fallback_label:
if !needs_load { if !needs_load {
return; return;
} }
let loaded = fs::read_to_string(path) let draft = match MaterialInstanceAsset::load_from_path(path) {
.map_err(|error| format!("could not read {path}: {error}")) Ok(asset) => MaterialInstanceAssetDraft {
.and_then(|text| {
let snapshot = FileSnapshot::from_loaded_bytes(Path::new(path), text.as_bytes());
ron::from_str::<MaterialInstanceAsset>(&text)
.map(|asset| (asset, snapshot))
.map_err(|error| format!("invalid material-instance RON in {path}: {error}"))
});
let draft = match loaded {
Ok((asset, disk_snapshot)) => MaterialInstanceAssetDraft {
path: path.to_string(), path: path.to_string(),
asset, asset,
disk_snapshot,
error: None, error: None,
}, },
Err(error) => MaterialInstanceAssetDraft { Err(error) => MaterialInstanceAssetDraft {
@ -2902,8 +2860,6 @@ fn ensure_material_instance_draft(world: &mut World, path: &str, fallback_label:
parameters: Vec::new(), parameters: Vec::new(),
textures: Vec::new(), textures: Vec::new(),
}, },
disk_snapshot: FileSnapshot::capture(Path::new(path))
.unwrap_or_else(|_| FileSnapshot::missing()),
error: Some(error), error: Some(error),
}, },
}; };
@ -2928,24 +2884,18 @@ fn save_material_instance_draft(world: &mut World) {
ron::ser::to_string_pretty(&draft.asset, ron::ser::PrettyConfig::default()) ron::ser::to_string_pretty(&draft.asset, ron::ser::PrettyConfig::default())
.map_err(|error| format!("could not serialize material instance: {error}")) .map_err(|error| format!("could not serialize material instance: {error}"))
.and_then(|text| { .and_then(|text| {
publish_authored_file( fs::write(&draft.path, text)
world, .map_err(|error| format!("could not write {}: {error}", draft.path))
Path::new(&draft.path),
text.as_bytes(),
&draft.disk_snapshot,
FileWriteIntent::MaterialInstance,
)
}) })
}; };
match result { match result {
Ok(disk_snapshot) => { Ok(()) => {
if let Some(current) = world if let Some(current) = world
.resource_mut::<AssetBrowserUiState>() .resource_mut::<AssetBrowserUiState>()
.material_instance_draft .material_instance_draft
.as_mut() .as_mut()
{ {
current.error = None; current.error = None;
current.disk_snapshot = disk_snapshot;
} }
world.resource_mut::<EditorAssets>().refresh(); world.resource_mut::<EditorAssets>().refresh();
invalidate_on_catalog_refresh(world); invalidate_on_catalog_refresh(world);
@ -4430,181 +4380,10 @@ fn dedup_strings(values: &mut Vec<String>) {
values.retain(|value| seen.insert(value.clone())); values.retain(|value| seen.insert(value.clone()));
} }
pub(crate) fn validate_material_conflict_destination(
world: &World,
destination: &Path,
) -> Result<String, String> {
let root = PathBuf::from(&world.resource::<crate::project_io::ProjectWorkspace>().root);
let root = root.canonicalize().unwrap_or(root);
let absolute = if destination.is_absolute() {
destination.to_path_buf()
} else {
root.join(destination)
};
let absolute = absolute
.parent()
.and_then(|parent| parent.canonicalize().ok())
.and_then(|parent| destination.file_name().map(|name| parent.join(name)))
.unwrap_or(absolute);
let relative = absolute.strip_prefix(&root).map_err(|_| {
format!(
"Material assets must stay inside {}",
root.join("assets").display()
)
})?;
if !relative.starts_with("assets") {
return Err(format!(
"Material assets must stay inside {}",
root.join("assets").display()
));
}
Ok(relative.to_string_lossy().replace('\\', "/"))
}
pub(crate) fn reload_material_after_file_conflict(
world: &mut World,
path: &Path,
intent: &FileWriteIntent,
) -> Result<String, String> {
let path = path.to_string_lossy();
{
let mut state = world.resource_mut::<AssetBrowserUiState>();
match intent {
FileWriteIntent::Material => {
if state
.material_draft
.as_ref()
.is_some_and(|draft| draft.path == path)
{
state.material_draft = None;
}
}
FileWriteIntent::MaterialInstance => {
if state
.material_instance_draft
.as_ref()
.is_some_and(|draft| draft.path == path)
{
state.material_instance_draft = None;
}
}
_ => return Err("the conflict is not an editable material document".into()),
}
}
world.resource_mut::<EditorAssets>().refresh();
invalidate_on_catalog_refresh(world);
Ok(format!("Reloaded {} from disk", path))
}
pub(crate) fn adopt_material_conflict_save_as(
world: &mut World,
original_path: &Path,
catalog_path: String,
disk_snapshot: FileSnapshot,
intent: &FileWriteIntent,
) -> Result<String, String> {
let original_path = original_path.to_string_lossy();
{
let mut state = world.resource_mut::<AssetBrowserUiState>();
match intent {
FileWriteIntent::Material => {
if let Some(draft) = state
.material_draft
.as_mut()
.filter(|draft| draft.path == original_path)
{
draft.path.clone_from(&catalog_path);
draft.disk_snapshot = disk_snapshot;
draft.error = None;
} else {
let asset = MaterialAsset::load_from_path(&catalog_path)?;
state.material_draft = Some(MaterialAssetDraft {
path: catalog_path.clone(),
asset,
disk_snapshot,
error: None,
});
}
}
FileWriteIntent::MaterialInstance => {
if let Some(draft) = state
.material_instance_draft
.as_mut()
.filter(|draft| draft.path == original_path)
{
draft.path.clone_from(&catalog_path);
draft.disk_snapshot = disk_snapshot;
draft.error = None;
} else {
let asset = MaterialInstanceAsset::load_from_path(&catalog_path)?;
state.material_instance_draft = Some(MaterialInstanceAssetDraft {
path: catalog_path.clone(),
asset,
disk_snapshot,
error: None,
});
}
}
_ => return Err("the conflict is not an editable material document".into()),
}
}
{
let mut assets = world.resource_mut::<EditorAssets>();
assets.refresh();
assets.select(AssetSelection::File(catalog_path.clone()));
}
invalidate_on_catalog_refresh(world);
Ok(format!("Saved material copy to {catalog_path}"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn material_apply_refuses_an_external_revision() {
let root = std::env::temp_dir().join(format!(
"blacksite-material-external-write-{}",
uuid::Uuid::new_v4()
));
let path = root.join("assets/materials/test.ron");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, b"loaded").unwrap();
let disk_snapshot = FileSnapshot::capture(&path).unwrap();
let draft = MaterialAssetDraft {
path: path.to_string_lossy().into_owned(),
asset: MaterialAsset {
schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
label: "Test".into(),
shader: None,
shader_ref: None,
render_state: shared::MaterialRenderState::default(),
material: MaterialDesc::default(),
},
disk_snapshot,
error: None,
};
let state = AssetBrowserUiState {
material_draft: Some(draft),
..Default::default()
};
let mut world = World::new();
world.insert_resource(state);
world.insert_resource(SceneIo::default());
fs::write(&path, b"external").unwrap();
save_material_draft(&mut world);
assert_eq!(fs::read(&path).unwrap(), b"external");
assert!(world
.resource::<AssetBrowserUiState>()
.material_draft
.as_ref()
.and_then(|draft| draft.error.as_deref())
.is_some_and(|error| error.contains("changed outside Blacksite")));
fs::remove_dir_all(root).unwrap();
}
#[test] #[test]
fn audio_filter_matches_only_audio_clips() { fn audio_filter_matches_only_audio_clips() {
let audio = EditorAsset { let audio = EditorAsset {

View File

@ -7,7 +7,6 @@ use shared::{MaterialAsset, MaterialInstanceAsset};
use crate::asset_db::ImportSettings; use crate::asset_db::ImportSettings;
use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER}; use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER};
use crate::project::collaboration::FileSnapshot;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AssetBrowserView { pub(crate) enum AssetBrowserView {
@ -70,7 +69,6 @@ pub(crate) struct ImportSettingsDraft {
pub(crate) struct MaterialAssetDraft { pub(crate) struct MaterialAssetDraft {
pub(crate) path: String, pub(crate) path: String,
pub(crate) asset: MaterialAsset, pub(crate) asset: MaterialAsset,
pub(crate) disk_snapshot: FileSnapshot,
pub(crate) error: Option<String>, pub(crate) error: Option<String>,
} }
@ -78,7 +76,6 @@ pub(crate) struct MaterialAssetDraft {
pub(crate) struct MaterialInstanceAssetDraft { pub(crate) struct MaterialInstanceAssetDraft {
pub(crate) path: String, pub(crate) path: String,
pub(crate) asset: MaterialInstanceAsset, pub(crate) asset: MaterialInstanceAsset,
pub(crate) disk_snapshot: FileSnapshot,
pub(crate) error: Option<String>, pub(crate) error: Option<String>,
} }

View File

@ -39,10 +39,6 @@ use crate::project_io::UserPreferences;
use crate::selection::SelectedEntity; use crate::selection::SelectedEntity;
use crate::state::EditorMode; use crate::state::EditorMode;
pub(crate) use asset_browser::{
adopt_material_conflict_save_as, reload_material_after_file_conflict,
validate_material_conflict_destination,
};
pub use build::BuildPanel; pub use build::BuildPanel;
pub use diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel}; pub use diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
pub use layout::LayoutSaveTimer; pub use layout::LayoutSaveTimer;
@ -219,8 +215,6 @@ impl UiState {
project_settings_window(world, ctx, &mut panel); project_settings_window(world, ctx, &mut panel);
}); });
crate::collaboration::file_conflict_modal(world, ctx);
if ctx.input(|input| input.key_pressed(egui::Key::F1)) { if ctx.input(|input| input.key_pressed(egui::Key::F1)) {
world.resource_mut::<ViewportUiState>().shortcuts_open = true; world.resource_mut::<ViewportUiState>().shortcuts_open = true;
} }

View File

@ -7,7 +7,6 @@ use egui_phosphor_icons::icons;
use crate::history::EditorHistory; use crate::history::EditorHistory;
use crate::operators::ActiveOperator; use crate::operators::ActiveOperator;
use crate::project::collaboration::{file_status_indicator_ui, CollaborationState};
use crate::scene_io::SceneIo; use crate::scene_io::SceneIo;
use crate::state::{EditorMode, PlayPaused}; use crate::state::{EditorMode, PlayPaused};
@ -48,16 +47,6 @@ pub fn status_bar_ui(
.on_hover_text(status); .on_hover_text(status);
}, },
|ui| { |ui| {
if let (Some(state), Some(path)) = (
world.get_resource::<CollaborationState>(),
world.resource::<SceneIo>().active_path.as_deref(),
) {
let status = state.file_status(path);
if status.indicator().is_some() {
let _ = file_status_indicator_ui(ui, &status, path);
ui.separator();
}
}
let (mode_icon, mode_label, mode_color) = match mode { let (mode_icon, mode_label, mode_color) = match mode {
EditorMode::Editing => (icons::PENCIL_SIMPLE, "EDIT", ACCENT), EditorMode::Editing => (icons::PENCIL_SIMPLE, "EDIT", ACCENT),
EditorMode::Playing => { EditorMode::Playing => {

View File

@ -227,22 +227,17 @@ impl Default for InputSettings {
/// Loads settings from disk, falling back to defaults. /// Loads settings from disk, falling back to defaults.
pub fn load_project_settings_from_path(path: &str) -> ProjectSettings { pub fn load_project_settings_from_path(path: &str) -> ProjectSettings {
load_project_settings_with_source(path).0
}
/// Loads settings plus the exact source text used when the file was readable.
pub fn load_project_settings_with_source(path: &str) -> (ProjectSettings, Option<String>) {
match std::fs::read_to_string(path) { match std::fs::read_to_string(path) {
Ok(contents) => match ron::from_str(&contents) { Ok(contents) => match ron::from_str(&contents) {
Ok(settings) => (settings, Some(contents)), Ok(settings) => settings,
Err(error) => { Err(error) => {
warn!("Failed to parse {path}: {error}; using defaults"); warn!("Failed to parse {path}: {error}; using defaults");
(ProjectSettings::default(), Some(contents)) ProjectSettings::default()
} }
}, },
Err(error) => { Err(error) => {
warn!("Could not read {path}: {error}; using defaults"); warn!("Could not read {path}: {error}; using defaults");
(ProjectSettings::default(), None) ProjectSettings::default()
} }
} }
} }
@ -271,27 +266,6 @@ mod tests {
assert_eq!(parsed.rendering.gi_mode, settings.rendering.gi_mode); assert_eq!(parsed.rendering.gi_mode, settings.rendering.gi_mode);
} }
#[test]
fn settings_load_returns_the_exact_source_for_revision_guards() {
let root = std::env::temp_dir().join(format!(
"blacksite-settings-source-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let path = root.join("project.ron");
std::fs::create_dir_all(&root).unwrap();
let source = save_project_settings_to_string(&ProjectSettings::default()).unwrap();
std::fs::write(&path, &source).unwrap();
let (settings, loaded_source) = load_project_settings_with_source(path.to_str().unwrap());
assert_eq!(settings.name, ProjectSettings::default().name);
assert_eq!(loaded_source.as_deref(), Some(source.as_str()));
std::fs::remove_dir_all(root).unwrap();
}
#[test] #[test]
fn legacy_manifest_defaults_project_identity_fields() { fn legacy_manifest_defaults_project_identity_fields() {
let legacy = r#"( let legacy = r#"(

View File

@ -1,7 +1,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::{ use crate::{
load_project_settings_with_source, save_project_settings_to_string, PhysicsSettings, load_project_settings_from_path, save_project_settings_to_string, PhysicsSettings,
ProjectSettings, DEFAULT_PROJECT_PATH, ProjectSettings, DEFAULT_PROJECT_PATH,
}; };
@ -56,8 +56,6 @@ impl SimTuning {
pub struct ProjectSettingsIo { pub struct ProjectSettingsIo {
pub path: String, pub path: String,
pub dirty: bool, pub dirty: bool,
/// Exact source loaded or last saved by this process; collaboration guards hash it.
pub loaded_source: Option<String>,
} }
impl Default for ProjectSettingsIo { impl Default for ProjectSettingsIo {
@ -65,7 +63,6 @@ impl Default for ProjectSettingsIo {
Self { Self {
path: DEFAULT_PROJECT_PATH.into(), path: DEFAULT_PROJECT_PATH.into(),
dirty: false, dirty: false,
loaded_source: None,
} }
} }
} }
@ -94,9 +91,8 @@ impl Plugin for ProjectSettingsPlugin {
} }
} }
fn load_settings(mut commands: Commands, mut io: ResMut<ProjectSettingsIo>) { fn load_settings(mut commands: Commands, io: Res<ProjectSettingsIo>) {
let (settings, source) = load_project_settings_with_source(&io.path); let settings = load_project_settings_from_path(&io.path);
io.loaded_source = source;
let tuning = SimTuning::from_physics(&settings.physics); let tuning = SimTuning::from_physics(&settings.physics);
commands.insert_resource(settings); commands.insert_resource(settings);
commands.insert_resource(tuning); commands.insert_resource(tuning);
@ -107,17 +103,13 @@ pub fn sync_sim_tuning(settings: &ProjectSettings, mut tuning: ResMut<SimTuning>
*tuning = SimTuning::from_physics(&settings.physics); *tuning = SimTuning::from_physics(&settings.physics);
} }
/// Low-level unconditional persistence for non-editor callers. /// Persists settings to the path in [`ProjectSettingsIo`].
///
/// The editor uses its guarded authored-file publication path instead so external revisions cannot
/// be overwritten.
pub fn save_project_settings( pub fn save_project_settings(
settings: &ProjectSettings, settings: &ProjectSettings,
io: &mut ProjectSettingsIo, io: &mut ProjectSettingsIo,
) -> Result<(), String> { ) -> Result<(), String> {
let text = save_project_settings_to_string(settings).map_err(|error| error.to_string())?; let text = save_project_settings_to_string(settings).map_err(|error| error.to_string())?;
std::fs::write(&io.path, &text).map_err(|error| error.to_string())?; std::fs::write(&io.path, text).map_err(|error| error.to_string())?;
io.loaded_source = Some(text);
io.dirty = false; io.dirty = false;
Ok(()) Ok(())
} }

View File

@ -51,7 +51,6 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [0034](adr/0034-registry-driven-authoring-components.md) | Stable component IDs, reflected lifecycle/history, composition, and independent active state | | [0034](adr/0034-registry-driven-authoring-components.md) | Stable component IDs, reflected lifecycle/history, composition, and independent active state |
| [0035](adr/0035-shared-material-assets-and-renderer-slots.md) | Shared Material/Material Instance assets, stable renderer slots, and runtime-only property blocks | | [0035](adr/0035-shared-material-assets-and-renderer-slots.md) | Shared Material/Material Instance assets, stable renderer slots, and runtime-only property blocks |
| [0036](adr/0036-surface-abi-and-solari-parity.md) | Constrained Surface ABI v1, raster/Solari evaluator parity, and deformed-geometry boundary | | [0036](adr/0036-surface-abi-and-solari-parity.md) | Constrained Surface ABI v1, raster/Solari evaluator parity, and deformed-geometry boundary |
| [0037](adr/0037-collaborative-authored-file-safety.md) | Exact authored-file revisions, observational Git status, and optional ownership providers |
## Editor framework ## Editor framework
@ -76,10 +75,8 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [editor/navigation-authoring.md](editor/navigation-authoring.md) | Bounds/obstacles/areas/links, deterministic bake, overlay, path preview, and runtime queries | | [editor/navigation-authoring.md](editor/navigation-authoring.md) | Bounds/obstacles/areas/links, deterministic bake, overlay, path preview, and runtime queries |
| [editor/extensibility.md](editor/extensibility.md) | Static authoring component registration, lifecycle, composition, and history contract | | [editor/extensibility.md](editor/extensibility.md) | Static authoring component registration, lifecycle, composition, and history contract |
| [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics | | [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics |
| [editor/collaborative-file-safety.md](editor/collaborative-file-safety.md) | Guarded authored writes, compact Git/read-only status, conflict recovery, and ownership providers |
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation | | [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity | | [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
## Working plans (not canonical long-term) ## Working plans (not canonical long-term)
@ -98,9 +95,6 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
| `animation_authoring_*.plan.md` | M7 glTF skeletal animation import, preview, controller, and runtime acceptance | | `animation_authoring_*.plan.md` | M7 glTF skeletal animation import, preview, controller, and runtime acceptance |
| `navigation_authoring_*.plan.md` | M7 navigation bounds, deterministic bake, diagnostics, preview, and runtime query API | | `navigation_authoring_*.plan.md` | M7 navigation bounds, deterministic bake, diagnostics, preview, and runtime query API |
| `renderer_material_component_foundation_*.plan.md` | Renderer/material slots, skinned pose lifecycle, Surface ABI, and component foundation | | `renderer_material_component_foundation_*.plan.md` | Renderer/material slots, skinned pose lifecycle, Surface ABI, and component foundation |
| `source_control_collaboration_safety_*.plan.md` | Exact authored-file guards, observational Git status, conflict recovery, and provider contract |
| `production_readiness_acceptance_*.plan.md` | Release-candidate evidence matrix, blocker sequence, clean-checkout checks, soak, budgets, and independent sign-off |
| `material_library_and_targeted_drop_*.plan.md` | Dedicated Material Library, exact viewport slot/primitive/brush targeting, hover preview, cancel, and grouped history |
## Crate responsibilities (quick reference) ## Crate responsibilities (quick reference)

View File

@ -1,53 +0,0 @@
# ADR 0037: Collaborative Authored-File Safety
## Status
Accepted
## Context
Transactional replacement protects an authored file from partial writes, but it does not prevent
an editor session from replacing a newer revision written by another tool or collaborator. Git can
explain common workspace states, while Perforce-style ownership or lock services may be present in
some teams. Neither Git nor a provider may be assumed, and source-control integration must never
stage, reset, discard, commit, or otherwise mutate repository state.
Blacksite needs one save boundary for user-authored scenes and assets. The boundary must detect
changes using file content rather than timestamps alone, preserve atomic replacement, explain
read-only and collaboration conflicts, and remain useful in projects with no repository or lock
provider.
## Decision
- Every loaded authored document records a `FileSnapshot` containing a BLAKE3 content revision and
comparison metadata. Missing files have an explicit revision so create/save-as races are guarded.
- Authored writes compare the current revision with the caller's baseline before serialization is
published and again immediately before the same-directory atomic rename. A mismatch, read-only
target, or provider lock blocks publication and leaves the existing file untouched.
- The conflict workflow offers reload, metadata comparison, save-as, and cancel. There is no
force-overwrite action. Reload semantics are owned by the document type so scene and asset state
cannot be confused.
- Git status is collected asynchronously with read-only commands and parsed from NUL-delimited
porcelain output. The editor reports clean, modified, untracked, and conflicted state for the
active scene and selected asset without making Git a project requirement.
- Optional ownership integrations implement a thread-safe provider trait over the currently
tracked project-relative paths. Provider absence performs no work and produces no warning.
Provider errors are diagnostic status, not a reason to disable filesystem revision protection.
- Generated registries, import artifacts, caches, recovery snapshots, and package output do not
open authored-file conflict UI. Their existing subsystem-specific transactional or regenerative
rules remain authoritative.
- Source-control integration is observational. Blacksite never stages, commits, resets, checks out,
discards, or changes locks through the Git status path.
## Consequences
- External changes to loaded scenes, prefabs, materials, material instances, and project settings
cannot be silently replaced by normal editor save/apply/history paths.
- Timestamp-only changes do not create false content conflicts, while metadata remains available
for human comparison.
- A save conflict requires an explicit recovery choice and may interrupt a bulk save operation.
- Git is invoked periodically in a worker thread; the frame loop does not wait for repository I/O.
- Provider implementations must be deterministic, bounded, and safe to call off the main thread.
- Native filesystem replacement cannot coordinate with unrelated writers that ignore advisory
conventions after Blacksite's final revision check; the narrow check-to-rename interval is the
platform boundary, and deterministic race hooks cover the editor-controlled interval.

View File

@ -23,10 +23,8 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| [navigation-authoring.md](navigation-authoring.md) | Bounds, source geometry, deterministic bake, viewport path preview, and runtime query workflow | | [navigation-authoring.md](navigation-authoring.md) | Bounds, source geometry, deterministic bake, viewport path preview, and runtime query workflow |
| [extensibility.md](extensibility.md) | Static authoring component lifecycle registration, stable IDs, composition, and generic history | | [extensibility.md](extensibility.md) | Static authoring component lifecycle registration, stable IDs, composition, and generic history |
| [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration | | [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration |
| [collaborative-file-safety.md](collaborative-file-safety.md) | Exact authored-file revisions, Git/read-only status, conflict recovery, and optional ownership providers |
| [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation | | [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation |
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity | | [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
## Subsystems (code → doc) ## Subsystems (code → doc)
@ -45,7 +43,6 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| `assets/` | Catalog, asset DB, static mesh artifacts, `thumbnails/`, `materials.rs`, prefab overrides v2 | this file (below), prefab-authoring.md, ADR 0017, ADR 0027 | | `assets/` | Catalog, asset DB, static mesh artifacts, `thumbnails/`, `materials.rs`, prefab overrides v2 | this file (below), prefab-authoring.md, ADR 0017, ADR 0027 |
| `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 | | `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 |
| `project/` | Workspace, settings UI, user prefs, support diagnostics | roadmap Phase 1 | | `project/` | Workspace, settings UI, user prefs, support diagnostics | roadmap Phase 1 |
| `project/collaboration.rs` | Guarded authored writes, asynchronous Git status, and optional ownership providers | collaborative-file-safety.md, ADR 0037 |
| `project/session.rs` | Versioned XDG session document, clean marker, and safe resume | session-recovery.md, ADR 0024 | | `project/session.rs` | Versioned XDG session document, clean marker, and safe resume | session-recovery.md, ADR 0024 |
| `project/diagnostics_bundle.rs` | Privacy-bounded transactional support report export | session-recovery.md, ADR 0024 | | `project/diagnostics_bundle.rs` | Privacy-bounded transactional support report export | session-recovery.md, ADR 0024 |
| `project/launcher.rs` | Strict project inspection, CLI activation, recent filtering, and sandbox scaffolding | project-launcher.md, ADR 0025 | | `project/launcher.rs` | Strict project inspection, CLI activation, recent filtering, and sandbox scaffolding | project-launcher.md, ADR 0025 |
@ -89,7 +86,6 @@ 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 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. 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). - **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).
- **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. - **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. - **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.
- **Viewport shading** (toolbar): **Lit** (default WYSIWYG), **Unlit** (base-color debug), **Colliders** (hide meshes, emphasize collider gizmos). The viewport combines active shading/render path in one bottom-right chip, draws a camera-relative X/Y/Z orientation widget, identifies the primary selection in a responsive top-right HUD, and previews asset placement with a pointer card, placement reticle, corner brackets, and highlighted drop boundary. - **Viewport shading** (toolbar): **Lit** (default WYSIWYG), **Unlit** (base-color debug), **Colliders** (hide meshes, emphasize collider gizmos). The viewport combines active shading/render path in one bottom-right chip, draws a camera-relative X/Y/Z orientation widget, identifies the primary selection in a responsive top-right HUD, and previews asset placement with a pointer card, placement reticle, corner brackets, and highlighted drop boundary.

View File

@ -29,7 +29,6 @@ Native-only dependencies (`rfd`, BRP HTTP) stay in `editor`. `settings`, `shared
| `ActorIconsPlugin` | Selectable actor root billboard icons | | `ActorIconsPlugin` | Selectable actor root billboard icons |
| `EditorVisualizersPlugin` | Collider/light/spawn/prefab/runtime visualizers and selectable proxies | | `EditorVisualizersPlugin` | Collider/light/spawn/prefab/runtime visualizers and selectable proxies |
| `SceneIoPlugin` | Level New/Open/Save, recent paths | | `SceneIoPlugin` | Level New/Open/Save, recent paths |
| `CollaborationPlugin` | Exact authored-file guards, asynchronous Git status, optional ownership providers |
| `OperatorPlugin` | Active operator lifecycle/status for commands and modal tools | | `OperatorPlugin` | Active operator lifecycle/status for commands and modal tools |
| `SettingsUiPlugin` | egui Project Settings panel | | `SettingsUiPlugin` | egui Project Settings panel |
| `EditorUiPlugin` | Dock, themed egui shell, viewport overlays, menus, status bar | | `EditorUiPlugin` | Dock, themed egui shell, viewport overlays, menus, status bar |
@ -77,15 +76,6 @@ The bounded in-session scene I/O event log feeds the Diagnostics window so a lat
does not erase a save or recovery failure. does not erase a save or recovery failure.
See [ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md). See [ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).
`project/collaboration.rs` wraps user-authored publication with BLAKE3 baselines and a second
revision/read-only check immediately before the same-directory rename. Scene tabs, prefab source
Apply/history, staged Material documents, and Project Settings use this shared boundary. Its worker
observes path-scoped Git porcelain and optional ownership providers without blocking the frame loop
or changing repository state; conflict recovery is explicit Reload, metadata comparison, Save As,
or Cancel. Generated artifacts remain under their subsystem-owned regeneration policy. See
[collaborative-file-safety.md](collaborative-file-safety.md) and
[ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
`project/session.rs` owns the independent versioned restart document under the user state `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 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 bookmarks, writes a running marker at startup, and records clean `AppExit`. Abnormal sessions do
@ -161,7 +151,7 @@ The egui layer lives under `crates/editor/src/ui/`:
| `diagnostics.rs` | Detailed stats (Window → Diagnostics, Asset Browser footer) | | `diagnostics.rs` | Detailed stats (Window → Diagnostics, Asset Browser footer) |
| `layout.rs` | Dock layout RON persistence in `editor_prefs.ron` | | `layout.rs` | Dock layout RON persistence in `editor_prefs.ron` |
The Viewport fills the tab (no inline help text). Gizmo/grid/play controls sit on a near-black semi-transparent overlay, and `G` hides editor-only overlays for a clean game view. The fixed dark status strip prioritizes `SceneIo.status` feedback and also shows scene path/dirty state, compact active-file collaboration status, mode, selection count, history, and the active operator. Long feedback is truncated responsively and available in a hover tooltip. Dock layout restores from `~/.config/bevy-fps/editor_prefs.ron` on startup; **View → Reset Layout** restores defaults. The Viewport fills the tab (no inline help text). Gizmo/grid/play controls sit on a near-black semi-transparent overlay, and `G` hides editor-only overlays for a clean game view. The fixed dark status strip prioritizes `SceneIo.status` feedback and also shows scene path/dirty state, mode, selection count, history, and the active operator. Long feedback is truncated responsively and available in a hover tooltip. Dock layout restores from `~/.config/bevy-fps/editor_prefs.ron` on startup; **View → Reset Layout** restores defaults.
**Follow-up (Phase 4b):** inspector search and richer per-asset previews. **Follow-up (Phase 4b):** inspector search and richer per-asset previews.
@ -257,7 +247,7 @@ PIE stop restores player simulation state only; authored `LevelObject` edits mad
| `viewport/` | Camera, selection, gizmos, render views, panel settings | | `viewport/` | Camera, selection, gizmos, render views, panel settings |
| `play/` | PIE session, editor mode, net editor profiles | | `play/` | PIE session, editor mode, net editor profiles |
| `assets/` | Catalog, asset DB, static mesh artifacts, prefab overrides | | `assets/` | Catalog, asset DB, static mesh artifacts, prefab overrides |
| `project/` | Project I/O, settings UI, authored-file safety, and observational collaboration status | | `project/` | Project I/O and settings UI |
| `ext/` | Command palette, BRP, game panel adapters | | `ext/` | Command palette, BRP, game panel adapters |
| `history/` | Undo commands + plugin | | `history/` | Undo commands + plugin |
| `ui/` | egui dock shell | | `ui/` | egui dock shell |

View File

@ -1,58 +0,0 @@
# Collaborative File Safety
Blacksite protects loaded user-authored documents with exact content revisions. This layer covers
scene and prefab documents, editable Material and Material Instance assets, and `assets/project.ron`.
Generated import artifacts, registries, thumbnails, navigation bakes, recovery snapshots, and package output keep
their subsystem-owned regeneration rules and never interrupt editing with authored-file conflicts.
## Status Surfaces
The bottom status strip shows a compact state for the active saved scene. The selected asset's
Details header shows the same state when the asset has an on-disk source. Hover either indicator for
the path, Git state, read-only state, ownership detail, and scanner diagnostics.
When Git is available, Blacksite distinguishes clean, modified, untracked, and conflicted files.
The path-scoped scanner uses only `rev-parse` and NUL-delimited `status --porcelain`; it never
stages, commits, resets, checks out, restores, or discards files. A missing Git executable or
project outside a repository is a normal quiet state.
## Save Conflicts
On load, the editor records a BLAKE3 revision and file metadata. Save verifies that revision before
preparing the atomic replacement and again immediately before rename. A changed, newly created, or
read-only target remains untouched and opens the collaborative-file dialog.
The dialog provides:
- **Reload**: discard the editor draft for that document and load the current disk revision.
- **Compare Metadata**: show expected/current revision, byte size, modified time, and permissions.
- **Save As**: preserve the editor draft at a separately chosen path, guarded against a create race.
- **Cancel**: keep the editor draft and make no filesystem change.
There is intentionally no force-overwrite action. Resolve or preserve the other revision first.
## Ownership Providers
Teams can register an implementation of `FileOwnershipProvider` with
`register_file_ownership_provider`. Providers receive the active project root and a bounded list of
currently tracked project-relative paths on the background scan worker. They return owner/lock
details per path.
A lock held by another user blocks authored publication and uses the same recovery dialog. Provider
errors appear only in indicator details; the exact filesystem revision guard continues to work. An
empty provider registry performs no calls and adds no UI.
## Recovery Notes
- Scene Reload explicitly replaces the active dirty document with disk content and clears its local
history. Use Save As first when both versions matter.
- Material Reload discards only the staged Details draft and reloads the asset on the next draw.
- Project Settings Reload strictly parses the current manifest, reapplies it live, and refreshes the
panel draft; invalid external RON stays blocked and visible.
- Prefab Apply conflicts keep instance overrides intact. Reload tears down stale hydration and
queues the current source for rehydration; retry Apply after reviewing Changed Base/conflict
state, or Save As to preserve the proposed source copy.
- Prefab source undo/redo stays blocked when the exact expected source revision is absent.
The architectural policy is recorded in
[ADR 0037](../adr/0037-collaborative-authored-file-safety.md).

View File

@ -1,7 +1,5 @@
# H1 Gate Sign-Off — Game Development Ready # H1 Gate Sign-Off — Game Development Ready
> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/).
**Date:** 2026-05-30 **Date:** 2026-05-30
**Evaluator:** implementation pass **Evaluator:** implementation pass
**Result:** Pass (implementation complete; manual dogfood recommended) **Result:** Pass (implementation complete; manual dogfood recommended)

View File

@ -1,7 +1,5 @@
# H2 Gate Sign-Off — Content Pipeline Maturity # H2 Gate Sign-Off — Content Pipeline Maturity
> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/).
**Date:** 2026-05-30 **Date:** 2026-05-30
**Result:** Pass with waivers **Result:** Pass with waivers

View File

@ -1,7 +1,5 @@
# H3 Gate Sign-Off — Gameplay Authoring # H3 Gate Sign-Off — Gameplay Authoring
> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/).
**Date:** 2026-05-30 **Date:** 2026-05-30
**Result:** Pass **Result:** Pass

View File

@ -1,7 +1,5 @@
# H4 Gate Sign-Off — Multiplayer Tooling # H4 Gate Sign-Off — Multiplayer Tooling
> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/).
**Date:** 2026-05-30 **Date:** 2026-05-30
**Result:** Pass with waivers **Result:** Pass with waivers

View File

@ -1,7 +1,5 @@
# H5 Gate Sign-Off — Extensibility # H5 Gate Sign-Off — Extensibility
> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/).
**Date:** 2026-05-30 **Date:** 2026-05-30
**Result:** Pass **Result:** Pass

View File

@ -1,7 +1,5 @@
# H6 Gate Sign-Off — Production / Framework 1.0 # H6 Gate Sign-Off — Production / Framework 1.0
> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/).
**Date:** 2026-05-30 **Date:** 2026-05-30
**Result:** Pass with waivers **Result:** Pass with waivers

View File

@ -1,104 +0,0 @@
# Production-Readiness Acceptance Matrix
**Matrix version:** 0.1
**Last audit:** 2026-07-12
**Release-candidate commit:** Not nominated
**Overall result:** **Not ready**
This is the current authority for Gitea
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50).
The older H1-H6 files are historical implementation snapshots and do not satisfy this gate.
## Evidence Rules
| State | Meaning |
|-------|---------|
| Pass | Candidate-specific evidence proves the full row. |
| Partial | Some implementation/evidence exists, but the full row is not proven. |
| Deferred | The project owner has explicitly postponed this check; it is not a pass. |
| Missing | Required implementation or candidate evidence does not exist. |
| Fail | Current authoritative evidence contradicts the gate. |
| External | Requires a person other than the implementer. |
Acceptance evidence must name the exact candidate commit and commands or protocol used. Results from
another commit, a dirty worktree, or an older package do not transfer to the candidate.
## Exit Gate
| ID | Requirement | State | Current evidence and gap |
|----|-------------|-------|--------------------------|
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Partial | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Candidate-specific end-to-end reruns and the open asset-integrity work remain. |
| G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Fail | Animation, audio, navigation, and build foundations are implemented. Material/drag-drop tickets `#16`, `#18`, and `#51` remain open; terrain `#22`-`#24` and physics placement `#25` are not complete; brush acceptance `#37` remains open. Packaged testing is owner-deferred. |
| G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | The reusable harness and current tool coverage are documented in [operator regression testing](../../operator-regression-testing.md). Gitea `#33` remains open for terrain-stroke and physics-settle fixtures after those tools exist. |
| G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. |
| G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. |
| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) exists and local source/headless checks have passed during feature work, but no clean-checkout candidate run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. |
| G7 | First-hour UX and recovery QA are signed off by someone other than the implementer | External | No independent sign-off exists. Gitea `#36` remains open; historical H1-H6 implementation-pass notes do not count. |
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Fail | Limitations are distributed across feature docs rather than one candidate ledger. Open P0 work includes `#16`, `#18`, `#32`, `#33`, `#35`, and `#51`; `#50` cannot close while a gate-relevant P0 remains. |
## Deliverables
| ID | Deliverable | State | Evidence or next action |
|----|-------------|-------|-------------------------|
| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.1. |
| D2 | Release-candidate representative project and reproducible validation commands | Missing | Expand the committed project/regression pack through `#32`, including terrain and physics-placement samples, then nominate an exact commit. |
| D3 | Signed milestone comment linking evidence, limitations, and exact commit | Missing | Post only after G1-G8 pass; no candidate exists yet. |
## Workflow Coverage
| Workflow | Implementation status | Candidate acceptance |
|----------|-----------------------|----------------------|
| Project create/open/resume | Implemented | Not rerun |
| Scene save, autosave, recovery | Implemented | Not rerun |
| Hierarchy parenting and prefab structure | Implemented | Not rerun |
| Multi-scene composition | Implemented | Not rerun |
| Brush blockout/edit/CSG | Implemented foundation; `#37` open | Not signed off |
| Material catalog and assignment | Partial; `#16`, `#18`, `#51` open | Blocked |
| Terrain authoring | `#22`-`#24` open | Blocked |
| Physics placement | `#25` open | Blocked |
| Animation | Implemented | Not rerun |
| Audio | Implemented | Not rerun |
| Navigation | Implemented | Not rerun |
| PIE | Implemented | Not rerun |
| Build/package/runtime | Implemented foundation | Deferred by owner |
| Collaborative file safety | Local source/headless acceptance; `#49` open | Awaiting publication and live visual QA |
## Candidate Validation Commands
Run these from a fresh clone of the nominated commit with Git LFS content present. Record wall time,
machine profile, exit code, and artifact hashes. Packaged commands stay disabled until the project
owner re-enables them.
```bash
cargo fmt --all -- --check
cargo check --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings
cargo test --workspace
cargo validate-levels --project .
cargo bake-navigation --project . --check
```
When packaged acceptance is re-enabled, append the exact `package-project` profile/target commands,
package hash verification, and packaged-runtime smoke protocol used for the candidate.
## Soak Protocol
The final candidate must run for eight continuous hours against the representative project. The
record must sample process RSS, GPU memory, target-directory size, recovery generations, background
job counts, and error/warning totals at least every five minutes. Exercise scene switching, save and
recovery, asset refresh, material editing, brush/terrain/physics tools, animation/audio/navigation,
PIE transitions, and build UI cancellation without replacing the candidate during the run.
A pass requires no unrecoverable editor failure, no lost authored work, no unbounded upward trend in
steady-state resource use, bounded recovery/cache behavior, and a triaged explanation for every
error. Numeric thresholds remain blocked on the performance-budget work in `#34`.
## Independent Sign-Off
The evaluator must not be the implementation agent. They record the candidate commit, machine,
elapsed first-hour workflow, recovery scenario, defects, severity, and go/no-go decision. Waivers
must include a workaround and owner ticket; an open P0 cannot be waived.

View File

@ -147,7 +147,7 @@ with implementation sequencing in
| Milestone | Exit condition | Status | | Milestone | Exit condition | Status |
|-----------|----------------|--------| |-----------|----------------|--------|
| M6 Reliability, recovery, and project workflow | Transactional save/recovery, stable sessions, project launcher, hardened hierarchy/prefabs, multi-scene composition | Implementation complete; all six scoped issues are closed after prefab #43 passed workspace, headless, packaged-runtime, and live editor acceptance | | M6 Reliability, recovery, and project workflow | Transactional save/recovery, stable sessions, project launcher, hardened hierarchy/prefabs, multi-scene composition | Implementation complete; all six scoped issues are closed after prefab #43 passed workspace, headless, packaged-runtime, and live editor acceptance |
| M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#48 are complete. #49 implementation and source/headless acceptance are complete, with live visual acceptance pending; final readiness gate #50 remains. Packaged acceptance is deferred until requested by the project owner. | | M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#48 are complete. Source-control safety #49 and final readiness gate #50 remain; packaged acceptance is deferred until requested by the project owner. |
Production readiness is not inferred from feature count. Gitea Production readiness is not inferred from feature count. Gitea
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50) [`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)