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 4094679..65893f9 100644 --- a/docs/release-notes-0.1.7.md +++ b/docs/release-notes-0.1.7.md @@ -19,6 +19,36 @@ 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. Small and medium copies now grow the + save in place (built-in headroom); bigger ones rebuild through a + 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 + 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. 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 + 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/editor.js b/map/static/map/editor.js index ca00651..fe0e41e 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 --------------------------------------------------------------- @@ -257,9 +264,17 @@ var EditorTool = (function() { } function failApply(error) { - var message = "Edit failed: " + (error && error.message || error); - // Semantic refusals (uneditable object, unknown name, ...) leave the - // session intact: just report them. + 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"; + } + 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(); @@ -268,17 +283,19 @@ 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); } 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(); @@ -289,19 +306,25 @@ 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(); 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() { clipboard = savedClipboard; - return replaySequentially(backup, 0); + return replayAll(backup, savedClipboard); }) .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."); @@ -324,12 +347,44 @@ 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(); } SaveLoadFlow.showBusy("Recovering — re-applying edit " + (i + 1) + " of " + backup.length + "…"); - return SaveClient.applyEdits(backup[i], false, editProgress).then(function(payload) { + // 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); actions.push(backup[i]); @@ -458,47 +513,100 @@ 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 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 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 + // 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; var n = targets.actorNames.length + targets.lightweight.length; - SaveLoadFlow.setStatus("Copied " + n.toLocaleString() + " object" + (n === 1 ? "" : "s") - + " — Ctrl+V or right-click to paste."); - // 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"); - } + var label = n.toLocaleString() + " object" + (n === 1 ? "" : "s"); + 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 + // 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: @@ -516,34 +624,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; }); } @@ -1086,6 +1210,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(); diff --git a/map/static/map/save_client.js b/map/static/map/save_client.js index 95002d4..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); } @@ -443,14 +446,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 growth dry-run refusal (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/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/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/decompress.rs b/rust_parser/core/src/decompress.rs index 2f5c8c3..281185b 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; +/// 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. /// `progress(bytes_of_file_consumed, total_file_bytes)` fires as chunks finish. pub fn decompress_save_file( @@ -80,16 +91,18 @@ 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 + 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 06fd203..9958ee8 100644 --- a/rust_parser/core/src/editor/apply.rs +++ b/rust_parser/core/src/editor/apply.rs @@ -28,25 +28,32 @@ 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. 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() + } } -/// 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 +67,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 { @@ -113,6 +146,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. 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 @@ -132,6 +169,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 // --------------------------------------------------------------------------- @@ -494,6 +607,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)); @@ -502,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; @@ -604,14 +729,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 +792,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 +802,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 +837,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 +862,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 +1106,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")); } @@ -1250,3 +1407,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"); + } +} 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/editor/session.rs b/rust_parser/core/src/editor/session.rs index de752ef..81133de 100644 --- a/rust_parser/core/src/editor/session.rs +++ b/rust_parser/core/src/editor/session.rs @@ -112,6 +112,47 @@ 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 +/// 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() { + 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) +} + +/// 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 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 /// ~1/15th-size zlib instead of a full second body). Returns (zlib bytes, /// raw length). @@ -124,7 +165,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/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..a923c44 100644 --- a/rust_parser/core/tests/editor_duplicate.rs +++ b/rust_parser/core/tests/editor_duplicate.rs @@ -281,3 +281,195 @@ 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); +} + +/// 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" + ); +} + +/// 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()); +} + +/// 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 restart_is_reserved_for_impossible_bodies() { + let gb = |n: u64| n << 30; + // 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::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 05cdbf9..f54f6b2 100644 --- a/rust_parser/wasm/src/lib.rs +++ b/rust_parser/wasm/src/lib.rs @@ -138,6 +138,13 @@ pub struct SaveSession { const SESSION_LOST: &str = "No usable save state (a failed edit was not recovered) -- reload the save file"; +/// 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> { self.store.as_ref().map(|a| a.as_ref()).ok_or_else(|| JsError::new(SESSION_LOST)) @@ -301,6 +308,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| { @@ -321,15 +329,32 @@ 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. - drop( - sav_core::editor::apply::plan_op(self.store()?, &new_ops[0]) - .map_err(|e| JsError::new(&e.msg))?, - ); + // Growth beyond the body's spare capacity is applied HERE too: + // apply_plan's streamed fallback rebuilds through a compressed + // 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( + 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))?; + if !session::grown_body_can_exist(store.data.len() as u64, growth as u64) { + return Err(JsError::new(EDIT_TOO_BIG)); + } + } // Free the index and stale payload up front -- everything below is // memory-critical on 600k-object saves.