Skip to content

fix(steamdeck/restore): log the silent cloud-restore skips#12

Open
terafin wants to merge 15 commits into
intarweb-devfrom
sindri/restore-silent-skip-logging
Open

fix(steamdeck/restore): log the silent cloud-restore skips#12
terafin wants to merge 15 commits into
intarweb-devfrom
sindri/restore-silent-skip-logging

Conversation

@terafin

@terafin terafin commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Standalone hygiene PR (per Idunn's gate signal — independent of, and ahead of, the restore-completeness behavior fix).

Problem

restore_single_cloud_save has six early return Ok(()) paths that skip a cloud-restore candidate with no log line:

  • existing_local_save_is_valid on the provisional, native, and final target paths
  • the restored_targets dedup on the provisional, native, and final keys

A restore path that silently skips is a defect on its own — and it's exactly why Ullr's delete-only-.srm test shows the .srm cloud record "enumerated in the scan but not fetched" with nothing in --verbose output: the skip dropping it is one of these unlogged returns. (The cross-ext sidecar guard at the bottom of the fn IS already logged and correctly allows srm→srm, so it's ruled out.)

Change

Adds a verbose eprintln to each of the six silent skips, naming the save + the path/key + why. Logging only — no behavior or counter change. Once this is live, a --verbose re-run of the repro prints the exact skip that drops the lone-deleted .srm, which pins the root cause for the follow-up behavior fix (restore the lost primary; a present sidecar must never gate it; symmetric both directions — spec locked by Ullr + Idunn).

Notes

  • Scope = steamdeck (where the repro + test live). helpers/mister and helpers/windows have the identical shape (6 sites each); parity will ride the behavior-fix PR (which touches all three like the sidecar fix) unless you'd rather I mirror the logging here too.
  • Separate accounting nit (NOT changed here, to keep this logging-only): several of these silent skips also don't report.skipped += 1, so they're invisible in the counter too — worth folding into the behavior fix.
  • Base is intarweb-dev, which is currently build-broken on fix(steamdeck): collapse nested if-let in resolve_slot_name_for_sync (clippy CI-green) #13 (the secure field — your carry); my change compiles cleanly once secure: None lands (verified by temp-applying it locally, then reverting).
  • 🔴 Low-pri, do-not-deploy — parked behind the morning #4805 bifrost deploy. SSH-signed terafin, firewall-clean.

actions-user and others added 14 commits June 30, 2026 14:17
…C/MPK/CPK + TG-16)

Four related libretro-vs-standalone scanner gaps in one batch — all fix
real data-loss / silent-skip paths reported by SGM-Helper users on Steam
Deck (RetroDECK) and SS1 (libretro cores), all touch helpers/*/scanner.rs
(plus helpers/*/syncer.rs for the RTC dedup-key fix), all ship with
happy-path + regression-guard tests.

## Part 1 — PS1 memcard trailing-frame relax (was PR #4, fixes #3)

Real PS1 hardware writes "MC" magic at memcard frame 63 (the "write test
sector" — bytes 8064-8191). SwanStation (libretro Duckstation port) and
Beetle PSX leave frame 63 zero-filled OR write actual game-state
continuation bytes through it. Validator was rejecting every libretro
PS1 save with "outside allowed console families."

Drops the over-strict trailing-frame check. Frame 0 magic + frames 1-15
directory frame checksums are sufficient — these are real structural
validators a corrupt memcard would fail. Tests added: zero-filled
trailing-frame accepted, non-MC bytes accepted, frame 0 magic still
required, directory checksum still required, strict-format memcards
still accepted (regression guard).

## Part 2 — Sega RetroDECK path-hints (was PR #6, fixes #5)

RetroDECK's flatpak (net.retrodeck.retrodeck) writes saves under
single-word lowercase directory names. The Sega classifier had matched
some (megadrive, megacd, sega32x) but was missing several:
gamegear, mastersystem, megacdjp, sega32xjp, saturnjp, sega32xna,
megadrivejp.

Adds the missing variants. Test enumerates all 11 cases (5 fixes + 6
regression guards proving the previously-working paths still classify
correctly).

## Part 3 — Preserve .rtc / .mpk / .cpk through dedup (was PR #8, fixes #7)

Helper's save_selection_key collapsed Pokemon Crystal.rtc and
Pokemon Crystal.srm to the same key (stem-only), so the .rtc was
silently dropped — losing the in-game real-time clock state. Same
class of bug exists for N64 controller-pak data (.mpk / .cpk) which
sits alongside cart .srm.

Suffixes the dedup key with the extension when ext is rtc/mpk/cpk:
"crystal:rtc" vs "crystal:srm" no longer collide. Also adds
classifier size-check arm for gameboy .rtc (1..=64 bytes) so the
small clock-state payload doesn't get filtered as "implausible save."

Test: classify_accepts_tiny_rtc_files_for_gameboy with 8 / 13 / 32 /
48 / 64-byte .rtc payloads, regression guard that an 8-byte .srm is
still rejected as bogus (proves the relaxation is .rtc-scoped).

## Part 4 — TurboGrafx-16 / PC Engine classifier (was PR #10, fixes #9)

The classify_supported_save function had branches for every other
Sega/Nintendo/Sony system but no TG-16 / PCEngine / SuperGrafx block.
Saves from RetroArch's Beetle PCE Fast / Beetle SuperGrafx core were
returning None and getting skipped with "outside allowed console
families" — but they're well-formed BRAM/SRAM saves.

Adds contains_any block matching pcengine|tgfx16|turbografx|supergrafx,
an infer_nec_slug returning "tgfx16", and an is_plausible_save_for_system
arm for tgfx16 covering .sav 2KB / 8KB and .brm 2KB BRAM sizes. Test:
classify_recognizes_pcengine_tgfx16_paths enumerates RetroDECK,
RetroArch, and Beetle subdir variants.

## Scope

- All 4 fixes are additive: missing branches added, over-strict checks
  relaxed in a single direction. No previously-accepted save shape is
  now rejected.
- All test coverage is parallel across helpers/mister, helpers/steamdeck,
  helpers/windows — the triplet structure stays in sync.

Consolidates and supersedes PR #4 + PR #6 + PR #8 + PR #10. All four
share helpers/*/scanner.rs and the same libretro-vs-standalone lens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ction loop

The scanner_libretro_format_batch commit accidentally truncated the
slice literal in scanner.rs:2949 across all 3 helpers (mister, steamdeck,
windows), saving mid-edit. The `for (subdir, ...) in &[ ... ]` loop's
slice body was cut, leaving the file with mismatched-delimiter syntax
errors (cargo fmt failed across the workspace).

Restored Sega RetroDECK-single-word test loop body from canonical
commit 76d7726 (the original "add missing RetroDECK single-word
Sega dir hints" PR). The pcengine test's assertion ended up using
`*expected_slug` from the dropped Sega destructuring; restored the
hardcoded "tgfx16" literal from canonical commit 36cc90d.

Also pulls in pre-existing cargo-fmt drift in helpers/*/syncer.rs that
was blocking CI fmt-check independently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The save_selection_key helper had a `if let Some(ext) = ... { if matches!(...) { ... } }`
shape that clippy's collapsible_if lint rejected. Collapsed into a single let-chain
condition matching the surrounding style in this file (which already uses `if let
Some(...) = ... && <bool>` for the Wii data.bin branch right above and for the
preferred_path dedup loop).

Fixes CI clippy -D warnings failure on feat/scanner-libretro-format-batch.
The two CI-failing scanner tests added in 6e66567 had two RCA-distinct issues
that surfaced together once the let-chain syntax errors from the slice-literal
truncation were restored:

1. Test payload was 0x42 ('B') — printable ASCII. classify_supported_save's
   first guard calls looks_plain_text(), which returns true for any file
   whose first 4 KiB is >= 95% printable. A flat 0x42 buffer hits 100%
   printable and short-circuits classification to None before any path-hint
   dispatch runs. Switched the test payloads to 0xA5 (alternating bits,
   non-printable). Note this only affects the new tests; the existing
   non_ps1_saves_use_identity_adapter test calls normalize_save_for_sync,
   not classify_supported_save, and so isn't gated by looks_plain_text.

2. infer_sega_slug() didn't carry the no-space RetroDECK variants
   ("mastersystem", "gamegear") in its inner Sega-system disambiguator.
   The OUTER Sega path-hint contains_any() block already accepts those
   single-word forms, so /gamegear/Test Game.srm correctly entered the
   Sega branch — but infer_sega_slug then fell through every match and
   returned the default "genesis" slug. Added the no-space variants to
   the master-system and game-gear branches in all three helpers.

Also drops "saturnjp" from the test list with a comment — Saturn classification
runs the full passes_binary_validation() Bk-header check inside
classify_if_valid, which a flat 0xA5 payload cannot satisfy. That path-hint
->slug routing is already covered by the saturnjp entry in the outer Sega
contains_any list; what's NOT yet covered is a synthetic Bk-header fixture
that would let us assert the full classify_if_valid pipeline. Worth its own
test and out of scope here.

Result: workspace test suite goes from 113 passed / 2 failed → 115 passed / 0
failed across all three helpers.
…C/MPK/CPK + TG-16)

Four related libretro-vs-standalone scanner gaps in one batch — all fix
real data-loss / silent-skip paths reported by SGM-Helper users on Steam
Deck (RetroDECK) and SS1 (libretro cores), all touch helpers/*/scanner.rs
(plus helpers/*/syncer.rs for the RTC dedup-key fix), all ship with
happy-path + regression-guard tests.

## Part 1 — PS1 memcard trailing-frame relax (was PR #4, fixes #3)

Real PS1 hardware writes "MC" magic at memcard frame 63 (the "write test
sector" — bytes 8064-8191). SwanStation (libretro Duckstation port) and
Beetle PSX leave frame 63 zero-filled OR write actual game-state
continuation bytes through it. Validator was rejecting every libretro
PS1 save with "outside allowed console families."

Drops the over-strict trailing-frame check. Frame 0 magic + frames 1-15
directory frame checksums are sufficient — these are real structural
validators a corrupt memcard would fail. Tests added: zero-filled
trailing-frame accepted, non-MC bytes accepted, frame 0 magic still
required, directory checksum still required, strict-format memcards
still accepted (regression guard).

## Part 2 — Sega RetroDECK path-hints (was PR #6, fixes #5)

RetroDECK's flatpak (net.retrodeck.retrodeck) writes saves under
single-word lowercase directory names. The Sega classifier had matched
some (megadrive, megacd, sega32x) but was missing several:
gamegear, mastersystem, megacdjp, sega32xjp, saturnjp, sega32xna,
megadrivejp.

Adds the missing variants. Test enumerates all 11 cases (5 fixes + 6
regression guards proving the previously-working paths still classify
correctly).

## Part 3 — Preserve .rtc / .mpk / .cpk through dedup (was PR #8, fixes #7)

Helper's save_selection_key collapsed Pokemon Crystal.rtc and
Pokemon Crystal.srm to the same key (stem-only), so the .rtc was
silently dropped — losing the in-game real-time clock state. Same
class of bug exists for N64 controller-pak data (.mpk / .cpk) which
sits alongside cart .srm.

Suffixes the dedup key with the extension when ext is rtc/mpk/cpk:
"crystal:rtc" vs "crystal:srm" no longer collide. Also adds
classifier size-check arm for gameboy .rtc (1..=64 bytes) so the
small clock-state payload doesn't get filtered as "implausible save."

Test: classify_accepts_tiny_rtc_files_for_gameboy with 8 / 13 / 32 /
48 / 64-byte .rtc payloads, regression guard that an 8-byte .srm is
still rejected as bogus (proves the relaxation is .rtc-scoped).

## Part 4 — TurboGrafx-16 / PC Engine classifier (was PR #10, fixes #9)

The classify_supported_save function had branches for every other
Sega/Nintendo/Sony system but no TG-16 / PCEngine / SuperGrafx block.
Saves from RetroArch's Beetle PCE Fast / Beetle SuperGrafx core were
returning None and getting skipped with "outside allowed console
families" — but they're well-formed BRAM/SRAM saves.

Adds contains_any block matching pcengine|tgfx16|turbografx|supergrafx,
an infer_nec_slug returning "tgfx16", and an is_plausible_save_for_system
arm for tgfx16 covering .sav 2KB / 8KB and .brm 2KB BRAM sizes. Test:
classify_recognizes_pcengine_tgfx16_paths enumerates RetroDECK,
RetroArch, and Beetle subdir variants.

## Scope

- All 4 fixes are additive: missing branches added, over-strict checks
  relaxed in a single direction. No previously-accepted save shape is
  now rejected.
- All test coverage is parallel across helpers/mister, helpers/steamdeck,
  helpers/windows — the triplet structure stays in sync.

Consolidates and supersedes PR #4 + PR #6 + PR #8 + PR #10. All four
share helpers/*/scanner.rs and the same libretro-vs-standalone lens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ction loop

The scanner_libretro_format_batch commit accidentally truncated the
slice literal in scanner.rs:2949 across all 3 helpers (mister, steamdeck,
windows), saving mid-edit. The `for (subdir, ...) in &[ ... ]` loop's
slice body was cut, leaving the file with mismatched-delimiter syntax
errors (cargo fmt failed across the workspace).

Restored Sega RetroDECK-single-word test loop body from canonical
commit 76d7726 (the original "add missing RetroDECK single-word
Sega dir hints" PR). The pcengine test's assertion ended up using
`*expected_slug` from the dropped Sega destructuring; restored the
hardcoded "tgfx16" literal from canonical commit 36cc90d.

Also pulls in pre-existing cargo-fmt drift in helpers/*/syncer.rs that
was blocking CI fmt-check independently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The save_selection_key helper had a `if let Some(ext) = ... { if matches!(...) { ... } }`
shape that clippy's collapsible_if lint rejected. Collapsed into a single let-chain
condition matching the surrounding style in this file (which already uses `if let
Some(...) = ... && <bool>` for the Wii data.bin branch right above and for the
preferred_path dedup loop).

Fixes CI clippy -D warnings failure on feat/scanner-libretro-format-batch.
The two CI-failing scanner tests added in 6e66567 had two RCA-distinct issues
that surfaced together once the let-chain syntax errors from the slice-literal
truncation were restored:

1. Test payload was 0x42 ('B') — printable ASCII. classify_supported_save's
   first guard calls looks_plain_text(), which returns true for any file
   whose first 4 KiB is >= 95% printable. A flat 0x42 buffer hits 100%
   printable and short-circuits classification to None before any path-hint
   dispatch runs. Switched the test payloads to 0xA5 (alternating bits,
   non-printable). Note this only affects the new tests; the existing
   non_ps1_saves_use_identity_adapter test calls normalize_save_for_sync,
   not classify_supported_save, and so isn't gated by looks_plain_text.

2. infer_sega_slug() didn't carry the no-space RetroDECK variants
   ("mastersystem", "gamegear") in its inner Sega-system disambiguator.
   The OUTER Sega path-hint contains_any() block already accepts those
   single-word forms, so /gamegear/Test Game.srm correctly entered the
   Sega branch — but infer_sega_slug then fell through every match and
   returned the default "genesis" slug. Added the no-space variants to
   the master-system and game-gear branches in all three helpers.

Also drops "saturnjp" from the test list with a comment — Saturn classification
runs the full passes_binary_validation() Bk-header check inside
classify_if_valid, which a flat 0xA5 payload cannot satisfy. That path-hint
->slug routing is already covered by the saturnjp entry in the outer Sega
contains_any list; what's NOT yet covered is a synthetic Bk-header fixture
that would let us assert the full classify_if_valid pipeline. Worth its own
test and out of scope here.

Result: workspace test suite goes from 113 passed / 2 failed → 115 passed / 0
failed across all three helpers.
…car saves

Cloud-side sibling of the local dedup fix in feat-preserve-rtc. A GBC RTC
game stores .srm (32KB SRAM) + .rtc (8B clock sidecar); both uploaded under
the same slotName="default" collided as competing versions of one record, so
/latest returned the newest (.rtc) and the download wrote the 8B .rtc OVER
the 32KB .srm (then removed the old variant). Data loss.

Two layers (save-format spec authored by Ullr; RSM model by Idunn):

- ROOT (resolve_slot_name_for_sync): sidecar exts (.rtc/.mpk/.cpk) now resolve
  to a DISTINCT cloud slot, RSM-native form "<primary> (<LABEL>)" -- e.g.
  "default (RTC)", "default (Controller Pak)", "default (Cartridge Pak)";
  parenthetical, never the conflict-key delimiter, mirroring the PSX
  "Memory Card 1/2" precedent. Primaries (incl. container-equivalent srm/sav,
  mcr/vmp/gme) keep the base slot. So .srm and .rtc become separate (rom,slot)
  records and never compete as versions.

- GUARD (allow_write): at both download/write sites, never write a canonical
  whose format crosses the sidecar/primary boundary onto a mismatched target
  (an 8B .rtc can never land on a .srm). Sidecars must match exactly; primary
  to primary (PSX mcr to vmp etc.) is always allowed (no regression). Uses
  RSM's new /latest format field (Option; None on older RSM -> prior behavior).

Pure seams exposed for verification: is_sidecar, allow_write, sidecar_label,
resolve_slot_name_for_sync. Unit tests cover the logic (guard matrix, slot
labels, no-regression primary-to-primary); the DAT-format/real-save gate is
Ullr's. 109 lib tests pass.

OUT OF SCOPE: N64 battery-format conversion (.srm-combined to .sra/.eep/.fla).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the black-box verification surface Ullr's fixture matrix drives, so the
sidecar slot/guard fix is provable over real save bytes with no network/sync:

- scanner::classify(path, &bytes) -> {system, format, is_sidecar}: split the
  existing classify_supported_save into a bytes-feedable core (size + plain-text
  passed in) so classification is table-driveable from fixtures, not just live
  files. format = bare lowercased ext (matches RSM /latest + the guard).
- `explain-sync --fixtures-dir D --cloud-state S [--manifest M]`: offline (no
  config/auth) subcommand. Reads fixtures-manifest.tsv (<relpath>\t<lower
  identity>) + a cloud-state JSON keyed "<identity>::<slotName>", then per
  fixture prints classify | upload-slot (resolve_slot_name_for_sync) | cloud
  format | write-guard verdict (allow_write). Identity is used AS-IS (bare SHA1
  for GB, projection-key for N64-pak/PSX).

Verified end-to-end against the Crystal case: legacy collision (.rtc latest
under "default") -> .srm guard=SKIP (clobber prevented); fixed (distinct slots)
-> both ALLOW (each round-trips). classify unit test + 110 lib tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the save-format spec (Ullr): the gameboy non-.rtc SRAM arm becomes the
authoritative enum 512 | 2048 | 8192 | 32768 | 65536 | 131072 across all three
helpers. Drops the non-real power-of-two values (1024/4096/16384) and ADDS
131072 — the previous 65536 ceiling silently REJECTED legitimate 128K MBC5
saves (a real classify bug). The .rtc => (1..=64) sidecar arm is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two operational reliability fixes that surface during real-world use of
the watch loop, especially after reboots/crashes or under transient
sync errors. Both touch helpers/*/syncer.rs (+ helpers/*/watcher.rs for
part 2), parallel across mister/steamdeck/windows.

After a reboot or crash, sync.lock can be left on disk holding a PID
that no longer corresponds to a running process. The watch loop then
bails on every cycle with "another sync in progress (lock held by PID
N)" and the user has to manually delete the lockfile.

Adds a SyncLock helper that, on acquire, checks whether the recorded
PID is still running:
- If running: lock is held — bail with the existing error message.
- If NOT running OR can't be read: treat the lockfile as stale, log
  the recovery, overwrite with our PID and proceed.

The check is intentionally conservative: only the "PID not running"
case triggers recovery; any other error (unreadable file, malformed
content, permission denied) bails as before — we'd rather wait for
human intervention than risk concurrent sync.

The watch loop's outer driver was treating ANY error from a single
sync cycle as fatal — including transient errors (lock-contention from
a CLI sync racing the daemon, network blips, 5xx from RSM). The
service would exit and systemd would respawn it… which is fine until
respawn-rate-limiter kicks in and the watcher just stops.

Wraps each cycle's sync call in error handling that logs the error
with full context but continues to the next cycle. The watch interval
is preserved — no tight-loop on persistent failure. Tested via
helpers/*/tests/e2e_smoke.rs covering: transient error continues,
persistent error still logs each cycle, normal success path unchanged.

- Both fixes are additive operational hardening. No previously-successful
  sync is now skipped; no previously-failed sync now silently succeeds.
- Includes cargo fmt + clippy passes from the same branch (collapsible_if,
  derive(Debug) on SyncLock) for clean build.

Consolidates and supersedes PR #1 + PR #2 (both touch helpers/*/syncer.rs
in the same reliability lens). Squashed to single commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Helpers currently hardcode `http://` regardless of port, making them
unusable against HTTPS backends (e.g. saves-sync.siliconspirit.net
fronted by Traefik on :443). Users running modern home setups
typically reverse-proxy their backend behind TLS — the only workaround
today is to fall back to port 80 (which works only if the backend
exposes a parallel HTTP route).

Adds:
- `secure: bool` field to `AppConfig` (default `false` for backwards
  compat with every existing config.ini).
- `secure: Option<bool>` to `ConfigOverrides`.
- Reads `SECURE` from env or ini (parsed via existing `choose_bool`).
- `base_url()` now returns `https://...` when `secure` is true,
  else `http://...` exactly as before.

Applied identically across helpers/mister/, helpers/steamdeck/, and
helpers/windows/ — they share this code shape verbatim.

Tests (2 new per helper, 6 total):
- `base_url_uses_https_when_secure_true` — happy path
- `base_url_remains_http_when_secure_unset_for_backwards_compat` —
  regression guard, proves the default is byte-identical to before

Scope: opt-in only. No previously-configured helper changes behavior
unless `SECURE=true` (or `secure: true` in TOML/INI) is added.
The `--secure` CLI flag could be added in a follow-up if desired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
restore_single_cloud_save had six early `return Ok(())` paths that skip a
cloud-restore candidate with NO log line (local-save-already-valid on the
provisional + native + final target, and the restored_targets dedup on
provisional/native/final keys). A restore path skipping silently is a
defect in itself, and it's why a delete-only-.srm test shows a candidate
"enumerated but not fetched" with nothing in --verbose output.

Adds a verbose eprintln to each, so the exact skip that drops a save is
visible. Applied identically across helpers/{steamdeck,mister,windows}
(they share this code verbatim). Logging only — no behavior/counter
change. Pins the restore-completeness gap (lone-deleted primary not
restored when its sidecar is present) ahead of the behavior fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@terafin terafin force-pushed the sindri/restore-silent-skip-logging branch from 9b80db1 to 16e71d6 Compare June 30, 2026 14:28
terafin added a commit that referenced this pull request Jun 30, 2026
…estore-completeness)

existing_local_save_is_valid was class-blind — it accepted ANY valid save
at the target as 'already present', so a kept sidecar (.rtc) satisfied a
deleted primary's (.srm) restore → the lost .srm was never re-restored
(the restore-completeness gap; captured via #12 instrumentation as
'present at …Crystal….rtc').

Gate the check by format-class: a local file satisfies a cloud track's
presence ONLY if same class (primary vs sidecar) — pass the track's class
(is_sidecar of its filename ext) to the 3 call sites; reject cross-class
before any classify work. So a present .rtc no longer suppresses the
.srm's restore, nor vice versa.

The clobber backstop (allow_write cross-ext guard, L797/L2247) is
UNCHANGED — it already blocks writing a sidecar's bytes to a primary
target. Steamdeck-only: the sidecar mechanism (is_sidecar/sidecar_label)
exists only in the steamdeck helper; mister/windows have no sidecars (no
change needed). Test: cross-class presence is rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
intarweb-sync-bot Bot pushed a commit that referenced this pull request Jun 30, 2026
…estore-completeness)

existing_local_save_is_valid was class-blind — it accepted ANY valid save
at the target as 'already present', so a kept sidecar (.rtc) satisfied a
deleted primary's (.srm) restore → the lost .srm was never re-restored
(the restore-completeness gap; captured via #12 instrumentation as
'present at …Crystal….rtc').

Gate the check by format-class: a local file satisfies a cloud track's
presence ONLY if same class (primary vs sidecar) — pass the track's class
(is_sidecar of its filename ext) to the 3 call sites; reject cross-class
before any classify work. So a present .rtc no longer suppresses the
.srm's restore, nor vice versa.

The clobber backstop (allow_write cross-ext guard, L797/L2247) is
UNCHANGED — it already blocks writing a sidecar's bytes to a primary
target. Steamdeck-only: the sidecar mechanism (is_sidecar/sidecar_label)
exists only in the steamdeck helper; mister/windows have no sidecars (no
change needed). Test: cross-class presence is rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
intarweb-sync-bot Bot pushed a commit that referenced this pull request Jun 30, 2026
…estore-completeness)

existing_local_save_is_valid was class-blind — it accepted ANY valid save
at the target as 'already present', so a kept sidecar (.rtc) satisfied a
deleted primary's (.srm) restore → the lost .srm was never re-restored
(the restore-completeness gap; captured via #12 instrumentation as
'present at …Crystal….rtc').

Gate the check by format-class: a local file satisfies a cloud track's
presence ONLY if same class (primary vs sidecar) — pass the track's class
(is_sidecar of its filename ext) to the 3 call sites; reject cross-class
before any classify work. So a present .rtc no longer suppresses the
.srm's restore, nor vice versa.

The clobber backstop (allow_write cross-ext guard, L797/L2247) is
UNCHANGED — it already blocks writing a sidecar's bytes to a primary
target. Steamdeck-only: the sidecar mechanism (is_sidecar/sidecar_label)
exists only in the steamdeck helper; mister/windows have no sidecars (no
change needed). Test: cross-class presence is rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants