Skip to content

editor: hypertube entrances selectable again; power lines selectable as riders - #11

Merged
valentinps merged 11 commits into
mainfrom
wire-selection-fixes
Jul 26, 2026
Merged

editor: hypertube entrances selectable again; power lines selectable as riders#11
valentinps merged 11 commits into
mainfrom
wire-selection-fixes

Conversation

@valentinps

@valentinps valentinps commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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_C sat in LINE_RENDERED_TYPE_PATHS, but an entrance with its default tube shape serializes no mSplineData -- 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:

  • move: skipped (a wire moves only when both endpoint owners move)
  • copy/clipboard: named wires without both owners in the set are pruned (their copies would dangle)
  • delete: kept (that IS the single-wire delete)

Test plan

  • Reveal save (bug report): 36 entrances selectable; select-all copy/paste 33 -> 66 wires; single-wire delete 66 -> 65 (all in the real browser UI)
  • New regression test 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)
  • Full cargo test -p sav_core --release green (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 with unreachable applies 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_edits now 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.

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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 25, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
satisfactorymap 06b44d5 Jul 25 2026, 08:50 PM

valentinps and others added 10 commits July 25, 2026 20:13
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>
@valentinps
valentinps merged commit fcb467d into main Jul 26, 2026
2 checks passed
@valentinps
valentinps deleted the wire-selection-fixes branch July 28, 2026 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant