From 4ba1c1128ce191f36ebf0855465b1a28c143ce76 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sat, 25 Jul 2026 19:38:07 +0200 Subject: [PATCH 01/11] editor: hypertube entrances selectable again; wires selectable as riders Copies of daisy-chained builds (1.2 machine-to-machine wiring, e.g. hypertube entrances) silently lost their power wires: the entrance class sat in LINE_RENDERED_TYPE_PATHS, but an entrance with its default tube shape serializes NO mSplineData, so the line collector emitted nothing for it -- leaving it in no bucket at all, invisible and unselectable, so the wire rider expansion never saw its owners. Entrances are dot-rendered powered buildings again (their tube still draws when a custom spline is present). Power lines also join box selection (line:powerLines) for visibility and single-wire deletion. The engine treats explicitly selected wires as riders: move skips them (a wire moves only when both endpoint owners move), and the copy paths prune named wires without both owners in the set (their copies would dangle) -- delete keeps them, which is exactly the single-wire delete. Regression test covers all three semantics. Verified on the report's reveal save: 36 entrances selectable, select-all copy/paste goes 33 -> 66 wires, single-wire delete 66 -> 65; full suite green. Co-Authored-By: Claude Fable 5 --- docs/release-notes-0.1.7.md | 10 +++ map/static/map/selection.js | 14 ++-- rust_parser/core/src/editor/apply.rs | 59 +++++++++++++--- rust_parser/core/src/editor/clipboard.rs | 2 +- rust_parser/core/src/mapdata/consts.rs | 13 ++++ rust_parser/core/tests/editor_duplicate.rs | 78 ++++++++++++++++++++++ 6 files changed, 162 insertions(+), 14 deletions(-) diff --git a/docs/release-notes-0.1.7.md b/docs/release-notes-0.1.7.md index 4094679..323cffb 100644 --- a/docs/release-notes-0.1.7.md +++ b/docs/release-notes-0.1.7.md @@ -19,6 +19,16 @@ game's own files. ## UI fixes +- **Hypertube entrances are back on the map** — and with them, copies of + daisy-chained builds keep their power wires. Entrances were treated as + line-only geometry, but an entrance with its default tube shape carries no + spline in the save, leaving it invisible and unselectable — so wires + between chained entrances could never travel with a copy. They now render + as regular powered buildings again. +- **Power lines are selectable** — they highlight in box selections and can + be deleted individually. For move and copy they act as riders: a wire + travels exactly when both the things it connects travel, so a selected + wire can never be dragged off a pole or pasted dangling. - **Pipe bottleneck warnings are now scoped like belt ones.** A Mk2 pipe feeding two Mk1 pipes through a junction no longer warns — the detector follows the pipe line and stops at junctions that actually split or merge diff --git a/map/static/map/selection.js b/map/static/map/selection.js index d08abd5..65f82da 100644 --- a/map/static/map/selection.js +++ b/map/static/map/selection.js @@ -31,16 +31,20 @@ var SelectionTool = {}; var MIN_DRAG_PX = 4; // Below this the gesture is a stray right-click, not a drag. // Line layers whose segments are real editable actors: belts/pipes - // (per-mark buckets "line:belt:Mk.6", ...), railroads, hypertubes, and - // vehicle paths ("line:vehiclePath:"). Their spline data is - // actor-local, so the edit engine's header-transform move/copy carries - // the geometry. Power lines stay out: wires already travel with their - // poles on copy/move, and selecting them directly would double-count. + // (per-mark buckets "line:belt:Mk.6", ...), railroads, hypertubes, + // vehicle paths ("line:vehiclePath:"), and power lines. Their + // spline data is actor-local, so the edit engine's header-transform + // move/copy carries the geometry. Power lines are selectable for + // visibility and single-wire deletion, but the engine treats them as + // RIDERS of their endpoint owners: they move/copy only when both owners + // do, and a selected wire without both owners is dropped from copies + // (editor/apply.rs expand_duplicate_set / the move wire pass). function isSelectableLineBucket(bucket) { return bucket.key.indexOf("line:belt:") === 0 || bucket.key.indexOf("line:pipe:") === 0 || bucket.key === "line:railroads" || bucket.key === "line:hypertubes" + || bucket.key === "line:powerLines" || bucket.key.indexOf("line:vehiclePath:") === 0; } diff --git a/rust_parser/core/src/editor/apply.rs b/rust_parser/core/src/editor/apply.rs index 06fd203..bee83a2 100644 --- a/rust_parser/core/src/editor/apply.rs +++ b/rust_parser/core/src/editor/apply.rs @@ -494,6 +494,17 @@ fn plan_move_actors( continue; } let header = &store.levels[li].headers[oi]; + // Wires are riders even when explicitly selected (they're + // box-selectable): a wire moves only via the both-owners-moved pass + // below -- moving one on its own would tear it off a pole it is + // still attached to. + if let Header::Actor(a) = header { + let tp = a.type_path.bytes(data); + if crate::object::POWER_LINES.iter().any(|c| c.as_bytes() == tp) { + moved_slots.remove(&(li, oi)); + continue; + } + } let object = fetch(store, li, oi)?; if let Some(reason) = move_refusal(store, header, &object) { return Err(perr!("Cannot move {}: {}", name, reason)); @@ -604,14 +615,18 @@ fn plan_move_lightweight( // Duplication // --------------------------------------------------------------------------- -/// Expand the requested actor names into the full copy set: each actor plus -/// its components, plus every power line whose BOTH endpoints are owned by -/// actors in the set (wires aren't map-selectable, so connected copies -/// would otherwise always lose their wiring). +/// Expand the requested actor names into the full copy/delete set: each +/// actor plus its components, plus every power line whose BOTH endpoints +/// are owned by actors in the set. With `prune_unanchored_wires` (the copy +/// paths), wires are pure riders: explicitly selected ones without both +/// owners in the set are dropped, since their copies would dangle. Delete +/// passes false -- removing a lone wire is legitimate (and how the map's +/// single-wire delete works). pub(crate) fn expand_duplicate_set( store: &SaveStore, scan: &SaveScan, names: &[String], + prune_unanchored_wires: bool, ) -> PResult> { let data: &[u8] = &store.data; let mut set: BTreeSet<(usize, usize)> = BTreeSet::new(); @@ -663,7 +678,7 @@ pub(crate) fn expand_duplicate_set( // Wires: owner actor of an endpoint component "….Build_X_C_123.Conn" is // everything before the last '.'. - let owner_in_set = |endpoint: &ObjectRef| -> bool { + let owner_in_set = |actor_names: &BTreeSet>, endpoint: &ObjectRef| -> bool { if endpoint.path_name.is_empty() { return false; } @@ -673,6 +688,34 @@ pub(crate) fn expand_duplicate_set( }; actor_names.contains(&path[..dot]) }; + // Wires are riders even when explicitly selected (they're + // box-selectable): drop any named wire whose endpoint owners aren't both + // in the set -- a copy of it would dangle (tombstoned endpoints), and + // the rider scan below re-adds every wire that legitimately travels. + let mut pruned: Vec<(usize, usize)> = Vec::new(); + for &(li, oi) in set.iter().filter(|_| prune_unanchored_wires) { + let Header::Actor(a) = &store.levels[li].headers[oi] else { continue }; + let tp = a.type_path.bytes(data); + if !crate::object::POWER_LINES.iter().any(|c| c.as_bytes() == tp) { + continue; + } + let object = fetch(store, li, oi)?; + if let ActorSpecific::PowerLine(a, b) = &object.actor_specific { + if !(owner_in_set(&actor_names, a) && owner_in_set(&actor_names, b)) { + pruned.push((li, oi)); + } + } + } + for (li, oi) in pruned { + set.remove(&(li, oi)); + if let Some((_, components)) = &fetch(store, li, oi)?.actor_reference_associations { + for comp in components { + if let Some(&slot) = scan.by_instance_name.get(comp.path_name.bytes(data)) { + set.remove(&slot); + } + } + } + } let mut wires: Vec<(usize, usize)> = Vec::new(); for (li, oi) in actor_slots_of_types(store, &crate::object::POWER_LINES) { if set.contains(&(li, oi)) { @@ -680,7 +723,7 @@ pub(crate) fn expand_duplicate_set( } let object = fetch(store, li, oi)?; if let ActorSpecific::PowerLine(a, b) = &object.actor_specific { - if owner_in_set(a) && owner_in_set(b) { + if owner_in_set(&actor_names, a) && owner_in_set(&actor_names, b) { wires.push((li, oi)); } } @@ -705,7 +748,7 @@ fn plan_duplicate_actors( return Err(perr!("rotate requires a pivot")); } let data: &[u8] = &store.data; - let set = expand_duplicate_set(store, scan, names)?; + let set = expand_duplicate_set(store, scan, names, true)?; if set.is_empty() { return Err(perr!("Nothing to copy")); } @@ -949,7 +992,7 @@ fn plan_delete_actors( names: &[String], ) -> PResult<()> { let data: &[u8] = &store.data; - let set = expand_duplicate_set(store, scan, names)?; + let set = expand_duplicate_set(store, scan, names, false)?; if set.is_empty() { return Err(perr!("Nothing to delete")); } diff --git a/rust_parser/core/src/editor/clipboard.rs b/rust_parser/core/src/editor/clipboard.rs index 6e1de20..39846a0 100644 --- a/rust_parser/core/src/editor/clipboard.rs +++ b/rust_parser/core/src/editor/clipboard.rs @@ -144,7 +144,7 @@ pub fn extract_clipboard_with_meta( let mut object_version: Option = None; if !names.is_empty() { - let set = expand_duplicate_set(store, &scan, names)?; + let set = expand_duplicate_set(store, &scan, names, true)?; let level_idx = set.iter().next().map(|&(li, _)| li).unwrap_or(0); if set.iter().any(|&(li, _)| li != level_idx) { return Err(perr!("Cannot copy objects from different world levels together")); diff --git a/rust_parser/core/src/mapdata/consts.rs b/rust_parser/core/src/mapdata/consts.rs index 02b4b79..847742f 100644 --- a/rust_parser/core/src/mapdata/consts.rs +++ b/rust_parser/core/src/mapdata/consts.rs @@ -24,6 +24,16 @@ pub const HYPERTUBE_SEGMENTS: [&str; 2] = [ "/Game/FactoryGame/Buildable/Factory/PipeHyperStart/Build_PipeHyperStart.Build_PipeHyperStart_C", ]; +/// The hypertube entrance is BOTH a spline segment (its tube part, drawn via +/// HYPERTUBE_SEGMENTS when the save carries a non-default mSplineData) AND a +/// powered machine. It must stay a dot-rendered building: an entrance whose +/// spline equals the cooked default serializes NO mSplineData, so the line +/// collector emits nothing for it -- treating it as line-only made such +/// entrances invisible and unselectable, which silently dropped their power +/// wires from copies of daisy-chained (pole-less) builds. +pub const HYPERTUBE_ENTRANCE_TYPE_PATH: &str = + "/Game/FactoryGame/Buildable/Factory/PipeHyperStart/Build_PipeHyperStart.Build_PipeHyperStart_C"; + pub const VEHICLE_PATH_SEGMENTS: [&str; 5] = [ "/Game/FactoryGame/Buildable/Vehicle/Explorer/Build_VehiclePath_Explorer.Build_VehiclePath_Explorer_C", "/Game/FactoryGame/Buildable/Vehicle/Golfcart/Build_VehiclePath_FactoryCart.Build_VehiclePath_FactoryCart_C", @@ -120,6 +130,9 @@ pub fn line_rendered_type_paths() -> &'static HashSet<&'static str> { set.extend(RAILROAD_SEGMENTS); set.extend(HYPERTUBE_SEGMENTS); set.extend(VEHICLE_PATH_SEGMENTS); + // ... except the hypertube entrance, which is a powered machine and + // must keep its dot bucket (see HYPERTUBE_ENTRANCE_TYPE_PATH). + set.remove(HYPERTUBE_ENTRANCE_TYPE_PATH); // Power lines are line-rendered too (collect_power_lines), but were // never in this set -- an oversight inherited from the Python // original, where they're collected by a separate function. Without diff --git a/rust_parser/core/tests/editor_duplicate.rs b/rust_parser/core/tests/editor_duplicate.rs index be542ac..883485c 100644 --- a/rust_parser/core/tests/editor_duplicate.rs +++ b/rust_parser/core/tests/editor_duplicate.rs @@ -281,3 +281,81 @@ fn duplicate_replay_is_deterministic() { let b = session::rebuild(pristine.clone(), &store.file_header, &store.info, &tables, &ops, None).unwrap(); assert_eq!(a.data, b.data); } + +/// Wires are riders even when explicitly selected (they are box-selectable +/// since the selection got full power-line support): a wire named without +/// both endpoint owners must not produce a dangling copy, and a wire named +/// WITH its owners must not double the wire. +#[test] +fn explicit_wire_is_a_rider_not_a_copy_target() { + let store = load("All_080726-163150.sav"); + let tables = ClassTables::embedded(); + let data: &[u8] = &store.data; + let scan = SaveScan::new(&store); + + let mut target: Option<(String, String, String)> = None; + 'outer: for level in &store.levels { + for (oi, object) in level.parsed_objects().iter().enumerate() { + if let ActorSpecific::PowerLine(a, b) = &object.actor_specific { + let (pa, pb) = (a.path_name.to_string(data), b.path_name.to_string(data)); + let (Some(da), Some(db)) = (pa.rfind('.'), pb.rfind('.')) else { continue }; + let (owner_a, owner_b) = (pa[..da].to_string(), pb[..db].to_string()); + if owner_a != owner_b + && scan.by_instance_name.contains_key(owner_a.as_bytes()) + && scan.by_instance_name.contains_key(owner_b.as_bytes()) + { + target = Some((owner_a, owner_b, level.headers[oi].instance_name().to_string(data))); + break 'outer; + } + } + } + } + let Some((owner_a, owner_b, wire)) = target else { + eprintln!("save has no two-owner wire; skipping"); + return; + }; + + let count_wires = |s: &SaveStore| -> usize { + s.levels + .iter() + .flat_map(|l| l.parsed_objects()) + .filter(|o| matches!(o.actor_specific, ActorSpecific::PowerLine(..))) + .count() + }; + let wires_before = count_wires(&store); + + // Wire alone: pruned to nothing. + let op = EditOp::DuplicateActors { + names: vec![wire.clone()], + delta: [3000.0, 0.0, 0.0], + rotate_yaw_deg: 0.0, + pivot: None, + seed: 7, + }; + assert!(session::step(&store, &op, &tables).is_err(), "wire-only copy should be empty"); + + // Wire + both owners: same result as owners alone -- exactly one new wire. + let op = EditOp::DuplicateActors { + names: vec![owner_a.clone(), owner_b.clone(), wire.clone()], + delta: [3000.0, 0.0, 0.0], + rotate_yaw_deg: 0.0, + pivot: None, + seed: 7, + }; + let store2 = session::step(&store, &op, &tables).unwrap(); + assert_eq!(count_wires(&store2), wires_before + 1); + + // Move guard: moving a wire alone changes nothing. + let wire_slot = *scan.by_instance_name.get(wire.as_bytes()).unwrap(); + let locations_before = wire_locations(&store, wire_slot.0, wire_slot.1); + let op = EditOp::MoveActors { + names: vec![wire.clone()], + delta: [800.0, 0.0, 0.0], + rotate_yaw_deg: 0.0, + pivot: None, + }; + let store3 = session::step(&store, &op, &tables).unwrap(); + let scan3 = SaveScan::new(&store3); + let slot3 = *scan3.by_instance_name.get(wire.as_bytes()).unwrap(); + assert_eq!(wire_locations(&store3, slot3.0, slot3.1), locations_before); +} From 088b45f99cb0f32a7d53743a510bf536653a3d14 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sat, 25 Jul 2026 20:13:45 +0200 Subject: [PATCH 02/11] editor: allocate bodies with edit headroom so copies grow in place Copy/paste on GB-scale saves trapped the 4GB wasm heap with 'unreachable': apply_plan's insert path called reserve_exact on a body at exact capacity, and that reallocation keeps ~2x the body alive while the old block is copied out -- 2.6GB transient on a 1.3GB-body save, on a heap already holding the payload/index. Selection size was irrelevant (the realloc copies the whole body), which is why even 5k-object copies died. Every decompressed body (fresh loads and pristine-replay decompression) now carries 64MB spare capacity, so insert growth extends in place; the slack also absorbs the 4-byte calculator quirk pad. Pastes larger than the slack still fall back to the realloc. Regression test asserts the body pointer is stable across an inserting edit. Verified in the browser on the reported save (1.3GB body): a 2,000-object copy/paste that previously trapped now applies (bucket 14,323 -> 16,323). Co-Authored-By: Claude Fable 5 --- docs/release-notes-0.1.7.md | 6 ++++ rust_parser/core/src/decompress.rs | 16 +++++++-- rust_parser/core/src/editor/apply.rs | 4 +++ rust_parser/core/src/editor/session.rs | 5 ++- rust_parser/core/tests/editor_duplicate.rs | 41 ++++++++++++++++++++++ 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/docs/release-notes-0.1.7.md b/docs/release-notes-0.1.7.md index 323cffb..196a6eb 100644 --- a/docs/release-notes-0.1.7.md +++ b/docs/release-notes-0.1.7.md @@ -19,6 +19,12 @@ game's own files. ## UI fixes +- **Copying on very large saves no longer crashes the editor.** On saves + above roughly a gigabyte of decompressed data, any copy/paste could die + with "Edit failed: unreachable" in the browser: growing the save to fit + the copied objects briefly needed twice the save's memory, blowing the + browser's 4 GB WebAssembly limit. Save data now carries built-in growth + headroom so copies apply in place. - **Hypertube entrances are back on the map** — and with them, copies of daisy-chained builds keep their power wires. Entrances were treated as line-only geometry, but an entrance with its default tube shape carries no diff --git a/rust_parser/core/src/decompress.rs b/rust_parser/core/src/decompress.rs index 2f5c8c3..ce68031 100644 --- a/rust_parser/core/src/decompress.rs +++ b/rust_parser/core/src/decompress.rs @@ -18,6 +18,17 @@ struct Chunk { uncomp_len: usize, } +/// Extra capacity allocated with every decompressed body so editor inserts +/// (copies grow the body) extend IN PLACE instead of reallocating. A +/// reallocation of a body at exact capacity means a second full-body-sized +/// contiguous block lives while the first is copied out -- ~2x the body +/// transiently, which is exactly what trapped the 4GB-capped wasm build when +/// copying on a save with a 1.3GB body. 64MB covers ~50k copied objects; +/// pastes larger than the slack fall back to the reallocation (and may still +/// OOM on huge saves in the browser -- the desktop app is uncapped). Also +/// covers the 4-byte "Missing final array count" quirk pad. +pub const BODY_EDIT_SLACK: usize = 64 << 20; + /// Mirrors decompressSaveFile(offset, data) including its confirm checks. /// `progress(bytes_of_file_consumed, total_file_bytes)` fires as chunks finish. pub fn decompress_save_file( @@ -82,14 +93,15 @@ pub fn decompress_save_file( // the actionable error instead -- the desktop app has no such limit. The // +8 leaves room for the editor's quirk-padding append. #[cfg(target_pointer_width = "32")] - if total_uncomp as u64 + 8 > isize::MAX as u64 { + if total_uncomp as u64 + BODY_EDIT_SLACK as u64 > isize::MAX as u64 { return Err(perr!( "This save decompresses to {:.2} GB. The browser build can load saves up to about 2 GB; use the desktop app for larger ones.", total_uncomp as f64 / 1e9 )); } - let mut out: Vec = vec![0u8; total_uncomp]; + let mut out: Vec = Vec::with_capacity(total_uncomp + BODY_EDIT_SLACK); + out.resize(total_uncomp, 0); // Carve the output into per-chunk slices for parallel inflation. let mut slices: Vec<(&Chunk, &mut [u8])> = Vec::with_capacity(chunks.len()); diff --git a/rust_parser/core/src/editor/apply.rs b/rust_parser/core/src/editor/apply.rs index bee83a2..21f1aa5 100644 --- a/rust_parser/core/src/editor/apply.rs +++ b/rust_parser/core/src/editor/apply.rs @@ -113,6 +113,10 @@ pub fn apply_plan(body: &mut Vec, mut plan: EditPlan) -> PResult<()> { plan.inserts.sort_by_key(|(at, _)| *at); let added: usize = plan.inserts.iter().map(|(_, b)| b.len()).sum(); let old_len = body.len(); + // Usually a no-op: bodies are allocated with BODY_EDIT_SLACK spare + // capacity so growth stays in place. If `added` exceeds the slack + // this reallocates -- transiently 2x the body, which a 4GB-capped + // wasm heap may not survive on GB-scale saves. body.reserve_exact(added); body.resize(old_len + added, 0); // Shift the pre-existing segments right-to-left so nothing is diff --git a/rust_parser/core/src/editor/session.rs b/rust_parser/core/src/editor/session.rs index de752ef..db3fe07 100644 --- a/rust_parser/core/src/editor/session.rs +++ b/rust_parser/core/src/editor/session.rs @@ -124,7 +124,10 @@ pub fn compress_body(body: &[u8]) -> (Vec, usize) { pub fn decompress_body(compressed: &[u8], raw_len: usize) -> PResult> { let mut dec = ZlibDecoder::new(compressed); - let mut out = Vec::with_capacity(raw_len); + // Same edit headroom as a fresh decompress: replayed bodies get edited + // again, and in-place insert growth must not realloc (see + // decompress::BODY_EDIT_SLACK). + let mut out = Vec::with_capacity(raw_len + crate::decompress::BODY_EDIT_SLACK); dec.read_to_end(&mut out).map_err(|e| perr!("Pristine body decompression failed: {}", e))?; if out.len() != raw_len { return Err(perr!("Pristine body length mismatch: {} != {}", out.len(), raw_len)); diff --git a/rust_parser/core/tests/editor_duplicate.rs b/rust_parser/core/tests/editor_duplicate.rs index 883485c..47de6a7 100644 --- a/rust_parser/core/tests/editor_duplicate.rs +++ b/rust_parser/core/tests/editor_duplicate.rs @@ -359,3 +359,44 @@ fn explicit_wire_is_a_rider_not_a_copy_target() { let slot3 = *scan3.by_instance_name.get(wire.as_bytes()).unwrap(); assert_eq!(wire_locations(&store3, slot3.0, slot3.1), locations_before); } + +/// Copies grow the body in place: bodies carry BODY_EDIT_SLACK spare +/// capacity so an insert never reallocates (a realloc means ~2x the body +/// alive at once -- what used to trap the 4GB wasm heap on GB-scale saves). +#[test] +fn insert_growth_stays_in_place() { + let store = load("All_080726-163150.sav"); + let tables = ClassTables::embedded(); + assert!( + store.data.capacity() >= store.data.len() + (60 << 20), + "body allocated without edit slack: len={} cap={}", + store.data.len(), + store.data.capacity() + ); + let ptr_before = store.data.as_ptr() as usize; + let (_, _, name) = { + let mut found = None; + for level in &store.levels { + for header in &level.headers { + if let Header::Actor(a) = header { + if a.type_path.to_string(&store.data).contains("ConstructorMk1") { + found = Some((0usize, 0usize, a.instance_name.to_string(&store.data))); + } + } + } + } + found.expect("constructor present") + }; + let op = EditOp::DuplicateActors { + names: vec![name], + delta: [2000.0, 0.0, 0.0], + rotate_yaw_deg: 0.0, + pivot: None, + seed: 3, + }; + let store2 = session::fold_ops(store, &[op], &tables, None).unwrap(); + assert_eq!( + store2.data.as_ptr() as usize, ptr_before, + "insert reallocated the body instead of growing in place" + ); +} From 54ea93741a1bccf863e70d29d60d5877ca899f8d Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sat, 25 Jul 2026 22:32:42 +0200 Subject: [PATCH 03/11] editor: stream huge pastes through a compressed snapshot; cap browser cross-tab copies The 64MB body slack absorbs typical copies, but a 100k+-object paste overflows it and the fallback realloc (2x body transiently) still trapped the 4GB wasm heap. When growth exceeds capacity, the wasm build now applies the plan by compressing the patched body (~15:1 zlib-fast), freeing it, and streaming the new body out of the snapshot with removes skipped and inserts spliced in offset order -- peak stays ~one body for any paste size. Native keeps the plain realloc (no ceiling, no detour). Unit tests assert the streamed path is byte-identical to the in-place path and that malformed plans are refused with the body untouched; insert-inside-remove validation moved into the shared pre-flight. Separately, browser copies above 150k objects skip the OS-clipboard cross-tab mirror: extracting the byte payload and pushing a multi-hundred-MB string through the synchronous OS clipboard can stall the entire machine, and the 200MB blob ceiling would refuse it anyway. Same-tab paste works off the in-tab name list; the status line points at the desktop app (native clipboard slots, uncapped) for giant cross-save pastes. Co-Authored-By: Claude Fable 5 --- docs/release-notes-0.1.7.md | 9 +- map/static/map/editor.js | 14 +++ rust_parser/core/src/editor/apply.rs | 181 ++++++++++++++++++++++++--- 3 files changed, 187 insertions(+), 17 deletions(-) diff --git a/docs/release-notes-0.1.7.md b/docs/release-notes-0.1.7.md index 196a6eb..b7f2895 100644 --- a/docs/release-notes-0.1.7.md +++ b/docs/release-notes-0.1.7.md @@ -23,8 +23,13 @@ game's own files. above roughly a gigabyte of decompressed data, any copy/paste could die with "Edit failed: unreachable" in the browser: growing the save to fit the copied objects briefly needed twice the save's memory, blowing the - browser's 4 GB WebAssembly limit. Save data now carries built-in growth - headroom so copies apply in place. + browser's 4 GB WebAssembly limit. Small copies now grow the save in + place (built-in headroom), and very large pastes rebuild through a + compressed snapshot instead of doubling memory — 100k-object copies work + in the browser on gigabyte-scale saves. Browser copies above 150k + objects skip the cross-tab clipboard mirror (which could stall the whole + machine) — same-tab paste still works, and the desktop app handles + cross-save pastes of any size. - **Hypertube entrances are back on the map** — and with them, copies of daisy-chained builds keep their power wires. Entrances were treated as line-only geometry, but an entrance with its default tube shape carries no diff --git a/map/static/map/editor.js b/map/static/map/editor.js index ca00651..abb36ae 100644 --- a/map/static/map/editor.js +++ b/map/static/map/editor.js @@ -466,6 +466,20 @@ var EditorTool = (function() { var n = targets.actorNames.length + targets.lightweight.length; SaveLoadFlow.setStatus("Copied " + n.toLocaleString() + " object" + (n === 1 ? "" : "s") + " — Ctrl+V or right-click to paste."); + // In the browser, extremely large selections skip the cross-tab mirror + // below outright: extracting the byte payload and pushing a + // multi-hundred-MB string through the synchronous OS clipboard (which + // clipboard-history listeners then also chew on) can stall the whole + // machine, and the 200MB blob ceiling would refuse the result anyway. + // Same-tab paste only needs the name list set above. The desktop app is + // exempt: its native clipboard slots take big blobs without the OS + // clipboard string round-trip. + if (!window.__TAURI__ && n > 150000) { + SaveLoadFlow.setStatus("Copied " + n.toLocaleString() + + " objects — paste with Ctrl+V in this tab. Cross-tab copy is capped in the browser; " + + "use the desktop app for pastes this large."); + return; + } // Also put a portable blob (raw object bytes + version metadata) on the // OS clipboard so another tab -- even another save -- can paste it. // Extracting 100k+ objects takes a noticeable moment in the worker, so diff --git a/rust_parser/core/src/editor/apply.rs b/rust_parser/core/src/editor/apply.rs index 21f1aa5..844c92a 100644 --- a/rust_parser/core/src/editor/apply.rs +++ b/rust_parser/core/src/editor/apply.rs @@ -30,23 +30,22 @@ impl EditPlan { } } -/// Mutate `body` per the plan. Length changes shift the tail with -/// copy_within instead of building a second body; the leading u64 -/// uncompressedSize is refreshed at the end. -pub fn apply_plan(body: &mut Vec, mut plan: EditPlan) -> PResult<()> { - // Pre-flight: validate the WHOLE plan before touching a byte. Two - // reasons. (1) A mid-apply error would leave the body half-mutated, and - // recovery from that is a full multi-second replay from the pristine - // copy. (2) The splice logic below assumes removes are disjoint and - // patches never target removed bytes -- a planner bug violating either - // used to corrupt silently (same-length wrong bytes pass the strict - // re-parse) or underflow; now it aborts loudly with the body intact. +/// Pre-flight for both apply paths: validate the WHOLE plan before touching +/// a byte. Two reasons. (1) A mid-apply error would leave the body +/// half-mutated, and recovery from that is a full multi-second replay from +/// the pristine copy. (2) The splice logic assumes removes are disjoint, +/// patches never target removed bytes, and inserts never fall inside removed +/// spans -- a planner bug violating any of these used to corrupt silently +/// (same-length wrong bytes pass the strict re-parse) or underflow; now it +/// aborts loudly with the body intact. Sorts removes and inserts in place. +fn validate_plan(body_len: usize, plan: &mut EditPlan) -> PResult<()> { plan.removes.sort_by_key(|(at, _)| *at); + plan.inserts.sort_by_key(|(at, _)| *at); let mut prev_end = 0usize; for &(at, len) in &plan.removes { let end = at .checked_add(len) - .filter(|&e| e <= body.len()) + .filter(|&e| e <= body_len) .ok_or_else(|| perr!("Edit plan remove out of range"))?; if at < prev_end { return Err(perr!("Edit plan removes overlap")); @@ -60,16 +59,42 @@ pub fn apply_plan(body: &mut Vec, mut plan: EditPlan) -> PResult<()> { for (at, bytes) in &plan.patches { let end = at .checked_add(bytes.len()) - .filter(|&e| e <= body.len()) + .filter(|&e| e <= body_len) .ok_or_else(|| perr!("Edit patch out of range"))?; if overlaps_remove(*at, end) { return Err(perr!("Edit patch overlaps a removed span")); } } - for (at, _) in &plan.inserts { - if *at > body.len() { + for &(at, _) in &plan.inserts { + if at > body_len { return Err(perr!("Edit plan insert out of range")); } + // Inside a removed span (boundaries are fine): idx of the remove + // whose end is past `at`; if it starts strictly before `at`, the + // insert would land mid-removal. + let idx = plan.removes.partition_point(|&(r, len)| r + len <= at); + if idx < plan.removes.len() && plan.removes[idx].0 < at { + return Err(perr!("Edit plan inserts inside a removed span")); + } + } + Ok(()) +} + +/// Mutate `body` per the plan. Length changes shift the tail with +/// copy_within instead of building a second body; the leading u64 +/// uncompressedSize is refreshed at the end. +pub fn apply_plan(body: &mut Vec, mut plan: EditPlan) -> PResult<()> { + validate_plan(body.len(), &mut plan)?; + + // Growth beyond the body's spare capacity forces a reallocation -- + // transiently ~2x the body, which the 4GB-capped wasm heap cannot + // afford on GB-scale saves (BODY_EDIT_SLACK absorbs typical copies, but + // a 100k-object paste overflows it). The streamed path rebuilds through + // a compressed snapshot at ~1x peak instead; native builds keep the + // plain realloc (plenty of RAM, no compression detour). + let added: usize = plan.inserts.iter().map(|(_, b)| b.len()).sum(); + if cfg!(target_arch = "wasm32") && added > 0 && body.capacity() < body.len() + added { + return apply_plan_streamed_validated(body, plan); } for (at, bytes) in &plan.patches { @@ -136,6 +161,82 @@ pub fn apply_plan(body: &mut Vec, mut plan: EditPlan) -> PResult<()> { Ok(()) } +/// apply_plan, but rebuilt through a compressed snapshot so peak memory +/// stays ~one body no matter how much the inserts grow it: patches apply in +/// place (size-neutral), the body is zlib-compressed (~15:1) and freed, and +/// the new body streams out of the snapshot with removes skipped and +/// inserts spliced in offset order. Produces byte-identical output to the +/// in-place path (the test suite asserts parity); costs one compression +/// round-trip, so it only runs when growth would otherwise realloc. +pub fn apply_plan_streamed(body: &mut Vec, mut plan: EditPlan) -> PResult<()> { + validate_plan(body.len(), &mut plan)?; + apply_plan_streamed_validated(body, plan) +} + +fn apply_plan_streamed_validated(body: &mut Vec, plan: EditPlan) -> PResult<()> { + use flate2::read::{ZlibDecoder, ZlibEncoder}; + use flate2::Compression; + use std::io::Read; + + for (at, bytes) in &plan.patches { + body[*at..at + bytes.len()].copy_from_slice(bytes); + } + let removed: usize = plan.removes.iter().map(|(_, len)| *len).sum(); + let added: usize = plan.inserts.iter().map(|(_, b)| b.len()).sum(); + let old_len = body.len(); + let new_len = old_len - removed + added; + + let mut compressed = Vec::with_capacity(old_len / 8); + ZlibEncoder::new(&body[..], Compression::fast()) + .read_to_end(&mut compressed) + .map_err(|e| perr!("Edit snapshot compression failed: {}", e))?; + *body = Vec::new(); // free the old block before allocating the new one + + let mut out: Vec = Vec::with_capacity(new_len + crate::decompress::BODY_EDIT_SLACK); + let mut dec = ZlibDecoder::new(&compressed[..]); + let mut copy_exact = |out: &mut Vec, n: usize, discard: bool| -> PResult<()> { + let copied = if discard { + std::io::copy(&mut (&mut dec).take(n as u64), &mut std::io::sink()) + } else { + std::io::copy(&mut (&mut dec).take(n as u64), out) + } + .map_err(|e| perr!("Edit snapshot decompression failed: {}", e))?; + if copied != n as u64 { + return Err(perr!("Edit snapshot truncated: {} != {}", copied, n)); + } + Ok(()) + }; + + // Merge removes and inserts by pre-op offset (both sorted by + // validate_plan). An insert at a remove's start goes first -- same seam + // the in-place path produces. + let (mut pos, mut ri, mut ii) = (0usize, 0usize, 0usize); + while ri < plan.removes.len() || ii < plan.inserts.len() { + let r_at = plan.removes.get(ri).map_or(usize::MAX, |r| r.0); + let i_at = plan.inserts.get(ii).map_or(usize::MAX, |i| i.0); + if i_at <= r_at { + copy_exact(&mut out, i_at - pos, false)?; + pos = i_at; + out.extend_from_slice(&plan.inserts[ii].1); + ii += 1; + } else { + copy_exact(&mut out, r_at - pos, false)?; + pos = r_at; + copy_exact(&mut out, plan.removes[ri].1, true)?; + pos += plan.removes[ri].1; + ri += 1; + } + } + copy_exact(&mut out, old_len - pos, false)?; + if out.len() != new_len { + return Err(perr!("Edit rebuild length mismatch: {} != {}", out.len(), new_len)); + } + let size = (out.len() - 8) as u64; + out[0..8].copy_from_slice(&size.to_le_bytes()); + *body = out; + Ok(()) +} + // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- @@ -1297,3 +1398,53 @@ pub fn apply_op(store: &SaveStore, body: &[u8], op: &EditOp) -> PResult> apply_plan(&mut out, plan)?; Ok(out) } + +#[cfg(test)] +mod plan_apply_tests { + use super::*; + + fn base_body() -> Vec { + let mut body: Vec = (0..=255u8).cycle().take(4096).collect(); + body[0..8].copy_from_slice(&((4096u64 - 8).to_le_bytes())); + body + } + + fn sample_plan() -> EditPlan { + let mut plan = EditPlan::default(); + plan.patch(100, vec![0xAA; 16]); + plan.patch(3000, vec![0xBB; 4]); + plan.removes.push((512, 128)); + plan.removes.push((1024, 64)); + plan.inserts.push((512, vec![0x11; 300])); // at a remove's start + plan.inserts.push((2048, vec![0x22; 500])); + plan.inserts.push((2048, vec![0x33; 100])); // same offset, keeps order + plan.inserts.push((4096, vec![0x44; 50])); // at end-of-body + plan + } + + /// The streamed (compress-free-rebuild) path must produce byte-identical + /// output to the in-place path for the same plan. + #[test] + fn streamed_matches_in_place() { + let mut in_place = base_body(); + in_place.reserve_exact(4096); // headroom: stays on the in-place path + apply_plan(&mut in_place, sample_plan()).unwrap(); + + let mut streamed = base_body(); + apply_plan_streamed(&mut streamed, sample_plan()).unwrap(); + + assert_eq!(in_place, streamed); + assert_eq!(in_place.len(), 4096 - 128 - 64 + 300 + 500 + 100 + 50); + } + + /// Inserts inside a removed span are a planner bug both paths refuse. + #[test] + fn insert_inside_removed_span_refused() { + let mut plan = EditPlan::default(); + plan.removes.push((512, 128)); + plan.inserts.push((600, vec![1, 2, 3])); + let mut body = base_body(); + assert!(apply_plan(&mut body, plan).is_err()); + assert_eq!(body, base_body(), "body must be untouched after refusal"); + } +} From 06b44d58802123e443a746f323915fb78d082728 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sat, 25 Jul 2026 22:47:26 +0200 Subject: [PATCH 04/11] editor: big-growth edits rebuild in a fresh worker instead of gambling The streamed apply reduced the transient, but whether a grown body still fits under the 4GB wasm ceiling in a long-lived instance depends on allocation history -- linear memory never shrinks, so the same 78k-object paste could trap once and succeed after recovery (reported and confirmed: retrying identical objects post-recovery worked). apply_edits now checks the first op's planned insert bytes against the body's spare capacity BEFORE tearing anything down; when growth exceeds it, it returns a marker error with the session untouched, and the client commits the edit and routes it through the existing fresh-worker recovery flow (reload + replay), with force=true so the replay -- which IS the fresh instance -- applies it without re-triggering the check. User-facing: 'Large edit -- rebuilding the session to fit it' with progress, then the paste lands; cost is ~one save reload. Edits under the headroom keep the instant in-place path, and the headroom resets on every rebuild/load. User-verified on the 1.3GB-body save. Co-Authored-By: Claude Fable 5 --- docs/release-notes-0.1.7.md | 9 +++++---- map/static/map/editor.js | 24 +++++++++++++++++++++++- map/static/map/save_client.js | 10 ++++++---- map/static/map/worker.js | 2 +- rust_parser/core/src/editor/apply.rs | 9 +++++++++ rust_parser/wasm/src/lib.rs | 27 +++++++++++++++++++++++---- 6 files changed, 67 insertions(+), 14 deletions(-) diff --git a/docs/release-notes-0.1.7.md b/docs/release-notes-0.1.7.md index b7f2895..7c3bbfb 100644 --- a/docs/release-notes-0.1.7.md +++ b/docs/release-notes-0.1.7.md @@ -23,10 +23,11 @@ game's own files. above roughly a gigabyte of decompressed data, any copy/paste could die with "Edit failed: unreachable" in the browser: growing the save to fit the copied objects briefly needed twice the save's memory, blowing the - browser's 4 GB WebAssembly limit. Small copies now grow the save in - place (built-in headroom), and very large pastes rebuild through a - compressed snapshot instead of doubling memory — 100k-object copies work - in the browser on gigabyte-scale saves. Browser copies above 150k + browser's 4 GB WebAssembly limit. Small and medium copies now grow the + save in place (built-in headroom), and pastes too large for that + headroom automatically rebuild the session in a fresh worker — a + progress bar instead of a crash, and the paste lands applied. Browser + copies above 150k objects skip the cross-tab clipboard mirror (which could stall the whole machine) — same-tab paste still works, and the desktop app handles cross-save pastes of any size. diff --git a/map/static/map/editor.js b/map/static/map/editor.js index abb36ae..c7544bd 100644 --- a/map/static/map/editor.js +++ b/map/static/map/editor.js @@ -215,14 +215,20 @@ var EditorTool = (function() { SaveLoadFlow.busyProgress(phase, percent); } + // The ops of the in-flight applyAction, so failApply can commit them for + // the fresh-instance replay when the worker signals a big edit. + var pendingActionOps = null; + function applyAction(ops) { if (applyInFlight || ops.length === 0) { return; } applyInFlight = true; + pendingActionOps = ops; SaveLoadFlow.showBusy(describeOps(ops)); SaveClient.applyEdits(ops, false, editProgress) .then(function(payload) { + pendingActionOps = null; actions.push(ops); redoStack = []; finishApply(payload, "Edit applied."); @@ -257,6 +263,20 @@ var EditorTool = (function() { } function failApply(error) { + // Not a failure: the worker judged the edit too large to apply safely + // in its long-lived instance (grown body might not fit the wasm memory + // ceiling, depending on allocation history). The session is untouched; + // commit the edit and run it through the fresh-worker rebuild, where + // the replay applies it with force. + if (error && error.message && error.message.indexOf("__bigEditRestart__") !== -1 + && pendingActionOps) { + actions.push(pendingActionOps); + pendingActionOps = null; + redoStack = []; + recoverSession("Large edit — rebuilding the session to fit it"); + return; + } + pendingActionOps = null; var message = "Edit failed: " + (error && error.message || error); // Semantic refusals (uneditable object, unknown name, ...) leave the // session intact: just report them. @@ -329,7 +349,9 @@ var EditorTool = (function() { return Promise.resolve(); } SaveLoadFlow.showBusy("Recovering — re-applying edit " + (i + 1) + " of " + backup.length + "…"); - return SaveClient.applyEdits(backup[i], false, editProgress).then(function(payload) { + // force: the replay runs in a fresh worker, which is exactly what the + // big-edit check would ask for -- don't loop through it again. + return SaveClient.applyEdits(backup[i], false, editProgress, true).then(function(payload) { SaveLoadFlow.hideProgress(); SaveLoadFlow.applyPayload(payload); actions.push(backup[i]); diff --git a/map/static/map/save_client.js b/map/static/map/save_client.js index 95002d4..4c6cdb2 100644 --- a/map/static/map/save_client.js +++ b/map/static/map/save_client.js @@ -443,14 +443,16 @@ const SaveClient = (() => { memStats() { return request({ op: "memStats" }); }, - // applyEdits(ops, fromPristine, onProgress) -> Promise. + // applyEdits(ops, fromPristine, onProgress, force) -> Promise. // ops: array of edit-op objects (see rust editor/ops.rs). fromPristine - // replaces the whole op list (undo); otherwise ops append. - applyEdits(ops, fromPristine, onProgress) { + // replaces the whole op list (undo); otherwise ops append. force skips + // the big-edit fresh-instance check (recovery replay runs in a fresh + // worker already). + applyEdits(ops, fromPristine, onProgress, force) { activeProgress = onProgress || null; stateVersion++; abortHandoff(); - return request({ op: "applyEdits", ops, fromPristine }).then((payloadBytes) => { + return request({ op: "applyEdits", ops, fromPristine, force: !!force }).then((payloadBytes) => { activeProgress = null; // Every edit cycle grows and fragments the wasm heap a little; // swapping to a fresh lean worker after each one keeps repeated diff --git a/map/static/map/worker.js b/map/static/map/worker.js index bcd2717..6da058c 100644 --- a/map/static/map/worker.js +++ b/map/static/map/worker.js @@ -147,7 +147,7 @@ self.onmessage = async (event) => { const opsJson = JSON.stringify(msg.ops); const payload = msg.fromPristine ? session.apply_edits_from_pristine(opsJson, pristine, progressCb) - : session.apply_edits(opsJson, progressCb); + : session.apply_edits(opsJson, !!msg.force, progressCb); reply(id, payload, [payload.buffer]); return; } diff --git a/rust_parser/core/src/editor/apply.rs b/rust_parser/core/src/editor/apply.rs index 844c92a..5513dc0 100644 --- a/rust_parser/core/src/editor/apply.rs +++ b/rust_parser/core/src/editor/apply.rs @@ -28,6 +28,15 @@ impl EditPlan { pub(crate) fn patch(&mut self, at: usize, bytes: impl Into>) { self.patches.push((at, bytes.into())); } + + /// Total bytes the plan's inserts add to the body. The wasm session + /// compares this against the body's spare capacity to decide whether an + /// edit should be applied in a fresh instance instead (wasm linear + /// memory never shrinks, so growing past capacity in a long-lived + /// instance risks tripping the 4GB ceiling on heap-layout luck). + pub fn inserted_bytes(&self) -> usize { + self.inserts.iter().map(|(_, b)| b.len()).sum() + } } /// Pre-flight for both apply paths: validate the WHOLE plan before touching diff --git a/rust_parser/wasm/src/lib.rs b/rust_parser/wasm/src/lib.rs index 05cdbf9..34ad4f7 100644 --- a/rust_parser/wasm/src/lib.rs +++ b/rust_parser/wasm/src/lib.rs @@ -138,6 +138,11 @@ pub struct SaveSession { const SESSION_LOST: &str = "No usable save state (a failed edit was not recovered) -- reload the save file"; +/// Marker error apply_edits returns (session left healthy) when an edit +/// grows the body past its spare capacity: the client rebuilds in a fresh +/// worker and replays with force=true (editor.js failApply). +const BIG_EDIT_RESTART: &str = "__bigEditRestart__"; + impl SaveSession { fn store(&self) -> Result<&SaveStore, JsError> { self.store.as_ref().map(|a| a.as_ref()).ok_or_else(|| JsError::new(SESSION_LOST)) @@ -301,6 +306,7 @@ impl SaveSession { pub fn apply_edits( &mut self, ops_json: &str, + force: bool, on_progress: &js_sys::Function, ) -> Result, JsError> { let call = |phase: u8, current: u64, total: u64| { @@ -326,10 +332,23 @@ impl SaveSession { // this works directly on the lean store and semantic refusals // (uneditable object, unknown name, chained belt) surface as clean // errors with the session left healthy. - drop( - sav_core::editor::apply::plan_op(self.store()?, &new_ops[0]) - .map_err(|e| JsError::new(&e.msg))?, - ); + let first_plan = sav_core::editor::apply::plan_op(self.store()?, &new_ops[0]) + .map_err(|e| JsError::new(&e.msg))?; + // Edits that grow the body past its spare capacity are only reliable + // on a fresh instance: linear memory never shrinks, so whether the + // grown body still fits under the 4GB ceiling in THIS instance + // depends on allocation history (the same paste can trap once and + // succeed after recovery). Signal the client to rebuild in a fresh + // worker and replay with force=true instead of gambling; the + // session stays fully healthy. + if !force { + let store = self.store()?; + let spare = store.data.capacity() - store.data.len(); + if first_plan.inserted_bytes() > spare { + return Err(JsError::new(BIG_EDIT_RESTART)); + } + } + drop(first_plan); // Free the index and stale payload up front -- everything below is // memory-critical on 600k-object saves. From e0e16bffc7dfba1c4835d9dc67e876cd6d07b9c3 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sat, 25 Jul 2026 23:31:55 +0200 Subject: [PATCH 05/11] editor: big browser copies stay tab-held; cross-save paste in one tab Mirroring a copy onto the OS clipboard is a synchronous string push that clipboard-history listeners then also chew on -- big blobs could stall the whole machine. The browser mirror is now capped at 50k objects (down from 150k). Bigger copies (up to the 150k extraction cap) keep their extracted blob tab-held instead: the bytes are self-contained, so they deliberately survive save loads -- copy, load the other save in the same tab, paste. That flow is now documented (README + notes) and covered end-to-end both ways: via the OS blob (small copies) and with the OS clipboard junked (what tab-held copies rely on). The OS blob now carries its write time (spliced into the JSON, never a reparse); resolvePaste pastes the newest of the OS blob and the held blob, so a stale small copy on the OS clipboard can't shadow a newer big one after a save switch. Copies whose blob would blow the 200MB ceiling -- or above the extraction cap -- say plainly that carrying them across saves or tabs needs the desktop app; same-session paste keeps working from the name list alone. Desktop native-slot path is untouched. Co-Authored-By: Claude Fable 5 --- README.md | 5 +- docs/release-notes-0.1.7.md | 13 ++- map/static/map/editor.js | 186 ++++++++++++++++++++++++------------ 3 files changed, 135 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 8118e36..2a886da 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,10 @@ up to **15 seconds at a time** — WebGL rendering instead of DOM markers combined inventory and object list. - **Edit your save** — move, copy/paste, rotate or delete whole factory sections (with undo), then download the edited `.sav` and load it in the - game. + game. Copies travel **between saves**, too: copy, load the other save in + the same tab, paste. Copies up to 50,000 objects also go through the OS + clipboard for pasting in another browser tab; beyond what the browser's + memory can carry, the desktop app takes over. - **Link straight to a map** — append `?url=` to load a hosted `.sav` automatically (e.g. a dedicated server's autosave exposed over HTTP). The file downloads directly into your browser — never through diff --git a/docs/release-notes-0.1.7.md b/docs/release-notes-0.1.7.md index 7c3bbfb..e345863 100644 --- a/docs/release-notes-0.1.7.md +++ b/docs/release-notes-0.1.7.md @@ -26,11 +26,14 @@ game's own files. browser's 4 GB WebAssembly limit. Small and medium copies now grow the save in place (built-in headroom), and pastes too large for that headroom automatically rebuild the session in a fresh worker — a - progress bar instead of a crash, and the paste lands applied. Browser - copies above 150k - objects skip the cross-tab clipboard mirror (which could stall the whole - machine) — same-tab paste still works, and the desktop app handles - cross-save pastes of any size. + progress bar instead of a crash, and the paste lands applied. +- **Copy a build from one save into another — right in the browser.** Copy, + load the other save (same tab), paste. Copies up to 50,000 objects also go + through the OS clipboard, so they paste in another browser tab too; bigger + copies stay inside the tab — mirroring them onto the OS clipboard could + stall the whole machine — so for those, cross-save means the same-tab flow + above. Copies too large for the browser's memory to carry between saves + say so and point at the desktop app, which has no such limits. - **Hypertube entrances are back on the map** — and with them, copies of daisy-chained builds keep their power wires. Entrances were treated as line-only geometry, but an entrance with its default tube shape carries no diff --git a/map/static/map/editor.js b/map/static/map/editor.js index c7544bd..1c7b37d 100644 --- a/map/static/map/editor.js +++ b/map/static/map/editor.js @@ -65,6 +65,13 @@ var EditorTool = (function() { var placement = null; var offsetTargets = null; // targets of the open offset dialog var clipboard = null; // editTargets captured by Copy + // Last extracted copy blob, kept in this tab: { json, at: Date.now() }. + // Unlike `clipboard` (names scoped to the current session), the blob is a + // self-contained byte payload, so it deliberately SURVIVES loading another + // save -- that's what makes "copy, load the other save, paste" work in one + // tab without the OS clipboard. Browser only; the desktop app keeps blobs + // in native clipboard slots instead. + var heldBlob = null; // ---- Targets --------------------------------------------------------------- @@ -480,61 +487,94 @@ var EditorTool = (function() { startPlacement("move", targets); } + // Browser copy tiers. Above CROSS_TAB_MAX_OBJECTS the blob never touches + // the OS clipboard: pushing a multi-MB string through the synchronous OS + // clipboard (which clipboard-history listeners then also chew on) can + // stall the whole machine -- the blob stays tab-held (heldBlob), where + // cross-SAVE paste still works. Above EXTRACT_MAX_OBJECTS extraction is + // skipped outright: the byte payload would blow the 200MB blob ceiling + // anyway. The desktop app is exempt from both: its native clipboard slots + // take big blobs without the OS-clipboard string round-trip. + var CROSS_TAB_MAX_OBJECTS = 50000; + var EXTRACT_MAX_OBJECTS = 150000; + + // Prefix the blob JSON with its write time (cheap string splice -- the + // blob can be tens of MB, never reparse it). resolvePaste compares this + // against heldBlob's timestamp to paste whichever copy is newest. + function stampClipboardTs(json) { + return json.charAt(0) === "{" ? '{"ts":' + Date.now() + "," + json.slice(1) : json; + } + function copyTargets(targets) { if (!targets || (targets.actorNames.length + targets.lightweight.length) === 0) { return; } clipboard = targets; + heldBlob = null; // a new copy replaces whatever an older one held var n = targets.actorNames.length + targets.lightweight.length; - SaveLoadFlow.setStatus("Copied " + n.toLocaleString() + " object" + (n === 1 ? "" : "s") - + " — Ctrl+V or right-click to paste."); - // In the browser, extremely large selections skip the cross-tab mirror - // below outright: extracting the byte payload and pushing a - // multi-hundred-MB string through the synchronous OS clipboard (which - // clipboard-history listeners then also chew on) can stall the whole - // machine, and the 200MB blob ceiling would refuse the result anyway. - // Same-tab paste only needs the name list set above. The desktop app is - // exempt: its native clipboard slots take big blobs without the OS - // clipboard string round-trip. - if (!window.__TAURI__ && n > 150000) { - SaveLoadFlow.setStatus("Copied " + n.toLocaleString() - + " objects — paste with Ctrl+V in this tab. Cross-tab copy is capped in the browser; " - + "use the desktop app for pastes this large."); + var label = n.toLocaleString() + " object" + (n === 1 ? "" : "s"); + SaveLoadFlow.setStatus("Copied " + label + " — Ctrl+V or right-click to paste."); + if (!window.__TAURI__ && n > EXTRACT_MAX_OBJECTS) { + SaveLoadFlow.setStatus("Copied " + label + " — paste with Ctrl+V while this save is open. " + + "Too many objects for the browser to carry across saves or tabs — " + + "use the desktop app for copies this large."); return; } - // Also put a portable blob (raw object bytes + version metadata) on the - // OS clipboard so another tab -- even another save -- can paste it. - // Extracting 100k+ objects takes a noticeable moment in the worker, so - // it runs under the busy overlay -- otherwise a big Copy looks like - // nothing happened until the status line quietly changes. - if (window.__TAURI__ || (navigator.clipboard && navigator.clipboard.writeText)) { - SaveLoadFlow.showBusy("Copying " + n.toLocaleString() + " object" + (n === 1 ? "" : "s") + "…"); - SaveClient.extractClipboard(targets.actorNames, targets.lightweight) - .then(function(json) { - // Same 200MB ceiling resolvePaste enforces: writing a blob the - // paste side would refuse anyway just moves the confusion later. - if (json.length > 200e6) { - throw new Error("too many objects for the browser clipboard — use the desktop app"); - } + // Extract the portable blob (raw object bytes + version metadata): the + // desktop app writes it to a native clipboard slot; the browser keeps + // it tab-held and only mirrors small copies to the OS clipboard for + // cross-tab paste. Extracting 100k+ objects takes a noticeable moment + // in the worker, so it runs under the busy overlay -- otherwise a big + // Copy looks like nothing happened until the status line quietly + // changes. + SaveLoadFlow.showBusy("Copying " + label + "…"); + SaveClient.extractClipboard(targets.actorNames, targets.lightweight) + .then(function(json) { + // Same 200MB ceiling resolvePaste enforces: holding a blob the + // paste side would refuse anyway just moves the confusion later. + if (json.length > 200e6) { + throw new Error("too many objects for the browser's memory — use the desktop app " + + "to copy sections this large across saves"); + } + if (window.__TAURI__) { // Desktop: write native-side, off WebView2's permission-gated // clipboard API. - return window.__TAURI__ - ? SaveClient.writeClipboardText(json) - : navigator.clipboard.writeText(json); - }) - .then(function() { - SaveLoadFlow.hideBusy(); - SaveLoadFlow.setStatus("Copied " + n.toLocaleString() + " object" + (n === 1 ? "" : "s") - + " — paste with Ctrl+V here or in another tab."); - }) - .catch(function(error) { - SaveLoadFlow.hideBusy(); - console.warn("System-clipboard copy failed (same-tab paste still works):", error); - SaveLoadFlow.setStatus("Copied " + n.toLocaleString() + " object" + (n === 1 ? "" : "s") - + " — cross-tab copy failed (" + ((error && error.message) || error) - + "); paste works in this tab only."); - }); - } + return SaveClient.writeClipboardText(json).then(function() { + return " — paste with Ctrl+V here or in another tab."; + }); + } + heldBlob = { json: json, at: Date.now() }; + if (n > CROSS_TAB_MAX_OBJECTS) { + return " — paste with Ctrl+V here, or load another save in this tab and paste it " + + "there. (Copies this big stay out of the OS clipboard, so other tabs can't see " + + "them — the desktop app has no such cap.)"; + } + if (!(navigator.clipboard && navigator.clipboard.writeText)) { + return " — paste with Ctrl+V here, or load another save in this tab and paste it there."; + } + return navigator.clipboard.writeText(stampClipboardTs(json)) + .then(function() { + return " — paste with Ctrl+V here or in another tab."; + }) + .catch(function(error) { + // The tab-held blob still covers this tab, including across + // save loads; only other tabs miss out. + console.warn("System-clipboard copy failed (this-tab paste still works):", error); + return " — paste with Ctrl+V here, or load another save in this tab and paste it " + + "there (cross-tab copy failed: " + ((error && error.message) || error) + ")."; + }); + }) + .then(function(suffix) { + SaveLoadFlow.hideBusy(); + SaveLoadFlow.setStatus("Copied " + label + suffix); + }) + .catch(function(error) { + SaveLoadFlow.hideBusy(); + console.warn("Clipboard extraction failed (same-tab paste still works):", error); + SaveLoadFlow.setStatus("Copied " + label + " — paste with Ctrl+V while this save is " + + "open; carrying the copy across saves or tabs failed (" + + ((error && error.message) || error) + ")."); + }); } // OS-clipboard text a paste could use, or null. Desktop reads native-side: @@ -552,34 +592,50 @@ var EditorTool = (function() { }); } - // What a paste would use right now: the in-tab clipboard when set, else a - // cross-tab blob from the OS clipboard (written by copyTargets in any tab). + // Parse + validate clipboard text as a paste blob; null when it isn't one. + function parsePasteBlob(text) { + if (!text || text.length > 200e6 || text.indexOf("\"smapPaste\"") === -1) { + return null; + } + var blob; + try { + blob = JSON.parse(text); + } catch (error) { + return null; + } + if (!blob || (blob.smapPaste !== 1 && blob.smapPaste !== 2 && blob.smapPaste !== 3) + || !blob.anchor || !blob.bboxWorld) { + return null; + } + return blob; + } + + // What a paste would use right now: the in-tab clipboard when set, else + // the newest of the OS-clipboard blob (written by copyTargets in any tab) + // and this tab's held blob (a copy that skipped the OS clipboard, or that + // outlived a save load here). OS blobs carry their write time (see + // stampClipboardTs); ones without it -- older app versions -- lose to any + // held copy. function resolvePaste() { if (clipboard) { return Promise.resolve({ mode: "internal" }); } return readOsClipboardText().then(function(text) { - if (!text || text.length > 200e6 || text.indexOf("\"smapPaste\"") === -1) { - return null; - } - var blob; - try { - blob = JSON.parse(text); - } catch (error) { - return null; - } - if (!blob || (blob.smapPaste !== 1 && blob.smapPaste !== 2 && blob.smapPaste !== 3) - || !blob.anchor || !blob.bboxWorld) { - return null; - } + var osBlob = parsePasteBlob(text); // smapPaste 3 is a desktop-app pointer: the object bytes live in the // desktop process, not on the OS clipboard, so only it can paste them. - if (blob.smapPaste === 3 && !window.__TAURI__) { + if (osBlob && osBlob.smapPaste === 3 && !window.__TAURI__) { SaveLoadFlow.setStatus("These objects were copied in the desktop app — paste them there" + " (too many to travel through the browser clipboard)."); - return null; + osBlob = null; + } + if (heldBlob && (!osBlob || (osBlob.ts || 0) < heldBlob.at)) { + var held = parsePasteBlob(heldBlob.json); + if (held) { + return { mode: "external", blob: held }; + } } - return { mode: "external", blob: blob }; + return osBlob ? { mode: "external", blob: osBlob } : null; }); } @@ -1122,6 +1178,10 @@ var EditorTool = (function() { currentFileName = fileName; actions = []; redoStack = []; + // heldBlob deliberately survives: its bytes are self-contained, so a + // copy made in the previous save pastes into this one (the documented + // cross-save flow). Only the name-scoped clipboard dies with the + // session. clipboard = null; cancelPlacement(); closeOffsetDialog(); From 3ee28e6f42f8d86c51eaddc21024a209ddf4bda5 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sat, 25 Jul 2026 23:38:15 +0200 Subject: [PATCH 06/11] editor: refuse browser copies above 150k objects outright A 278k-object copy "succeeded" instantly -- only the name list was kept, extraction being skipped past the cap -- and the paste it invited is exactly the kind that gambles with the 4GB wasm heap. A copy that big now refuses up front: nothing is copied, the status names the count and the cap, and points at the desktop app. The previous copy (names and held blob) is dropped too -- keeping it would make the next Ctrl+V silently paste stale contents right after a copy the user just watched happen. Desktop app unaffected. Co-Authored-By: Claude Fable 5 --- docs/release-notes-0.1.7.md | 6 ++++-- map/static/map/editor.js | 30 ++++++++++++++++++------------ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/release-notes-0.1.7.md b/docs/release-notes-0.1.7.md index e345863..65c0749 100644 --- a/docs/release-notes-0.1.7.md +++ b/docs/release-notes-0.1.7.md @@ -32,8 +32,10 @@ game's own files. through the OS clipboard, so they paste in another browser tab too; bigger copies stay inside the tab — mirroring them onto the OS clipboard could stall the whole machine — so for those, cross-save means the same-tab flow - above. Copies too large for the browser's memory to carry between saves - say so and point at the desktop app, which has no such limits. + above. Above 150,000 objects the browser refuses the copy outright + (nothing is copied — a selection that size can't paste reliably inside + the browser's 4 GB limit) and points at the desktop app, which has no + such limits. - **Hypertube entrances are back on the map** — and with them, copies of daisy-chained builds keep their power wires. Entrances were treated as line-only geometry, but an entrance with its default tube shape carries no diff --git a/map/static/map/editor.js b/map/static/map/editor.js index 1c7b37d..0ed79c3 100644 --- a/map/static/map/editor.js +++ b/map/static/map/editor.js @@ -491,12 +491,13 @@ var EditorTool = (function() { // the OS clipboard: pushing a multi-MB string through the synchronous OS // clipboard (which clipboard-history listeners then also chew on) can // stall the whole machine -- the blob stays tab-held (heldBlob), where - // cross-SAVE paste still works. Above EXTRACT_MAX_OBJECTS extraction is - // skipped outright: the byte payload would blow the 200MB blob ceiling - // anyway. The desktop app is exempt from both: its native clipboard slots - // take big blobs without the OS-clipboard string round-trip. + // cross-SAVE paste still works. Above COPY_MAX_OBJECTS the copy is + // REFUSED outright: the blob would blow the 200MB ceiling, and pasting a + // selection that size gambles with the 4GB wasm heap -- refusing up + // front beats a copy that looks fine and then can't deliver. The desktop + // app is exempt from both: native clipboard slots, no wasm ceiling. var CROSS_TAB_MAX_OBJECTS = 50000; - var EXTRACT_MAX_OBJECTS = 150000; + var COPY_MAX_OBJECTS = 150000; // Prefix the blob JSON with its write time (cheap string splice -- the // blob can be tens of MB, never reparse it). resolvePaste compares this @@ -509,17 +510,22 @@ var EditorTool = (function() { if (!targets || (targets.actorNames.length + targets.lightweight.length) === 0) { return; } - clipboard = targets; - heldBlob = null; // a new copy replaces whatever an older one held var n = targets.actorNames.length + targets.lightweight.length; var label = n.toLocaleString() + " object" + (n === 1 ? "" : "s"); - SaveLoadFlow.setStatus("Copied " + label + " — Ctrl+V or right-click to paste."); - if (!window.__TAURI__ && n > EXTRACT_MAX_OBJECTS) { - SaveLoadFlow.setStatus("Copied " + label + " — paste with Ctrl+V while this save is open. " - + "Too many objects for the browser to carry across saves or tabs — " - + "use the desktop app for copies this large."); + if (!window.__TAURI__ && n > COPY_MAX_OBJECTS) { + // Refused, and the previous copy is dropped too: keeping it would + // make the next Ctrl+V silently paste stale contents right after a + // copy the user just watched happen. + clipboard = null; + heldBlob = null; + SaveLoadFlow.setStatus("Copy refused: " + label + " is more than the browser can handle (" + + COPY_MAX_OBJECTS.toLocaleString() + " max) — nothing was copied. " + + "The desktop app copies selections of any size."); return; } + clipboard = targets; + heldBlob = null; // a new copy replaces whatever an older one held + SaveLoadFlow.setStatus("Copied " + label + " — Ctrl+V or right-click to paste."); // Extract the portable blob (raw object bytes + version metadata): the // desktop app writes it to a native clipboard slot; the browser keeps // it tab-held and only mirrors small copies to the OS clipboard for From 9976a5c9b98f4d52eeb29aed0300d8d68ed7ffdb Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sun, 26 Jul 2026 00:05:34 +0200 Subject: [PATCH 07/11] editor: recovery holds input, replays in one pass; big redo recovers Four fixes from a review of the big-edit recovery flow: - Input is blocked for the whole recovery. recoverSession used to drop applyInFlight before the reload+replay, so keyboard undo/redo/paste could interleave with the replay (the busy overlay only blocks the mouse) and desync the map from the actions list. The flag now holds until recovery ends, and a re-entrant recoverSession call no longer wipes the edit state a running recovery owns. - Redo of a big edit recovers instead of dead-ending. redo() never armed pendingActionOps, so the __bigEditRestart__ marker fell through to the generic error path: "Edit failed: __bigEditRestart__" in the status, forever. Redo now takes the same restart path as a fresh edit; the marker branch pops the redone action off the redo stack rather than wiping it. - The big-edit guard estimates the WHOLE action. It dry-planned only ops[0]; a mixed paste is [duplicateActors, duplicateLightweight] and the lightweight half could overflow the slack unguarded in the live worker. session::planned_growth sums every op's plan (ops within one action are mutually independent); op-0 planning still surfaces semantic refusals while the session is healthy. - Recovery replays committed history in ONE forced apply: one lean fold, one payload rebuild, one repaint -- instead of a reparse + rebuild + main-thread repaint per action (seconds each on 600k-object saves). If the batch fails, the session reloads once more and falls back to one-at-a-time so everything before a bad op still lands and the "X of Y restored" report stays honest. Verified end-to-end with an injected restart marker: recovery is one batched forced call, Ctrl+Z during recovery is ignored, undo after recovery works, big redo recovers with no marker leak, redo stack consumed. Full sav_core suite + editor/cross-save/refusal e2e green. Co-Authored-By: Claude Fable 5 --- map/static/map/editor.js | 56 +++++++++++++++++++-- rust_parser/core/src/editor/session.rs | 23 +++++++++ rust_parser/core/tests/editor_duplicate.rs | 57 ++++++++++++++++++++++ rust_parser/wasm/src/lib.rs | 25 ++++++---- 4 files changed, 147 insertions(+), 14 deletions(-) diff --git a/map/static/map/editor.js b/map/static/map/editor.js index 0ed79c3..e45c0e0 100644 --- a/map/static/map/editor.js +++ b/map/static/map/editor.js @@ -278,8 +278,14 @@ var EditorTool = (function() { if (error && error.message && error.message.indexOf("__bigEditRestart__") !== -1 && pendingActionOps) { actions.push(pendingActionOps); + // A redone action comes off the top of the redo stack it was redone + // from; a brand-new edit invalidates the whole stack instead. + if (redoStack.length && redoStack[redoStack.length - 1] === pendingActionOps) { + redoStack.pop(); + } else { + redoStack = []; + } pendingActionOps = null; - redoStack = []; recoverSession("Large edit — rebuilding the session to fit it"); return; } @@ -304,8 +310,10 @@ var EditorTool = (function() { var recovering = false; function recoverSession(message) { - if (recovering || !SaveLoadFlow.canReload()) { - recovering = false; + if (recovering) { + return; // a rebuild is already running and owns the edit state + } + if (!SaveLoadFlow.canReload()) { applyInFlight = false; SaveLoadFlow.hideProgress(); SaveLoadFlow.hideBusy(); @@ -316,7 +324,10 @@ var EditorTool = (function() { return; } recovering = true; - applyInFlight = false; + // applyInFlight stays TRUE for the whole rebuild: the busy overlay only + // blocks the mouse, and a keyboard undo/redo/paste interleaved with the + // replay would desync the map from the actions list. + applyInFlight = true; SaveLoadFlow.showBusy("Recovering — reloading save…"); SaveLoadFlow.setStatus(message + " — recovering (reloading save)…"); var backup = actions.slice(); @@ -325,10 +336,11 @@ var EditorTool = (function() { SaveLoadFlow.reloadCurrentFile() // resets EditorTool via onSaveLoaded .then(function() { clipboard = savedClipboard; - return replaySequentially(backup, 0); + return replayAll(backup, savedClipboard); }) .then(function() { recovering = false; + applyInFlight = false; SaveLoadFlow.hideBusy(); SaveLoadFlow.setStatus(message + " — recovered; your " + actions.length + " earlier edit" + (actions.length === 1 ? " was" : "s were") + " re-applied."); @@ -351,6 +363,36 @@ var EditorTool = (function() { }); } + // Recovery replay: the whole committed history in ONE forced apply -- one + // lean fold + one payload rebuild + one repaint, instead of one per action + // (each repaint is a seconds-long main-thread stall on 600k-object saves). + // If the batch fails (one op inside it broke, or it died mid-way) the + // session is rebuilt once more and the actions replay one at a time, so + // everything before the bad op still lands and the reported count is honest. + function replayAll(backup, savedClipboard) { + if (backup.length === 0) { + return Promise.resolve(); + } + SaveLoadFlow.showBusy("Recovering — re-applying " + backup.length + + " edit" + (backup.length === 1 ? "" : "s") + "…"); + var flat = []; + backup.forEach(function(a) { flat.push.apply(flat, a); }); + return SaveClient.applyEdits(flat, false, editProgress, true).then(function(payload) { + SaveLoadFlow.hideProgress(); + SaveLoadFlow.applyPayload(payload); + Array.prototype.push.apply(actions, backup); + updateToolbar(); + }, function(batchError) { + console.warn("Batched recovery replay failed — retrying one edit at a time:", batchError); + SaveClient.reset(); + SaveLoadFlow.showBusy("Recovering — reloading save…"); + return SaveLoadFlow.reloadCurrentFile().then(function() { + clipboard = savedClipboard; + return replaySequentially(backup, 0); + }); + }); + } + function replaySequentially(backup, i) { if (i >= backup.length) { return Promise.resolve(); @@ -411,9 +453,13 @@ var EditorTool = (function() { } var ops = redoStack[redoStack.length - 1]; applyInFlight = true; + // Armed so a big redo takes the fresh-worker restart path in failApply + // (same as a fresh edit) instead of dead-ending on the marker error. + pendingActionOps = ops; SaveLoadFlow.showBusy("Redoing…"); SaveClient.applyEdits(ops, false, editProgress) .then(function(payload) { + pendingActionOps = null; redoStack.pop(); actions.push(ops); finishApply(payload, "Edit redone."); diff --git a/rust_parser/core/src/editor/session.rs b/rust_parser/core/src/editor/session.rs index db3fe07..e5fdf00 100644 --- a/rust_parser/core/src/editor/session.rs +++ b/rust_parser/core/src/editor/session.rs @@ -112,6 +112,29 @@ fn step_owned_impl( } } +/// Estimated body growth (inserted bytes) of applying `ops` as ONE action, +/// each op planned against the CURRENT store. UI actions' ops are mutually +/// independent -- a mixed paste is [duplicateActors, duplicateLightweight], +/// a box delete [deleteActors, deleteLightweight] -- so planning them all +/// against the pre-action state gives the right total. The first op must +/// plan (that's the dry-run that surfaces semantic refusals while the +/// session is still healthy); a later op that fails to plan just adds +/// nothing -- the fold surfaces its real error. Used by the wasm big-edit +/// guard: growth past the body's spare capacity means restart-in-a-fresh- +/// worker, and checking only the first op would let the other half of a +/// mixed paste overflow unguarded. +pub fn planned_growth(store: &SaveStore, ops: &[EditOp]) -> PResult { + let mut grow = 0usize; + for (i, op) in ops.iter().enumerate() { + match crate::editor::apply::plan_op(store, op) { + Ok(plan) => grow += plan.inserted_bytes(), + Err(e) if i == 0 => return Err(e), + Err(_) => {} + } + } + Ok(grow) +} + /// Compress a pristine body for retention (wasm keeps the undo baseline as /// ~1/15th-size zlib instead of a full second body). Returns (zlib bytes, /// raw length). diff --git a/rust_parser/core/tests/editor_duplicate.rs b/rust_parser/core/tests/editor_duplicate.rs index 47de6a7..e24feff 100644 --- a/rust_parser/core/tests/editor_duplicate.rs +++ b/rust_parser/core/tests/editor_duplicate.rs @@ -400,3 +400,60 @@ fn insert_growth_stays_in_place() { "insert reallocated the body instead of growing in place" ); } + +/// The wasm big-edit guard estimates growth for the WHOLE action: a mixed +/// paste is [duplicateActors, duplicateLightweight], and checking only the +/// first op would let the lightweight half overflow the slack unguarded. +#[test] +fn planned_growth_covers_every_op_of_an_action() { + let store = load("All_080726-163150.sav"); + let constructor = actors_of_type(&store, "/Game/FactoryGame/Buildable/Factory/ConstructorMk1/") + .first() + .expect("constructor present") + .2 + .clone(); + let mut lw_type: Option = None; + for level in &store.levels { + for object in level.parsed_objects() { + if let ActorSpecific::Lightweight { items, .. } = &object.actor_specific { + if let Some(group) = items.first() { + lw_type = Some(group.type_path.to_string(&store.data)); + } + } + } + } + let Some(lw_type) = lw_type else { + eprintln!("save has no lightweight buildables; skipping"); + return; + }; + + let ops = vec![ + EditOp::DuplicateActors { + names: vec![constructor], + delta: [2000.0, 0.0, 0.0], + rotate_yaw_deg: 0.0, + pivot: None, + seed: 7, + }, + EditOp::DuplicateLightweight { + items: vec![LwRef { type_path: lw_type, index: 0 }], + delta: [2000.0, 0.0, 0.0], + rotate_yaw_deg: 0.0, + pivot: None, + }, + ]; + let actors_only = sav_core::editor::apply::plan_op(&store, &ops[0]).unwrap().inserted_bytes(); + let lw_only = sav_core::editor::apply::plan_op(&store, &ops[1]).unwrap().inserted_bytes(); + assert!(actors_only > 0 && lw_only > 0); + assert_eq!(session::planned_growth(&store, &ops).unwrap(), actors_only + lw_only); + + // Op-0 failures still surface (the healthy-session semantic dry-run). + let bad = EditOp::DuplicateActors { + names: vec!["NoSuchInstance_1".into()], + delta: [0.0, 0.0, 0.0], + rotate_yaw_deg: 0.0, + pivot: None, + seed: 7, + }; + assert!(session::planned_growth(&store, std::slice::from_ref(&bad)).is_err()); +} diff --git a/rust_parser/wasm/src/lib.rs b/rust_parser/wasm/src/lib.rs index 34ad4f7..de853ad 100644 --- a/rust_parser/wasm/src/lib.rs +++ b/rust_parser/wasm/src/lib.rs @@ -327,28 +327,35 @@ impl SaveSession { } let tables = ClassTables::embedded(); - // Dry-run the first op's plan while nothing is torn down: planning is + // Dry-run planning while nothing is torn down: planning is // model-independent (objects re-parse on demand from their spans), so // this works directly on the lean store and semantic refusals // (uneditable object, unknown name, chained belt) surface as clean // errors with the session left healthy. - let first_plan = sav_core::editor::apply::plan_op(self.store()?, &new_ops[0]) - .map_err(|e| JsError::new(&e.msg))?; // Edits that grow the body past its spare capacity are only reliable // on a fresh instance: linear memory never shrinks, so whether the // grown body still fits under the 4GB ceiling in THIS instance // depends on allocation history (the same paste can trap once and - // succeed after recovery). Signal the client to rebuild in a fresh - // worker and replay with force=true instead of gambling; the - // session stays fully healthy. - if !force { + // succeed after recovery). The estimate covers EVERY op of the + // action -- a mixed paste is [duplicateActors, duplicateLightweight] + // and either half can overflow the slack alone. Signal the client to + // rebuild in a fresh worker and replay with force=true instead of + // gambling; the session stays fully healthy. + if force { + // Recovery replay in a fresh worker: keep only the op-0 dry-run. + drop( + sav_core::editor::apply::plan_op(self.store()?, &new_ops[0]) + .map_err(|e| JsError::new(&e.msg))?, + ); + } else { let store = self.store()?; + let growth = session::planned_growth(store, &new_ops) + .map_err(|e| JsError::new(&e.msg))?; let spare = store.data.capacity() - store.data.len(); - if first_plan.inserted_bytes() > spare { + if growth > spare { return Err(JsError::new(BIG_EDIT_RESTART)); } } - drop(first_plan); // Free the index and stale payload up front -- everything below is // memory-critical on 600k-object saves. From 3e747d1321d1df4ed8e7f3755be79a273dea43cd Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sun, 26 Jul 2026 00:16:15 +0200 Subject: [PATCH 08/11] editor: big pastes apply in the live session; restart only near the ceiling The fresh-worker restart fired on any edit growing the body past its 64MB slack, regardless of how much memory was actually free -- so a mid-size save's big paste went fail -> reload -> forced replay for no reason (the forced replay then applied the identical edit through the same streamed path the live worker already has). The trigger is now memory-aware: growth past the slack streams in place through the compressed-snapshot apply, and only when even its worst case (no freed-block reuse: snapshot + grown body on top of current linear memory, margin for the payload rebuild) would not fit under the 4GB wasm ceiling does the restart marker fire. The restart machinery stays as the backstop it was meant to be -- saves genuinely near the ceiling -- instead of a detour every >64MB paste takes. fits_streamed_apply lives in sav_core (host-testable, overflow-proof); the wasm side feeds it the live linear-memory size. Verified: solo_autosave_1 select-all copy/paste (143,595 objects, growth far beyond the slack) applies in the live session -- status goes straight to "Edit applied", no rebuild/recovery in the status history, bucket counts double. Recovery, editor, and cross-save e2e suites plus the full sav_core suite stay green. Co-Authored-By: Claude Fable 5 --- docs/release-notes-0.1.7.md | 8 ++-- rust_parser/core/src/editor/session.rs | 16 ++++++++ rust_parser/core/tests/editor_duplicate.rs | 12 ++++++ rust_parser/wasm/src/lib.rs | 43 ++++++++++++++++------ 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/docs/release-notes-0.1.7.md b/docs/release-notes-0.1.7.md index 65c0749..f58f7ef 100644 --- a/docs/release-notes-0.1.7.md +++ b/docs/release-notes-0.1.7.md @@ -24,9 +24,11 @@ game's own files. with "Edit failed: unreachable" in the browser: growing the save to fit the copied objects briefly needed twice the save's memory, blowing the browser's 4 GB WebAssembly limit. Small and medium copies now grow the - save in place (built-in headroom), and pastes too large for that - headroom automatically rebuild the session in a fresh worker — a - progress bar instead of a crash, and the paste lands applied. + save in place (built-in headroom); bigger ones rebuild through a + compressed snapshot without ever leaving the session, and only when even + that wouldn't fit under the 4 GB limit does the editor rebuild in a + fresh worker and re-apply your edits — a progress bar instead of a + crash, and the paste lands applied. - **Copy a build from one save into another — right in the browser.** Copy, load the other save (same tab), paste. Copies up to 50,000 objects also go through the OS clipboard, so they paste in another browser tab too; bigger diff --git a/rust_parser/core/src/editor/session.rs b/rust_parser/core/src/editor/session.rs index e5fdf00..fc43f73 100644 --- a/rust_parser/core/src/editor/session.rs +++ b/rust_parser/core/src/editor/session.rs @@ -135,6 +135,22 @@ pub fn planned_growth(store: &SaveStore, ops: &[EditOp]) -> PResult { Ok(grow) } +/// Can an edit whose growth exceeds the body's spare capacity still be +/// applied in THIS wasm instance (apply_plan's streamed fallback), or does +/// it need a fresh worker? Worst case assumes no freed-block reuse at all: +/// the compressed snapshot (~len/8 with fast zlib) and the grown body +/// (+slack) land as NEW allocations on top of the current linear memory, +/// all of it under the 4GiB wasm ceiling with margin left for the payload +/// rebuild that follows. Anything under ~3GB of combined footprint passes, +/// so the fresh-worker restart is reserved for saves genuinely near the +/// ceiling instead of firing on every >slack paste. +pub fn fits_streamed_apply(mem_bytes: u64, body_len: u64, growth: u64) -> bool { + const CEILING: u64 = 4 << 30; + const MARGIN: u64 = 512 << 20; + let needed = body_len + growth + crate::decompress::BODY_EDIT_SLACK as u64 + body_len / 8; + mem_bytes.saturating_add(needed).saturating_add(MARGIN) <= CEILING +} + /// Compress a pristine body for retention (wasm keeps the undo baseline as /// ~1/15th-size zlib instead of a full second body). Returns (zlib bytes, /// raw length). diff --git a/rust_parser/core/tests/editor_duplicate.rs b/rust_parser/core/tests/editor_duplicate.rs index e24feff..6872ffc 100644 --- a/rust_parser/core/tests/editor_duplicate.rs +++ b/rust_parser/core/tests/editor_duplicate.rs @@ -457,3 +457,15 @@ fn planned_growth_covers_every_op_of_an_action() { }; assert!(session::planned_growth(&store, std::slice::from_ref(&bad)).is_err()); } + +/// The fresh-worker restart fires only near the wasm ceiling: a mid-size +/// instance streams a >slack paste in place; a ~3.5GB one restarts. +#[test] +fn streamed_apply_fitness_is_memory_aware() { + // 1.2GB instance, 150MB body, 100MB growth: streams in place. + assert!(session::fits_streamed_apply(1200 << 20, 150 << 20, 100 << 20)); + // Near the ceiling (3.5GB instance, 2.5GB body): fresh worker. + assert!(!session::fits_streamed_apply(3500 << 20, 2500 << 20, 100 << 20)); + // Overflow-proof on absurd inputs. + assert!(!session::fits_streamed_apply(u64::MAX, u64::MAX, u64::MAX)); +} diff --git a/rust_parser/wasm/src/lib.rs b/rust_parser/wasm/src/lib.rs index de853ad..1333ab1 100644 --- a/rust_parser/wasm/src/lib.rs +++ b/rust_parser/wasm/src/lib.rs @@ -139,10 +139,22 @@ const SESSION_LOST: &str = "No usable save state (a failed edit was not recovered) -- reload the save file"; /// Marker error apply_edits returns (session left healthy) when an edit -/// grows the body past its spare capacity: the client rebuilds in a fresh -/// worker and replays with force=true (editor.js failApply). +/// grows the body past what even the streamed apply can fit in this +/// instance: the client rebuilds in a fresh worker and replays with +/// force=true (editor.js failApply). const BIG_EDIT_RESTART: &str = "__bigEditRestart__"; +/// Current wasm linear-memory size -- the footprint the streamed-apply +/// fitness check weighs against the 4GB ceiling. +#[cfg(target_arch = "wasm32")] +fn linear_memory_bytes() -> u64 { + core::arch::wasm32::memory_size(0) as u64 * 65536 +} +#[cfg(not(target_arch = "wasm32"))] +fn linear_memory_bytes() -> u64 { + 0 // host builds (cargo check/test): no ceiling to respect +} + impl SaveSession { fn store(&self) -> Result<&SaveStore, JsError> { self.store.as_ref().map(|a| a.as_ref()).ok_or_else(|| JsError::new(SESSION_LOST)) @@ -332,15 +344,16 @@ impl SaveSession { // this works directly on the lean store and semantic refusals // (uneditable object, unknown name, chained belt) surface as clean // errors with the session left healthy. - // Edits that grow the body past its spare capacity are only reliable - // on a fresh instance: linear memory never shrinks, so whether the - // grown body still fits under the 4GB ceiling in THIS instance - // depends on allocation history (the same paste can trap once and - // succeed after recovery). The estimate covers EVERY op of the - // action -- a mixed paste is [duplicateActors, duplicateLightweight] - // and either half can overflow the slack alone. Signal the client to - // rebuild in a fresh worker and replay with force=true instead of - // gambling; the session stays fully healthy. + // Growth beyond the body's spare capacity is normally fine HERE: + // apply_plan's streamed fallback rebuilds through a compressed + // snapshot at ~1x peak. The estimate covers EVERY op of the action + // (a mixed paste is [duplicateActors, duplicateLightweight] and + // either half can overflow the slack alone). Only when even the + // streamed apply's worst case would not fit under the 4GB wasm + // ceiling -- linear memory never shrinks, so allocation history + // counts against it -- do we signal the client to rebuild in a + // fresh worker and replay with force=true instead of gambling; the + // session stays fully healthy either way. if force { // Recovery replay in a fresh worker: keep only the op-0 dry-run. drop( @@ -352,7 +365,13 @@ impl SaveSession { let growth = session::planned_growth(store, &new_ops) .map_err(|e| JsError::new(&e.msg))?; let spare = store.data.capacity() - store.data.len(); - if growth > spare { + if growth > spare + && !session::fits_streamed_apply( + linear_memory_bytes(), + store.data.len() as u64, + growth as u64, + ) + { return Err(JsError::new(BIG_EDIT_RESTART)); } } From 718486f3de79345a317d01564ca6bb2d536b2937 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sun, 26 Jul 2026 00:43:55 +0200 Subject: [PATCH 09/11] editor: credit reusable freed heap in the big-edit fitness check The first memory-aware trigger still restarted on giant saves: it treated every byte the streamed apply needs as NEW linear memory, so a post-load full worker (2.3GB heap, 1.6GB live -- the dropped object model is free-but-allocated space the allocator reuses) failed the check on a 70k-object paste that fits comfortably. fits_streamed_apply now takes the allocator's live-bytes count: free heap covers the snapshot + grown body + rebuild margin at a 3/4 fragmentation discount, and only the remainder counts as linear-memory growth against the 4GiB ceiling. The old body's own block, freed mid-apply, still is not counted at all -- the estimate stays conservative. Verified on the report's save (PhantomTorture, 54MB): 70k-object copy + paste right after load, full worker at 2.3GB -- applies live in ~15s, status goes straight to "Edit applied", no rebuild/recovery; heap peaks at 3.8GB under the ceiling and the lean handoff reclaims it after. Full sav_core suite + editor/recovery e2e green. Co-Authored-By: Claude Fable 5 --- rust_parser/core/src/editor/session.rs | 30 ++++++++++++++-------- rust_parser/core/tests/editor_duplicate.rs | 20 ++++++++++----- rust_parser/wasm/src/lib.rs | 1 + 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/rust_parser/core/src/editor/session.rs b/rust_parser/core/src/editor/session.rs index fc43f73..67350ec 100644 --- a/rust_parser/core/src/editor/session.rs +++ b/rust_parser/core/src/editor/session.rs @@ -137,18 +137,28 @@ pub fn planned_growth(store: &SaveStore, ops: &[EditOp]) -> PResult { /// Can an edit whose growth exceeds the body's spare capacity still be /// applied in THIS wasm instance (apply_plan's streamed fallback), or does -/// it need a fresh worker? Worst case assumes no freed-block reuse at all: -/// the compressed snapshot (~len/8 with fast zlib) and the grown body -/// (+slack) land as NEW allocations on top of the current linear memory, -/// all of it under the 4GiB wasm ceiling with margin left for the payload -/// rebuild that follows. Anything under ~3GB of combined footprint passes, -/// so the fresh-worker restart is reserved for saves genuinely near the -/// ceiling instead of firing on every >slack paste. -pub fn fits_streamed_apply(mem_bytes: u64, body_len: u64, growth: u64) -> bool { +/// it need a fresh worker? The streamed apply allocates the compressed +/// snapshot (~len/8 with fast zlib) and the grown body (+slack); linear +/// memory that is already allocated but free again -- e.g. the dropped +/// object model in a post-load full worker -- is reusable (the allocator +/// coalesces; the 3/4 factor discounts fragmentation, and the old body's +/// own block, freed mid-apply, is not counted at all). Only what can't be +/// covered by reuse must come from growing linear memory, which together +/// with a margin for the payload rebuild has to stay under the 4GiB +/// ceiling. Mid-size saves always pass; the fresh-worker restart is +/// reserved for saves genuinely near the ceiling. +pub fn fits_streamed_apply(mem_bytes: u64, live_bytes: u64, body_len: u64, growth: u64) -> bool { const CEILING: u64 = 4 << 30; const MARGIN: u64 = 512 << 20; - let needed = body_len + growth + crate::decompress::BODY_EDIT_SLACK as u64 + body_len / 8; - mem_bytes.saturating_add(needed).saturating_add(MARGIN) <= CEILING + // MARGIN (payload rebuild etc.) counts as an allocation like the rest -- + // it reuses freed heap just as well, so it belongs in `needed`, not + // stacked on top of linear memory (which would bar any big instance no + // matter how empty its heap is). + let needed = body_len + growth + crate::decompress::BODY_EDIT_SLACK as u64 + body_len / 8 + + MARGIN; + let reusable = mem_bytes.saturating_sub(live_bytes) / 4 * 3; + let heap_growth = needed.saturating_sub(reusable); + mem_bytes.saturating_add(heap_growth) <= CEILING } /// Compress a pristine body for retention (wasm keeps the undo baseline as diff --git a/rust_parser/core/tests/editor_duplicate.rs b/rust_parser/core/tests/editor_duplicate.rs index 6872ffc..e058b8d 100644 --- a/rust_parser/core/tests/editor_duplicate.rs +++ b/rust_parser/core/tests/editor_duplicate.rs @@ -458,14 +458,20 @@ fn planned_growth_covers_every_op_of_an_action() { assert!(session::planned_growth(&store, std::slice::from_ref(&bad)).is_err()); } -/// The fresh-worker restart fires only near the wasm ceiling: a mid-size -/// instance streams a >slack paste in place; a ~3.5GB one restarts. +/// The fresh-worker restart fires only near the wasm ceiling: mid-size +/// instances stream a >slack paste in place, and a post-load full worker +/// whose heap is mostly the FREED object model gets credit for that reuse; +/// a heap whose live bytes genuinely crowd the ceiling restarts. #[test] fn streamed_apply_fitness_is_memory_aware() { - // 1.2GB instance, 150MB body, 100MB growth: streams in place. - assert!(session::fits_streamed_apply(1200 << 20, 150 << 20, 100 << 20)); - // Near the ceiling (3.5GB instance, 2.5GB body): fresh worker. - assert!(!session::fits_streamed_apply(3500 << 20, 2500 << 20, 100 << 20)); + let gb = |n: u64| n << 30; + // Mid-size save in a lean worker: streams in place. + assert!(session::fits_streamed_apply(1200 << 20, 900 << 20, 150 << 20, 100 << 20)); + // Giant save right after load: 3.6GB heap but only ~1.6GB live (the + // dropped model is reusable) -- a 100MB paste streams in place. + assert!(session::fits_streamed_apply(3600 << 20, 1600 << 20, 1000 << 20, 100 << 20)); + // Same heap size but genuinely live near the ceiling: fresh worker. + assert!(!session::fits_streamed_apply(3600 << 20, 3300 << 20, gb(3), 200 << 20)); // Overflow-proof on absurd inputs. - assert!(!session::fits_streamed_apply(u64::MAX, u64::MAX, u64::MAX)); + assert!(!session::fits_streamed_apply(u64::MAX, 0, u64::MAX, u64::MAX)); } diff --git a/rust_parser/wasm/src/lib.rs b/rust_parser/wasm/src/lib.rs index 1333ab1..c143f07 100644 --- a/rust_parser/wasm/src/lib.rs +++ b/rust_parser/wasm/src/lib.rs @@ -368,6 +368,7 @@ impl SaveSession { if growth > spare && !session::fits_streamed_apply( linear_memory_bytes(), + live_heap_bytes() as u64, store.data.len() as u64, growth as u64, ) From 1e5ec387acf8fb74aa7fee6da03d95c0e1b472b6 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sun, 26 Jul 2026 01:03:10 +0200 Subject: [PATCH 10/11] editor: no more pre-emptive restarts -- attempt every edit, refuse only the impossible Second heuristic round (free-heap-aware) still restarted the user's real 70k paste: a box selection full of belts/pipes carries far bigger bodies than the synthetic test's, and any estimate of allocator state keeps losing to reality. So stop estimating: - Every edit is attempted in the live session (the streamed apply peaks at ~1x body). The __bigEditRestart__ marker, its failApply branch, and pendingActionOps are gone. - The only pre-emptive refusal is certain doom -- a grown body that could never exist in a 4GiB heap -- and it is a plain healthy-session error naming the desktop app, not a rebuild. - If the wasm heap genuinely dies mid-edit (trap / worker crash -- the crash path now carries sessionLost too), the edit is abandoned and reported as too large with the desktop-app pointer; the reload that follows only restores the map and the earlier committed edits instead of leaving a dead page. Nothing is ever silently retried. Verified: injected sessionLost trap -- failed edit abandoned with the desktop-app message, earlier edit restored in one batched replay, input blocked during the reload, undo/redo fine after; 70k phantom paste still applies live in ~15s. Full sav_core + editor/cross-save e2e green. Co-Authored-By: Claude Fable 5 --- docs/release-notes-0.1.7.md | 9 ++-- map/static/map/editor.js | 52 +++++++--------------- map/static/map/save_client.js | 7 ++- rust_parser/core/src/editor/session.rs | 46 ++++++++----------- rust_parser/core/tests/editor_duplicate.rs | 24 +++++----- rust_parser/wasm/src/lib.rs | 49 ++++++-------------- 6 files changed, 69 insertions(+), 118 deletions(-) diff --git a/docs/release-notes-0.1.7.md b/docs/release-notes-0.1.7.md index f58f7ef..65893f9 100644 --- a/docs/release-notes-0.1.7.md +++ b/docs/release-notes-0.1.7.md @@ -25,10 +25,11 @@ game's own files. the copied objects briefly needed twice the save's memory, blowing the browser's 4 GB WebAssembly limit. Small and medium copies now grow the save in place (built-in headroom); bigger ones rebuild through a - compressed snapshot without ever leaving the session, and only when even - that wouldn't fit under the 4 GB limit does the editor rebuild in a - fresh worker and re-apply your edits — a progress bar instead of a - crash, and the paste lands applied. + compressed snapshot at ~1x memory, so pastes big and small apply + directly in the session. An edit that genuinely cannot fit under the + 4 GB limit fails up front with a clear message pointing at the desktop + app — and if the browser dies mid-edit anyway, the editor reloads and + restores your earlier edits instead of leaving a dead page. - **Copy a build from one save into another — right in the browser.** Copy, load the other save (same tab), paste. Copies up to 50,000 objects also go through the OS clipboard, so they paste in another browser tab too; bigger diff --git a/map/static/map/editor.js b/map/static/map/editor.js index e45c0e0..45aba8e 100644 --- a/map/static/map/editor.js +++ b/map/static/map/editor.js @@ -222,20 +222,14 @@ var EditorTool = (function() { SaveLoadFlow.busyProgress(phase, percent); } - // The ops of the in-flight applyAction, so failApply can commit them for - // the fresh-instance replay when the worker signals a big edit. - var pendingActionOps = null; - function applyAction(ops) { if (applyInFlight || ops.length === 0) { return; } applyInFlight = true; - pendingActionOps = ops; SaveLoadFlow.showBusy(describeOps(ops)); SaveClient.applyEdits(ops, false, editProgress) .then(function(payload) { - pendingActionOps = null; actions.push(ops); redoStack = []; finishApply(payload, "Edit applied."); @@ -270,29 +264,17 @@ var EditorTool = (function() { } function failApply(error) { - // Not a failure: the worker judged the edit too large to apply safely - // in its long-lived instance (grown body might not fit the wasm memory - // ceiling, depending on allocation history). The session is untouched; - // commit the edit and run it through the fresh-worker rebuild, where - // the replay applies it with force. - if (error && error.message && error.message.indexOf("__bigEditRestart__") !== -1 - && pendingActionOps) { - actions.push(pendingActionOps); - // A redone action comes off the top of the redo stack it was redone - // from; a brand-new edit invalidates the whole stack instead. - if (redoStack.length && redoStack[redoStack.length - 1] === pendingActionOps) { - redoStack.pop(); - } else { - redoStack = []; - } - pendingActionOps = null; - recoverSession("Large edit — rebuilding the session to fit it"); - return; + var text = (error && error.message) || String(error); + // A wasm memory death reads as a cryptic trap ("unreachable", "memory + // access out of bounds"); translate it into the actionable truth. + if (error && error.sessionLost + && /unreachable|out of bounds|out of memory|allocation|crashed/i.test(text)) { + text = "this edit is too large for the browser's 4GB memory limit — " + + "use the desktop app for edits this large"; } - pendingActionOps = null; - var message = "Edit failed: " + (error && error.message || error); - // Semantic refusals (uneditable object, unknown name, ...) leave the - // session intact: just report them. + var message = "Edit failed: " + text; + // Semantic refusals (uneditable object, unknown name, edit-too-big, ...) + // leave the session intact: just report them. if (!error || !error.sessionLost) { applyInFlight = false; SaveLoadFlow.hideProgress(); @@ -301,9 +283,9 @@ var EditorTool = (function() { updateToolbar(); return; } - // The wasm session is gone (out-of-memory trap on a huge save). Recover - // with a fresh worker: reload the original file, then replay the - // committed actions one at a time. + // The wasm session died with the failed edit. The edit itself is + // abandoned -- never retried; the reload below only brings back the + // map and the earlier committed edits instead of leaving a dead page. recoverSession(message); } @@ -398,8 +380,8 @@ var EditorTool = (function() { return Promise.resolve(); } SaveLoadFlow.showBusy("Recovering — re-applying edit " + (i + 1) + " of " + backup.length + "…"); - // force: the replay runs in a fresh worker, which is exactly what the - // big-edit check would ask for -- don't loop through it again. + // force: exact-history replay skips the growth dry-run -- these ops + // already applied once. return SaveClient.applyEdits(backup[i], false, editProgress, true).then(function(payload) { SaveLoadFlow.hideProgress(); SaveLoadFlow.applyPayload(payload); @@ -453,13 +435,9 @@ var EditorTool = (function() { } var ops = redoStack[redoStack.length - 1]; applyInFlight = true; - // Armed so a big redo takes the fresh-worker restart path in failApply - // (same as a fresh edit) instead of dead-ending on the marker error. - pendingActionOps = ops; SaveLoadFlow.showBusy("Redoing…"); SaveClient.applyEdits(ops, false, editProgress) .then(function(payload) { - pendingActionOps = null; redoStack.pop(); actions.push(ops); finishApply(payload, "Edit redone."); diff --git a/map/static/map/save_client.js b/map/static/map/save_client.js index 4c6cdb2..7d6acad 100644 --- a/map/static/map/save_client.js +++ b/map/static/map/save_client.js @@ -81,9 +81,12 @@ const SaveClient = (() => { } }; // A crashed worker (wasm panic / OOM) leaves indeterminate state: - // reject everything and respawn fresh. + // reject everything and respawn fresh. The session is gone with it, + // so flag sessionLost -- the editor recovers by reloading the save + // and replaying its edits instead of dead-ending on "Edit failed". w.onerror = (event) => { const error = new Error("Save worker crashed: " + (event.message || "unknown error")); + error.sessionLost = true; for (const entry of pending.values()) { entry.reject(error); } @@ -446,7 +449,7 @@ const SaveClient = (() => { // applyEdits(ops, fromPristine, onProgress, force) -> Promise. // ops: array of edit-op objects (see rust editor/ops.rs). fromPristine // replaces the whole op list (undo); otherwise ops append. force skips - // the big-edit fresh-instance check (recovery replay runs in a fresh + // the growth dry-run refusal (recovery replay runs in a fresh // worker already). applyEdits(ops, fromPristine, onProgress, force) { activeProgress = onProgress || null; diff --git a/rust_parser/core/src/editor/session.rs b/rust_parser/core/src/editor/session.rs index 67350ec..81133de 100644 --- a/rust_parser/core/src/editor/session.rs +++ b/rust_parser/core/src/editor/session.rs @@ -119,10 +119,9 @@ fn step_owned_impl( /// against the pre-action state gives the right total. The first op must /// plan (that's the dry-run that surfaces semantic refusals while the /// session is still healthy); a later op that fails to plan just adds -/// nothing -- the fold surfaces its real error. Used by the wasm big-edit -/// guard: growth past the body's spare capacity means restart-in-a-fresh- -/// worker, and checking only the first op would let the other half of a -/// mixed paste overflow unguarded. +/// nothing -- the fold surfaces its real error. Used by the wasm +/// too-big-edit refusal (grown_body_can_exist): checking only the first op +/// would let the other half of a mixed paste slip past the bound. pub fn planned_growth(store: &SaveStore, ops: &[EditOp]) -> PResult { let mut grow = 0usize; for (i, op) in ops.iter().enumerate() { @@ -135,30 +134,23 @@ pub fn planned_growth(store: &SaveStore, ops: &[EditOp]) -> PResult { Ok(grow) } -/// Can an edit whose growth exceeds the body's spare capacity still be -/// applied in THIS wasm instance (apply_plan's streamed fallback), or does -/// it need a fresh worker? The streamed apply allocates the compressed -/// snapshot (~len/8 with fast zlib) and the grown body (+slack); linear -/// memory that is already allocated but free again -- e.g. the dropped -/// object model in a post-load full worker -- is reusable (the allocator -/// coalesces; the 3/4 factor discounts fragmentation, and the old body's -/// own block, freed mid-apply, is not counted at all). Only what can't be -/// covered by reuse must come from growing linear memory, which together -/// with a margin for the payload rebuild has to stay under the 4GiB -/// ceiling. Mid-size saves always pass; the fresh-worker restart is -/// reserved for saves genuinely near the ceiling. -pub fn fits_streamed_apply(mem_bytes: u64, live_bytes: u64, body_len: u64, growth: u64) -> bool { +/// Pre-empting an in-place apply is reserved for CERTAIN doom: the grown +/// body itself -- one contiguous allocation -- can never exist in a 4GiB +/// wasm heap, so the edit is refused up front (session healthy, desktop +/// app suggested). Everything else is attempted right here: the streamed +/// apply peaks at ~1x body, and if the heap genuinely can't take it the +/// trap surfaces as the same too-big edit failure, with the client +/// reloading only to restore the map and the earlier committed edits. +/// (Two rounds of estimate heuristics -- worst-case, then free-heap-aware +/// -- each kept pre-empting real saves whose pastes applied fine; +/// predictions about allocator state lose to just asking it.) +pub fn grown_body_can_exist(body_len: u64, growth: u64) -> bool { const CEILING: u64 = 4 << 30; - const MARGIN: u64 = 512 << 20; - // MARGIN (payload rebuild etc.) counts as an allocation like the rest -- - // it reuses freed heap just as well, so it belongs in `needed`, not - // stacked on top of linear memory (which would bar any big instance no - // matter how empty its heap is). - let needed = body_len + growth + crate::decompress::BODY_EDIT_SLACK as u64 + body_len / 8 - + MARGIN; - let reusable = mem_bytes.saturating_sub(live_bytes) / 4 * 3; - let heap_growth = needed.saturating_sub(reusable); - mem_bytes.saturating_add(heap_growth) <= CEILING + const OVERHEAD: u64 = 64 << 20; // runtime + code + stacks, roughly + body_len + .saturating_add(growth) + .saturating_add(crate::decompress::BODY_EDIT_SLACK as u64) + <= CEILING - OVERHEAD } /// Compress a pristine body for retention (wasm keeps the undo baseline as diff --git a/rust_parser/core/tests/editor_duplicate.rs b/rust_parser/core/tests/editor_duplicate.rs index e058b8d..a923c44 100644 --- a/rust_parser/core/tests/editor_duplicate.rs +++ b/rust_parser/core/tests/editor_duplicate.rs @@ -458,20 +458,18 @@ fn planned_growth_covers_every_op_of_an_action() { assert!(session::planned_growth(&store, std::slice::from_ref(&bad)).is_err()); } -/// The fresh-worker restart fires only near the wasm ceiling: mid-size -/// instances stream a >slack paste in place, and a post-load full worker -/// whose heap is mostly the FREED object model gets credit for that reuse; -/// a heap whose live bytes genuinely crowd the ceiling restarts. +/// The fresh-worker restart marker fires only on certain doom -- a grown +/// body that can never exist in a 4GiB heap. Everything below that line is +/// attempted in place (trap recovery is the backstop). #[test] -fn streamed_apply_fitness_is_memory_aware() { +fn restart_is_reserved_for_impossible_bodies() { let gb = |n: u64| n << 30; - // Mid-size save in a lean worker: streams in place. - assert!(session::fits_streamed_apply(1200 << 20, 900 << 20, 150 << 20, 100 << 20)); - // Giant save right after load: 3.6GB heap but only ~1.6GB live (the - // dropped model is reusable) -- a 100MB paste streams in place. - assert!(session::fits_streamed_apply(3600 << 20, 1600 << 20, 1000 << 20, 100 << 20)); - // Same heap size but genuinely live near the ceiling: fresh worker. - assert!(!session::fits_streamed_apply(3600 << 20, 3300 << 20, gb(3), 200 << 20)); + // Giant-save paste with a big growth: attempted in place. + assert!(session::grown_body_can_exist(gb(1), 800 << 20)); + // Even a near-3GB body with a large paste: attempted. + assert!(session::grown_body_can_exist(gb(3), 500 << 20)); + // A grown body that can't exist under 4GiB at all: restart. + assert!(!session::grown_body_can_exist(gb(3), gb(1))); // Overflow-proof on absurd inputs. - assert!(!session::fits_streamed_apply(u64::MAX, 0, u64::MAX, u64::MAX)); + assert!(!session::grown_body_can_exist(u64::MAX, u64::MAX)); } diff --git a/rust_parser/wasm/src/lib.rs b/rust_parser/wasm/src/lib.rs index c143f07..f54f6b2 100644 --- a/rust_parser/wasm/src/lib.rs +++ b/rust_parser/wasm/src/lib.rs @@ -138,22 +138,12 @@ pub struct SaveSession { const SESSION_LOST: &str = "No usable save state (a failed edit was not recovered) -- reload the save file"; -/// Marker error apply_edits returns (session left healthy) when an edit -/// grows the body past what even the streamed apply can fit in this -/// instance: the client rebuilds in a fresh worker and replays with -/// force=true (editor.js failApply). -const BIG_EDIT_RESTART: &str = "__bigEditRestart__"; - -/// Current wasm linear-memory size -- the footprint the streamed-apply -/// fitness check weighs against the 4GB ceiling. -#[cfg(target_arch = "wasm32")] -fn linear_memory_bytes() -> u64 { - core::arch::wasm32::memory_size(0) as u64 * 65536 -} -#[cfg(not(target_arch = "wasm32"))] -fn linear_memory_bytes() -> u64 { - 0 // host builds (cargo check/test): no ceiling to respect -} +/// Refusal for edits whose grown body could never exist in a 4GiB wasm +/// heap. Surfaced as a plain edit error with the session left healthy -- +/// no rebuild dance; the desktop app is the answer at that size. +const EDIT_TOO_BIG: &str = + "this edit would grow the save past the browser's 4GB memory limit — \ + use the desktop app for edits this large"; impl SaveSession { fn store(&self) -> Result<&SaveStore, JsError> { @@ -344,16 +334,13 @@ impl SaveSession { // this works directly on the lean store and semantic refusals // (uneditable object, unknown name, chained belt) surface as clean // errors with the session left healthy. - // Growth beyond the body's spare capacity is normally fine HERE: + // Growth beyond the body's spare capacity is applied HERE too: // apply_plan's streamed fallback rebuilds through a compressed - // snapshot at ~1x peak. The estimate covers EVERY op of the action - // (a mixed paste is [duplicateActors, duplicateLightweight] and - // either half can overflow the slack alone). Only when even the - // streamed apply's worst case would not fit under the 4GB wasm - // ceiling -- linear memory never shrinks, so allocation history - // counts against it -- do we signal the client to rebuild in a - // fresh worker and replay with force=true instead of gambling; the - // session stays fully healthy either way. + // snapshot at ~1x peak. No memory estimates decide anything -- the + // only pre-emptive refusal is the one case attempting can't help: + // the grown body could never exist in a 4GiB heap at all (growth + // summed over EVERY op of the action -- a mixed paste is + // [duplicateActors, duplicateLightweight]). if force { // Recovery replay in a fresh worker: keep only the op-0 dry-run. drop( @@ -364,16 +351,8 @@ impl SaveSession { let store = self.store()?; let growth = session::planned_growth(store, &new_ops) .map_err(|e| JsError::new(&e.msg))?; - let spare = store.data.capacity() - store.data.len(); - if growth > spare - && !session::fits_streamed_apply( - linear_memory_bytes(), - live_heap_bytes() as u64, - store.data.len() as u64, - growth as u64, - ) - { - return Err(JsError::new(BIG_EDIT_RESTART)); + if !session::grown_body_can_exist(store.data.len() as u64, growth as u64) { + return Err(JsError::new(EDIT_TOO_BIG)); } } From c65dda18acc4dc3bd9c630ecdc5d95674776a687 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sun, 26 Jul 2026 01:11:58 +0200 Subject: [PATCH 11/11] editor: branch review cleanups -- stale comments, redo survives crash recovery Full review of the wire-selection-fixes branch after the copy/paste and recovery churn. Code paths came out coherent (no dead code, no dangling references to the removed restart machinery); fixes for what the churn left behind: - Comments that described superseded designs: inserted_bytes and BODY_EDIT_SLACK still talked about realloc-or-fresh-instance decisions that no longer exist (wasm streams past-slack growth; the only pre-emption is the impossible-body refusal), and the wire rider pass in plan_move_actors claimed wires aren't selectable (they are -- named wires are skipped above it). - The redo stack now survives the crash-recovery backstop: the failed edit changed nothing, so previously undone edits stay redoable (the reload used to wipe them; the incomplete-recovery path still drops them deliberately). Suites re-run green: editor_duplicate, backstop recovery e2e (incl. undo/redo after), editor e2e. Co-Authored-By: Claude Fable 5 --- map/static/map/editor.js | 2 ++ rust_parser/core/src/decompress.rs | 9 +++++---- rust_parser/core/src/editor/apply.rs | 20 ++++++++++---------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/map/static/map/editor.js b/map/static/map/editor.js index 45aba8e..fe0e41e 100644 --- a/map/static/map/editor.js +++ b/map/static/map/editor.js @@ -314,6 +314,7 @@ var EditorTool = (function() { SaveLoadFlow.setStatus(message + " — recovering (reloading save)…"); var backup = actions.slice(); var savedClipboard = clipboard; // survives the reload: same save, same names + var savedRedo = redoStack.slice(); // the failed edit changed nothing, so undone edits stay redoable SaveClient.reset(); SaveLoadFlow.reloadCurrentFile() // resets EditorTool via onSaveLoaded .then(function() { @@ -323,6 +324,7 @@ var EditorTool = (function() { .then(function() { recovering = false; applyInFlight = false; + redoStack = savedRedo; SaveLoadFlow.hideBusy(); SaveLoadFlow.setStatus(message + " — recovered; your " + actions.length + " earlier edit" + (actions.length === 1 ? " was" : "s were") + " re-applied."); diff --git a/rust_parser/core/src/decompress.rs b/rust_parser/core/src/decompress.rs index ce68031..281185b 100644 --- a/rust_parser/core/src/decompress.rs +++ b/rust_parser/core/src/decompress.rs @@ -24,9 +24,9 @@ struct Chunk { /// contiguous block lives while the first is copied out -- ~2x the body /// transiently, which is exactly what trapped the 4GB-capped wasm build when /// copying on a save with a 1.3GB body. 64MB covers ~50k copied objects; -/// pastes larger than the slack fall back to the reallocation (and may still -/// OOM on huge saves in the browser -- the desktop app is uncapped). Also -/// covers the 4-byte "Missing final array count" quirk pad. +/// past the slack, wasm builds rebuild through apply_plan's compressed +/// snapshot (~1x peak) and native builds take the plain realloc (plenty of +/// RAM). Also covers the 4-byte "Missing final array count" quirk pad. pub const BODY_EDIT_SLACK: usize = 64 << 20; /// Mirrors decompressSaveFile(offset, data) including its confirm checks. @@ -91,7 +91,8 @@ pub fn decompress_save_file( // panics with "capacity overflow" (an uncatchable wasm trap -> the app // showed a raw "unreachable") the moment the body exceeds ~2.14GB. Return // the actionable error instead -- the desktop app has no such limit. The - // +8 leaves room for the editor's quirk-padding append. + // BODY_EDIT_SLACK term keeps the allocation below (body + edit headroom) + // inside the same bound. #[cfg(target_pointer_width = "32")] if total_uncomp as u64 + BODY_EDIT_SLACK as u64 > isize::MAX as u64 { return Err(perr!( diff --git a/rust_parser/core/src/editor/apply.rs b/rust_parser/core/src/editor/apply.rs index 5513dc0..9958ee8 100644 --- a/rust_parser/core/src/editor/apply.rs +++ b/rust_parser/core/src/editor/apply.rs @@ -29,11 +29,10 @@ impl EditPlan { self.patches.push((at, bytes.into())); } - /// Total bytes the plan's inserts add to the body. The wasm session - /// compares this against the body's spare capacity to decide whether an - /// edit should be applied in a fresh instance instead (wasm linear - /// memory never shrinks, so growing past capacity in a long-lived - /// instance risks tripping the 4GB ceiling on heap-layout luck). + /// Total bytes the plan's inserts add to the body. Feeds + /// session::planned_growth, whose sum backs the wasm session's + /// too-big-edit refusal (a grown body that could never exist under the + /// 4GiB ceiling is refused up front; see grown_body_can_exist). pub fn inserted_bytes(&self) -> usize { self.inserts.iter().map(|(_, b)| b.len()).sum() } @@ -148,9 +147,9 @@ pub fn apply_plan(body: &mut Vec, mut plan: EditPlan) -> PResult<()> { let added: usize = plan.inserts.iter().map(|(_, b)| b.len()).sum(); let old_len = body.len(); // Usually a no-op: bodies are allocated with BODY_EDIT_SLACK spare - // capacity so growth stays in place. If `added` exceeds the slack - // this reallocates -- transiently 2x the body, which a 4GB-capped - // wasm heap may not survive on GB-scale saves. + // capacity so growth stays in place. Beyond the slack this + // reallocates (transiently ~2x the body) -- native builds only; on + // wasm the streamed dispatch above never lets it get here. body.reserve_exact(added); body.resize(old_len + added, 0); // Shift the pre-existing segments right-to-left so nothing is @@ -627,8 +626,9 @@ fn plan_move_actors( move_one(li, oi, &object)?; } - // Wires whose BOTH endpoint owners moved follow along rigidly (wires - // aren't map-selectable, so they never appear in `names` themselves). + // Wires whose BOTH endpoint owners moved follow along rigidly (a wire + // named in `names` was skipped above, so it moves exactly here or not + // at all). let owner_moved = |endpoint: &ObjectRef| -> bool { if endpoint.path_name.is_empty() { return false;