editor: hypertube entrances selectable again; power lines selectable as riders - #11
Merged
Conversation
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 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
satisfactorymap | 06b44d5 | Jul 25 2026, 08:50 PM |
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…eiling 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ly 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the reported bug: copying daisy-chained builds (1.2 machine-to-machine wiring, e.g. hypertube entrances chained with power lines) silently dropped the wires.
Root cause (older than #9, exposed by it):
Build_PipeHyperStart_Csat inLINE_RENDERED_TYPE_PATHS, but an entrance with its default tube shape serializes nomSplineData-- the line collector emitted nothing for it, leaving entrances in no bucket at all: invisible, unselectable, so the wire-rider expansion never saw their owners. Entrances are dot-rendered powered buildings again (their tube still draws when a custom spline exists).Also (as requested): power lines are fully selectable -- they highlight in box selections and support single-wire deletion. The engine treats explicitly selected wires as riders:
Test plan
explicit_wire_is_a_rider_not_a_copy_target(wire-only copy refused, wire+owners copies exactly one new wire, wire-only move is a no-op)cargo test -p sav_core --releasegreen (9 suites)🤖 Generated with Claude Code
Also in this PR: copy/paste on GB-scale saves no longer traps the wasm heap.
apply_plan's insert path reallocated the body (2x transient memory) on every copy; bodies now carry 64MB edit headroom so growth stays in place. Verified in-browser on the reported 1.3GB-body save: a 2,000-object copy that previously died withunreachableapplies cleanly (14,323 -> 16,323 objects), regression test asserts pointer stability.Follow-up (user limit-testing): pastes beyond the 64MB slack (100k+ objects) still hit the realloc. The wasm build now applies such plans by compressing the body (zlib-fast), freeing it, and streaming the new body out of the snapshot -- peak stays ~1x body for any paste size (user-verified: 100k-object copy applies in seconds on the 1.3GB-body save). Unit tests assert byte-identical output vs the in-place path. Browser copies >150k objects additionally skip the OS-clipboard cross-tab mirror (extracting + pushing a multi-hundred-MB string through the synchronous Windows clipboard stalls the whole machine); the status line points at the desktop app for giant cross-save pastes.
Follow-up 2 (user falsified the streamed fix): the same 78k-object paste trapped once and succeeded after recovery -- proof the streamed path only shrank the transient, while success still depended on heap layout (wasm linear memory never shrinks).
apply_editsnow pre-checks the first op's planned insert bytes against the body's spare capacity and, when exceeded, returns a marker with the session untouched; the client commits the edit and routes it through the existing fresh-worker recovery (reload + replay with force=true). Big pastes show 'Large edit -- rebuilding the session to fit it' and always land; small/medium edits keep the instant in-place path. User-verified end-to-end on the 1.3GB-body save.