Skip to content

Archive saves: compress a save, verify it, then remove the folder - #268

Open
wispborne wants to merge 2 commits into
mainfrom
claude/fervent-dijkstra-xsam2t
Open

wispborne wants to merge 2 commits into
mainfrom
claude/fervent-dijkstra-xsam2t

Conversation

@wispborne

@wispborne wispborne commented Sep 21, 2026

Copy link
Copy Markdown
Owner

What this does

Starsector saves are big and they pile up. A long modded campaign runs to hundreds of megabytes, and the game writes a new folder every fresh run. People end up with a dozen campaigns they aren't playing, a load list they scroll through, and tens of gigabytes gone.

This adds archiving: a save folder is compressed into a single .7z beside the saves folder, and the folder is removed. Archived saves are listed in a dialog and can be put back at any time.

Archived saves list

Two ways in, because the two jobs are different:

  • One at a time — an archive button on each save card in the Mod Profiles page. One confirmation, then it runs.
  • In bulk — "Archive saves" opens every save with a checkbox and a number at the top: keep the newest N. Changing the number reticks the list; every tick can then be changed by hand. Only what's ticked gets archived.

Picking saves to archive

"Restore saves" is the same dialog pointed the other way. An archive whose save folder name is already taken can't be ticked, and says why:

Picking saves to restore

A batch runs one job at a time, showing the step each one is on. "Stop after this one" skips the rest but never interrupts the job in flight:

A batch running

Each row ends up with its own outcome, so a partial failure is legible rather than one blanket error:

A batch finished

The safety rule

Everything here is built around one rule: a save folder is only ever removed once there is an archive that provably holds every byte of it. "Provably", in order:

  1. The archive passes 7-Zip's own integrity check.
  2. Every file in the folder is found in the archive, at the same size.
  3. Every one of those matches by CRC32 checksum — computed from the file on disk, compared against what 7-Zip recorded.

Only then does the folder go, and it goes to the recycle bin where the system allows it.

Supporting rules:

  • The archive is written under a .part name and only renamed into place once verified, so a crash halfway can't leave a file that looks finished. The .part file is deleted on every exit path.
  • Nothing on disk is ever overwritten. An existing archive name gets (2); an existing save folder blocks a restore outright.
  • Only a folder sitting directly inside the saves folder can be removed — checked before anything is read or written.
  • A folder holding a symlink or shortcut is refused, since there's no way to check one against an archive.
  • A remover that quietly does nothing is not reported as success.
  • Refuses while the game is running, checked before every job rather than once per dialog — a batch takes minutes, and somebody can start the game mid-way.

Restoring is as careful: unpack into a temporary folder, check what came out against what the archive says it holds, recheck the target name is still free, then move into place. A move that fails partway has its half-copied folder taken back out. The archive is kept unless you tick the box, and that box is off by default.

Any failure at any step leaves both copies exactly where they were, with a plain-English reason.

Tests

109 new tests, all green (suite: 921 → 1030).

  • save_archiver_test.dart (40) — a stand-in archiver that can be told to fail in one exact way: writes nothing, throws, writes a damaged file, fails its integrity check, loses one entry from the listing, reports a size one byte off, reports a wrong checksum, reports no checksum, unpacks the wrong bytes, loses a file on the way out. Each one asserts the save folder survived. Plus the guards: a folder outside the saves folder, a nested folder, the saves folder itself, an empty folder, a symlink, an archive folder inside the save.
  • save_archiver_seven_zip_test.dart (10) — the real bundled 7-Zip, end to end. Round trip compared byte for byte with an empty file, a non-ASCII filename, 200 KB of random bytes and a nested folder; a bit-flipped archive; a truncated archive; two saves of the same name; reading the descriptor straight out of an archive. Skips cleanly if there's no binary for the platform, so it runs for real on CI.
  • seven_zip_listing_test.dart (12) — the 7z l -slt parser, including Windows-shaped output and the archive's own filename never being read as an entry.
  • archived_save_store_test.dart (16) — the note beside an archive, its fallbacks, sorting, and an archive that isn't an archive at all.
  • save_archive_selection_test.dart (14) — the keep-newest-N arithmetic.
  • bulk_save_selection_dialog_test.dart (14) — that the ticks somebody is looking at are exactly what gets acted on. This one caught a real layout overflow, now fixed.
  • save_archive_runner_dialog_test.dart (7) — one job at a time, "stop" never interrupting the job in flight, and the dialog not closing mid-run.

The screenshots above come from screenshots_test.dart, which renders each dialog through the golden mechanism with the repo's own Roboto and the Material icon font loaded. It's tagged local-only so CI leaves it alone — golden images differ between machines. To redraw them after a UI change:

flutter test test/save_archiver/screenshots_test.dart --update-goldens

Making them turned up a real bug: the size summary used , which the Roboto in assets/fonts has no glyph for, so anyone on a theme using that font saw an empty box. It now reads "412 MB saved as 38 MB".

Changes to existing code

  • SevenZip gains listEntriesWithDetails (sizes and checksums; listFiles only returns paths, which isn't enough to tell a complete archive from a truncated one) and createArchiveFromFolder.
  • save_reader.dart: parseSaveDescriptor split out of SaveFileNotifier.readSave, so a descriptor read out of an archive is parsed by the same code as one read off disk. Body reindents, hence the large diff on that file.
  • Three new settings: the archive folder path, whether it's custom, and the remembered keep-newest number.
  • Constants.savesArchiveFolderName = TriOS_Save_Archives.

Notes

  • Archives go beside the saves folder, not inside it — the game scans the saves folder for folders, and there's no reason to make it look at ours. Changeable from the archive dialog.
  • The note beside each archive (<archive>.7z.info.json) holds the character name, date and mod list so the list draws without unpacking. It's a convenience, never a source of truth: it falls back to reading descriptor.xml out of the archive, and failing that still lists the archive so it can be restored (the old_campaign_backup row in the first screenshot).
  • Checkboxes render as filled squares with no tick because ThemeManager sets checkColor: Colors.transparent app-wide — that's existing behaviour, not something this PR introduces.
  • OpenSpec change added at openspec/changes/archive-saves/. Two boxes in tasks.md are unticked deliberately — hands-on checks on a real install, which I can't do from here: archive a save, confirm it's gone from the game's load list, restore it, load it; and confirm the archive folder lands correctly on all three platforms, including with a custom saves path.
  • changelog.md untouched, per CLAUDE.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VmU5sKTjNNKJ3TZutfCBzi

Starsector saves are big and they pile up. Archiving compresses a save
folder into a single .7z beside the saves folder and removes the folder,
which keeps the game's load list short and frees up disk space. Archived
saves are listed in a dialog and can be put back at any time.

Two ways in: an archive button on each save card for one-offs, and bulk
dialogs that start from "keep the newest N" and let every tick be changed
by hand. Restoring uses the same dialog pointed the other way.

The whole thing is built around one rule: a save folder is only ever
removed once there is an archive that provably holds every byte of it.
The archive passes 7-Zip's integrity check, then every file in the folder
is matched against the archive by name, size, and CRC32 checksum. The
archive is written under a .part name and only renamed into place once it
has passed, so a crash halfway cannot leave a file that looks finished.
Removal goes to the recycle bin. Any failure at any step leaves both
copies where they were and says what went wrong in plain words.

Restoring is as careful: it unpacks into a temporary folder, checks what
came out against what the archive says it holds, rechecks that the target
name is still free, and only then moves it into place. It never writes
over a save folder that is already there, and it keeps the archive unless
asked otherwise.

Also refuses to touch anything that is not a folder sitting directly
inside the saves folder, and refuses outright while the game is running.

Tested against a stand-in archiver that can be told to fail in one exact
way — writes nothing, writes a damaged file, loses an entry, reports a
wrong size or checksum, unpacks the wrong bytes — checking every time
that the save folder survived. Plus a full round trip against the real
7-Zip binary, byte for byte, with an empty file, a non-ASCII filename,
random bytes, and nested folders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmU5sKTjNNKJ3TZutfCBzi
Renders each of the save-archiver dialogs to a PNG for the README and for
showing the UI on a pull request. The tool is a widget test that loads the
repo's own Roboto and the Material icon font so text and icons draw
properly, builds the app's theme, and writes the images through the golden
mechanism. It is tagged local-only: golden images differ between machines,
so CI leaves it alone.

Making the images turned up two things. The size summary used "→", which
the Roboto in assets/fonts has no glyph for, so anyone on a theme using
that font saw an empty box; it now reads "412 MB saved as 38 MB". And the
picker's footer row overflowed at dialog width, which the widget test
added earlier caught — the count now takes the remaining space and
ellipsises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmU5sKTjNNKJ3TZutfCBzi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants