docs: add ADR-0003 on save format, location and migration

This commit is contained in:
2026-08-10 13:46:03 +02:00
parent 1d8175cb5a
commit cb13adce33

View File

@@ -0,0 +1,304 @@
# ADR-0003: Save-Format, Speicherort und Migration
- **Status:** Angenommen
- **Datum:** 2026-08-10
- **Betrifft:** Persistenz des Meta-Fortschritts (Workshop, Cards, Loadout, Meta-Währung, Wellen-Rekord)
- **Baut auf:** ADR-0002 (nur Quellen werden persistiert; Ablageregel `Resource` vs. `Component`),
ADR-0001 (Balancing-Werte sind in die Binary gebacken)
## Kontext
ADR-0002 hat die schwierige Hälfte bereits entschieden: Ein Save enthält **niemals einen
berechneten Wert**, nur Quellen. Damit ist die Datenmenge klein, flach und formelunabhängig —
jede Formeländerung wirkt rückwirkend, ohne Migration. Offen bleiben Format, Ort, Migrationsregel
und das Verhalten bei einem kaputten Save.
Der Spannungspunkt ist der Ort: Er muss plattformüblich sein (Windows, Linux, macOS heute; Mobile
und Web später), ohne dass der Spielkern eine Plattform-API sieht.
**Was Bevy 0.19 mitbringt** (an der Quelle geprüft, nicht aus dem Gedächtnis — Belege unten):
Bevy 0.19 hat mit `bevy_settings` ein eingebautes Framework für persistente Nutzerdaten. Es liefert
genau die vier Dinge, die man sonst selbst bauen müsste:
1. **Pfadauflösung für vier Plattformen** über `bevy_platform::dirs::preferences_dir()`.
2. **Atomares Schreiben** — erst `<datei>.toml.new`, dann `fs::rename`.
3. **Feld-Toleranz beim Laden** — Reflect gleicht Feldnamen ab; fehlende Felder behalten ihren
Wert, unbekannte Schlüssel werden ignoriert. Das ist `#[serde(default)]`-Verhalten ohne Attribut.
4. **Web-Pfad** über `localStorage` statt Dateisystem, hinter derselben API.
Das Framework hat drei Lücken, die diese ADR schließen muss: es gibt **keinen Rückkanal** an die
App, ob Laden oder Schreiben geklappt hat; auf **Android und iOS** persistiert es nichts; und in
`store_fs.rs` fehlt nach einem fehlgeschlagenen `fs::write` ein `return`.
**Randbedingung aus dem Spielablauf:** Cards sind im laufenden Run über den Pause-Button nur
einsehbar, nicht wechselbar; das Loadout steht vor dem Run fest, und Pause friert die gesamte
Simulation ein. Damit ändert sich **während eines Runs keine einzige persistierte Quelle**. Das
Schreibfenster liegt vollständig zwischen den Runs — es ist keine Politik, sondern eine Folge.
## Entscheidung
### 1. Format und Ort: `bevy_settings` mit eigener Fortschrittsdatei
Der Meta-Fortschritt lebt als `Resource` mit `#[derive(SettingsGroup)]` und
`#[settings_group(file = "progress")]` — also in **`progress.toml`**, getrennt von der späteren
`settings.toml` für Grafik und Audio. Fortschritt und Optionen haben verschiedene Lebensdauern und
verschiedene Löschwege; wer seine Grafikoptionen zurücksetzt, darf nicht seinen Turm verlieren.
Der App-Name ist die Reverse-Domain der Repo-URL: **`de.hive-brain.mdietermann.infinitower`**.
Daraus ergeben sich die Orte:
| Plattform | Verzeichnis | Quelle |
|---|---|---|
| Windows | `%LOCALAPPDATA%\<app>\progress.toml` (`FOLDERID_LocalAppData`) | `dirs/windows.rs` |
| Linux | `$XDG_CONFIG_HOME/<app>/progress.toml`, sonst `~/.config/...` | `dirs/linux.rs` |
| macOS | `~/Library/Preferences/<app>/progress.toml` | `dirs/macos.rs` |
| Web | `localStorage` (kein Dateisystem) | `store_wasm.rs` |
| Android, iOS | **nichts** — `preferences_dir()` liefert `None` | `dirs/mod.rs` |
**Das Format ist damit TOML, und zwar nicht frei gewählt, sondern der Preis des Frameworks.** Die
Abwägung fällt trotzdem für Klartext aus: Ein Spieler kann `progress.toml` mit jedem Editor
aufmachen und sich Workshop-Level schenken. Es gibt kein Leaderboard, keinen Server, keine
Ökonomie und genau einen Spieler — der Schaden ist null, und der Nutzen beim Debuggen eines
Lernprojekts ist real. Verschleierung (Base64, Prüfsumme, Binärformat) hält keinen auf, der es
ernst meint, und kostet genau die Lesbarkeit, die hier den Wert ausmacht. Schummeln wird
ausdrücklich akzeptiert, nicht übersehen.
### 2. Der Sentinel: `version` als erstes Feld
Das erste Feld ist `version: u32` mit `Default = 0`. Beim Schreiben steht dort **immer** der
aktuelle Wert (heute `1`), nie `0`. Weil das Framework der App nicht mitteilt, ob geladen wurde,
ist dieses Feld der Rückkanal — er liegt in den Daten statt in der API.
Nach dem Laden verzweigt genau ein System dreifach:
| Gelesen | Bedeutung | Verhalten |
|---|---|---|
| `0` | Es wurde **nichts** geladen: erster Start **oder** unlesbare Datei | Meldung an den Spieler, **kein Schreiben** bis zur ersten bewussten Aktion |
| `== CURRENT` | Normalfall | Normal weiter |
| `0 < v < CURRENT` | Älterer Stand mit Bedeutungsänderung | Migrationsschritt läuft **vor** dem ersten Schreiben |
| `> CURRENT` | Save aus einer neueren Programmversion | Alle Schreibvorgänge für die Sitzung gesperrt, Meldung an den Spieler |
`0` ist bewusst zweideutig: Der Unterschied zwischen „erster Start" und „konnte nicht lesen" ist
plattformübergreifend nicht feststellbar, weil eine Existenzprüfung auf Dateiebene im Web keine
Entsprechung hat. Statt die Zweideutigkeit zu verstecken, wird sie ausgesprochen — siehe Punkt 4.
### 3. Regel: niemals schreiben, bevor bestätigt gelesen wurde
**Solange nicht feststeht, dass ein vorhandener Stand gelesen wurde, schreibt das Programm nicht.**
Diese Regel ist wichtiger als die Wahl des Frameworks und gilt auch, wenn Punkt 1 später fällt.
Der Grund ist nicht Robustheit, sondern Respekt vor investierter Zeit: Ein Save, der still durch
Defaults überschrieben wird, entwertet rückwirkend jede Stunde, die darin steckt — und der Spieler
erfährt es nie. Ein Programm, das stattdessen einen Satz sagt und wartet, kann beendet werden,
bevor Schaden entsteht, und die Datei liegt noch da.
### 4. Migration: Feldnamen sind der Vertrag, `version` ist die Notausfahrt
| Änderung | Was passiert | Nötig |
|---|---|---|
| Feld kommt hinzu | Schlüssel fehlt in der Datei → `Default` bleibt stehen | nichts |
| Feld fällt weg | Schlüssel ist unbekannt → wird ignoriert, verschwindet beim nächsten Schreiben | nichts |
| Feld ändert seine **Bedeutung** | Reflect wendet den alten Wert widerspruchslos **falsch** an | **Verbot, siehe unten** |
Für die ersten beiden Fälle reicht die Feld-Toleranz des Frameworks; ein Versionsfeld wäre dafür
überflüssig. Der dritte Fall ist der, den `#[serde(default)]`-artige Toleranz prinzipbedingt nicht
abfangen kann — der Schlüssel passt ja.
**Verbindlich: Eine Bedeutungsänderung erzwingt einen neuen Schlüsselnamen, nie eine
Neuinterpretation des alten.** Umbenennen heißt: alter Schlüssel wird ignoriert, neuer Schlüssel
startet auf `Default`. Der Spieler verliert **einen Wert**, nicht seinen Spielstand. Nur wenn genau
dieser eine Wert nicht verloren gehen darf, wird `version` erhöht und ein expliziter
Migrationsschritt geschrieben, der vor dem ersten Schreiben läuft.
Daraus folgt eine zweite verbindliche Regel: **Die Ablage ist namensbasiert, nie positionsbasiert.**
Workshop-Upgrades und Cards stehen als je ein benanntes Feld in der Datei, nicht als Liste mit
Indizes. Eine positionsbasierte Liste macht das Einfügen eines Upgrades in der Mitte zu einer
stillen Bedeutungsänderung aller nachfolgenden Werte — genau der Fall, den das Verbot verhindern
soll, nur unsichtbar.
`version` wird also von Tag eins mitgeschrieben, obwohl es im Normalbetrieb nichts tut. Der Grund
ist, dass man es nicht nachrüsten kann: Eine Datei ohne Versionsfeld ist von `version = 0` nicht zu
unterscheiden.
### 5. Was persistiert wird
| Feld | Typ | Herkunft |
|---|---|---|
| `version` | `u32` | Sentinel, siehe Punkt 2 |
| `workshop_levels` | Struct mit **einem benannten Feld je Upgrade** | Workshop-Käufe zwischen Runs |
| `card_levels` | Struct mit **einem benannten Feld je Card**, `0` = nicht besessen | fasst „besessen" und „Stufe" in einem Feld zusammen |
| `equipped_cards` | Liste von Card-Bezeichnern (Namen, keine Indizes) | Loadout, steht vor dem Run fest |
| `card_slots` | `u8` | freigeschaltete Slot-Zahl |
| *Meta-Währung* | ganzzahlig | **Name offen** — `CONTEXT.md` hat für diese Ressource noch keinen Begriff; der Feldname folgt dem Glossar, sobald er feststeht |
| `highest_wave` | `u32` | höchste je erreichte Welle |
**Kein Zeitstempel.** Ohne Offline-Progress liest ihn niemand, und ein Feld ohne Leser ist die
Miniaturausgabe genau des Fehlers, den ADR-0002 verbietet — gespeicherter Zustand, den niemand
braucht.
**Kein laufender Run.** Keine In-Run-Upgrades, kein Wellenstand, keine Coins, kein `EffectiveStats`.
**Kein `Balance`.** `Balance` ist eine `Resource`, wird aber nie persistiert: Ihre Werte sind laut
ADR-0001 in die Binary gebacken, und ein Save darf nicht auf Werte zeigen, die es im aktuellen
Build nicht mehr gibt.
### 6. Verhältnis zur Ablageregel aus ADR-0002
Die Regel hält, aber sie ist **einseitig** und wird hier präzisiert:
> Persistiert ⊆ `Resource`. Nicht jede `Resource` ist persistiert.
`Balance` ist der Gegenbeleg (Punkt 5). Die Markierung ist deshalb nicht „ist eine `Resource`",
sondern das `SettingsGroup`-Derive — am Typ ablesbar, nicht aus der Ablage zu erraten.
**Es gibt genau eine Ausnahme, und sie ist benannt:** `highest_wave` und die im Run verdiente
Meta-Währung entstehen *innerhalb* eines Runs, müssen ihn aber überdauern. Sie sind trotzdem keine
Run-Daten, sondern Meta-Werte, die aus Run-Daten abgeleitet werden. Verbindlich: **Dieser Übergang
findet genau einmal statt, am Run-Ende, in einem einzigen System.** Kein anderes System schreibt
aus Run-Zustand in eine persistierte `Resource`. Alles Übrige folgt ADR-0002 unverändert.
Die Randbedingung „Cards im Run nur einsehbar" entzieht ADR-0002 Punkt 10 nebenbei ihren Fall 1
(Card-Wechsel im laufenden Run). Der Abgleich in `sync_tower_abilities` bleibt richtig; er hat nur
einen Auslöser weniger. Das ist eine Beobachtung, keine Änderung an ADR-0002.
### 7. Wann geschrieben wird
- Nach **jeder** Änderung einer persistierten `Resource`: `SaveSettingsDeferred` (1 s Debounce).
- **Zusätzlich** beim Beenden: `SaveSettingsSync::IfChanged`. Beides zusammen, nicht eines von
beiden — die Bevy-Doku weist ausdrücklich darauf hin, dass ein Exit-Event auf manchen Plattformen
(etwa Cmd-Q auf macOS) nicht abgefangen werden kann.
- **Nicht periodisch, nicht bei Pause.** Während eines Runs ändert sich keine persistierte Quelle
(Punkt 0 im Kontext), und Pause friert ohnehin alles ein. Es gibt schlicht nichts zu speichern.
Damit fällt in der Praxis **ein** Schreibvorgang pro Run an (Run-Ende) plus je einer pro Kauf oder
Loadout-Änderung zwischen den Runs.
**Atomares Schreiben ist nötig und kostet hier nichts**, weil das Framework es bereits tut: Bei
einer Datei unter 2 KB ist der Preis eine zusätzliche `write`- und eine `rename`-Operation, und der
Schutz gegen einen Absturz mitten im Schreiben ist strukturell statt hoffnungsbasiert.
### 8. Die vier Fehlerfälle
| Fall | Was technisch passiert | Was der Spieler sieht |
|---|---|---|
| **Beschädigte Datei** | `decode_toml_file` loggt `Error parsing settings file` und gibt `None`; alle Resources bleiben auf `Default`, also `version == 0` | „Kein Fortschritt geladen. Es wird ein neuer Spielstand angelegt, sobald du fortfährst." — und bis dahin wird **nicht** geschrieben (Punkt 3) |
| **Halb geschriebene Datei nach Absturz** | Kann `progress.toml` nicht erreichen: geschrieben wird `progress.toml.new`, erst `fs::rename` macht es gültig. Ein Absturz hinterlässt den alten Stand plus eine verwaiste `.new` | nichts — der alte Stand lädt normal |
| **Save aus neuerer Programmversion** | Unbekannte Schlüssel werden ignoriert, bekannte angewandt; `version > CURRENT` fängt das ab | „Dieser Spielstand stammt aus einer neueren Version." Alle Schreibvorgänge sind für die Sitzung gesperrt, die Datei bleibt unangetastet |
| **Fehlende Schreibrechte** | `fs::write` bzw. `fs::rename` schlagen fehl, werden geloggt, und die App erfährt es nicht | **Nichts** — und das ist die ehrliche Lücke dieser Entscheidung. Es gibt in 0.19 keinen Rückkanal. Deshalb zeigt der Slice ausdrücklich **keine** „Gespeichert"-Bestätigung: eine Erfolgsmeldung, die das Programm nicht belegen kann, ist schlimmer als gar keine |
Der Fall, der bestehenden Fortschritt aktiv zerstören kann, ist keiner dieser vier, sondern eine
Verkettung: In `store_fs.rs` fehlt nach einem fehlgeschlagenen `fs::write` ein `return`, sodass
`fs::rename` die möglicherweise abgeschnittene Temp-Datei trotzdem über den intakten Save schiebt.
Auslöser wäre eine volle Platte oder ein Quota. Der Schutz ist einseitig: Parst die abgeschnittene
Datei nicht mehr, fängt sie Fall 1 ab und es wird nichts überschrieben. **Ob eine abgeschnittene
TOML-Datei immer ungültig ist, wurde nicht geprüft und ist unwahrscheinlich** — eine Datei, die
nach einer vollständigen Zeile endet, parst tadellos und hat den Rest still auf `Default`. Genau
deshalb steht dieser Punkt unten als Revisions-Auslöser und nicht als gelöstes Problem.
## Belegtiefe
Jede tragende Behauptung mit ihrer Quelle. Alle Dateipfade beziehen sich auf das Bevy-Repo am Tag
**`v0.19.0`**.
| Behauptung | Beleg | Status |
|---|---|---|
| `bevy_settings` existiert im Engine-Repo, erreichbar als `bevy::settings` hinter dem **nicht-default** Feature `bevy_settings` | `crates/bevy_settings/src/lib.rs`; docs.rs `bevy/0.19.0/bevy/settings/`; Feature-Liste docs.rs `crate/bevy/0.19.0/features` | belegt |
| Attribut `#[settings_group(file = "<name>")]`, Default `settings`, ergibt `<name>.toml` | `lib.rs`, Doc-Kommentar am Trait `SettingsGroup`; verwendet im Test `#[settings_group(file = "audio")]` | belegt |
| `preferences_dir()` liefert auf Android und iOS `None` | `crates/bevy_platform/src/dirs/mod.rs`: `#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] pub fn preferences_dir() -> Option<PathBuf> { None }`. `store_fs.rs` setzt daraufhin `base_path: None`; `save` und `load` sind dann No-Ops mit `warn!("Could not find user configuration directories")` | belegt |
| Pfade: Windows `FOLDERID_LocalAppData`, macOS `~/Library/Preferences`, Linux `$XDG_CONFIG_HOME` bzw. `~/.config` | `dirs/windows.rs` (`SHGetKnownFolderPath`), `dirs/macos.rs`, `dirs/linux.rs` | belegt |
| Atomares Schreiben über Temp-Datei plus `rename` | `store_fs.rs`: `let temp_path = base_path.join(format!("{filename}.toml.new"));` … `fs::rename(&temp_path, file_path)` | belegt |
| In `store_fs.rs` fehlt nach fehlgeschlagenem `fs::write` ein `return`, `fs::rename` läuft trotzdem | `store_fs.rs`, in `save` und `save_async`: `if let Err(e) = fs::write(&temp_path, …) { error!(…); }` — ohne `return`, direkt gefolgt vom `fs::rename` | belegt **durch Code-Lesen**; ein tatsächlicher Datenverlust wurde nicht beobachtet und der Upstream-Bugtracker nicht geprüft |
| Kein Rückkanal an die App über Lade- oder Schreiberfolg | `SettingsStore::{save, save_async, load}` sind `pub(crate)` und geben nichts zurück; `load_settings_file` gibt `()` zurück; das Modul exportiert kein Fehler-Event und keine Status-Resource | belegt |
| Fehlerbehandlung ist **`error!`** für Lese- und Parse-Fehler, **`warn!`** für fehlende Datei und fehlendes Verzeichnis | `store_fs.rs`: `error!("Error reading settings file: …")`, `error!("Error parsing settings file: …")`; `lib.rs`: `warn!("Filename {filename}.toml not found")` | belegt — **korrigiert** gegenüber meiner früheren, ungenauen Aussage „nur `warn!`" |
| Fehlende Felder behalten ihren Wert, unbekannte Schlüssel werden ignoriert | `lib.rs`, `load_properties`, Kommentar wörtlich: *„Deserialize matching field names, ignore ones that don't match."* — die Schleife läuft über die Reflect-Feldnamen und schlägt jeden in der TOML-Tabelle nach | belegt |
| Deferred-Save mit 1 s Debounce; `SaveSettingsSync::IfChanged`; Empfehlung, beides zu nutzen | `lib.rs`: `SaveSettingsDeferred(pub Duration)` mit `Duration::from_secs(1)` als `Default`; `SaveSettingsSync::IfChanged`; Doc-Kommentar am `SettingsPlugin` zum Cmd-Q-Fall auf macOS | belegt |
| Persistierte Typen brauchen `#[reflect(Resource, SettingsGroup, Default)]` | `lib.rs` ruft `ty.data::<ReflectDefault>().unwrap()`, wenn die Resource noch nicht existiert — ein fehlendes `ReflectDefault` ist damit ein Panic, kein stiller Fehler | belegt |
| Ob eine abgeschnittene TOML-Datei zuverlässig ungültig ist | — | **nicht geprüft, wird nicht angenommen** |
| Ob `bevy_settings` in einer späteren Bevy-Version einen Rückkanal bekommt | — | **nicht geprüft** |
## Verworfene Alternativen
- **Eigenes Save-Modul mit `ron` und `serde`** (beide laut ADR-0001 bereits im `Cargo.lock`). Der
Rückkanal wäre ein echter Gewinn — man hielte `io::Result` in der Hand. Der Preis ist alles
andere: eine eigene `preferences_dir()`-Entsprechung für drei Betriebssysteme (Bevys Version ist
nach `dirs-sys` modelliert und braucht auf Windows `unsafe` um `SHGetKnownFolderPath`), ein
eigener `localStorage`-Pfad für Web, eigenes Temp-and-Rename und eine eigene Feld-Toleranz — für
sechs Felder Nutzdaten. Dazu bliebe es dauerhaft eigene Wartungslast, während der `store_fs`-Bug
stromaufwärts gefixt wird und beim nächsten Bevy-Bump gratis ankommt.
- **`dirs` (6.0.0) oder `directories` als Dependency plus eigenes Schreiben.** Löst nur den Ort,
nicht Web, nicht Atomarität, nicht die Feld-Toleranz — und `bevy_platform::dirs` ist ohnehin nach
demselben Vorbild gebaut und liegt bereits im Baum.
- **Alles in `settings.toml`, ohne eigene Datei.** Spart ein Attribut und koppelt Fortschritt an
Grafikoptionen. „Einstellungen zurücksetzen" würde den Spielstand mitnehmen.
- **Verschlüsselung oder Prüfsumme gegen Schummeln.** Kauft nichts (Einzelspieler, kein
Vergleichswert verlässt das Gerät) und kostet die Lesbarkeit, die beim Debuggen den Wert ausmacht.
- **Positionsbasierte Listen für Workshop-Level und Cards.** Kompakter in der Datei, aber ein
eingefügtes Upgrade verschiebt still die Bedeutung aller folgenden Werte — die Fehlerklasse, die
Punkt 4 verbietet, nur ohne Symptom.
- **Speichern bei jeder Änderung ohne Debounce.** Unnötig: Zwischen den Runs ändert sich wenig, im
Run nichts, und `SaveSettingsDeferred` fasst Klick-Serien im Workshop ohnehin zusammen.
## Konsequenzen
**Positiv**
- Keine deklarierte Dependency; nur ein Cargo-Feature (siehe unten).
- Der Spielkern sieht keine Plattform-API — nur ein `#[derive]` und eine `Resource`. Die
Plattformfallunterscheidung liegt vollständig in `bevy_settings`.
- Web funktioniert ohne eigenen Code, weil `localStorage` hinter derselben API liegt.
- Atomares Schreiben, Feld-Toleranz und Pfadauflösung sind fremdgewartet.
- Der Save ist im Editor lesbar — ein Fehlerbild lässt sich in Sekunden verifizieren.
**Negativ**
- **Android und iOS persistieren nichts** und melden es nur als `warn!`. Der Mobile-Port erbt ein
stumm kaputtes Save/Load und braucht eigene Arbeit. `CONTEXT.md` nennt Mobile als nächsten Schritt
nach dem Slice.
- Ein fehlgeschlagener Schreibvorgang ist für die App unsichtbar. Deshalb gibt es keine
„Gespeichert"-Anzeige — der Slice verzichtet lieber auf Rückmeldung als auf Wahrheit.
- `version == 0` kann „erster Start" oder „kaputte Datei" heißen. Die Meldung an den Spieler muss
beides abdecken und darf keines von beidem behaupten.
- Ein *Settings*-Framework trägt Save-Daten. Das passt, solange der Fortschritt aus flachen
Skalaren besteht (ADR-0002 Punkt 5). Sobald ein Save Entities oder Weltzustand enthielte, wäre es
das falsche Werkzeug — und ADR-0002 müsste zuerst fallen.
- Die persistierten Typen sind an `Reflect` gebunden; ein vergessenes `#[reflect(… Default)]` ist
ein Panic beim Start statt eines Compile-Fehlers.
**Neue Dependencies**
Keine deklarierte Dependency. Nötig ist das **nicht-default Cargo-Feature `bevy_settings`**:
```toml
bevy = { version = "0.19.0", features = ["bevy_settings"] }
```
Transitiv kommt dadurch genau **eine** Kiste neu in den `Cargo.lock`: die Fassade **`toml`
(^1.1.0)**. Ihr Unterbau liegt bereits dort (`toml_edit` 0.25.13, `toml_parser` 1.1.3,
`toml_datetime` 1.1.1), ebenso `serde`, `thiserror`, `web-sys` und `windows-sys`. Der Zuwachs an
Compile-Zeit ist damit auf eine dünne Fassadenkiste begrenzt — das ist der Grund, warum diese
Entscheidung nicht gegen die Dependency-Zurückhaltung aus ADR-0001 verstößt.
**Folgeaufgaben** (nicht Teil dieser ADR)
- Der Feldname für die Meta-Währung folgt, sobald `CONTEXT.md` den Begriff festlegt.
- Die Reihenfolge der Plugins: `SettingsPlugin` lädt beim Hinzufügen, also vor allem, was die
geladenen Werte lesen will.
## Revisions-Auslöser
Diese Entscheidung wird neu getroffen, sobald **eines** der folgenden Ereignisse eintritt:
1. **Truncated-Promote beobachtet:** Ein Log-Eintrag `Error saving settings file` erscheint, **oder**
eine Datei `progress.toml.new` bleibt im Einstellungsverzeichnis liegen. Beides ist der direkte
Beleg, dass der fehlende `return` in `store_fs.rs` gegriffen hat. Dann wird das Schreiben
übernommen — Eigenbau nur für den Schreibpfad, Laden bleibt beim Framework.
2. **Mobile im Scope:** Sobald ein Android- oder iOS-Build gebaut werden soll. `preferences_dir()`
liefert dort `None`, und der Fortschritt verschwindet lautlos. Kein Grenzwert, ein Build reicht.
3. **Ein Save muss mehr als flache Skalare enthalten** — Entities, Weltzustand, ein laufender Run.
Dann ist nicht dieses Format falsch, sondern zuerst ADR-0002 Punkt 5 neu zu bewerten.
4. **Ein Vergleichswert verlässt das Gerät** (Leaderboard, geteilter Wellen-Rekord, Cloud-Save).
Ab diesem Moment hat Schummeln ein Opfer, und die Klartext-Entscheidung aus Punkt 1 ist neu zu
treffen — nicht vorher.
5. **Eine zweite Bedeutungsänderung innerhalb eines Slice** erzwingt eine `version`-Erhöhung. Einmal
ist eine Notausfahrt; zweimal heißt, dass die Feldnamen den Vertrag nicht mehr tragen, und dann
wird ein echtes Migrationsschema fällig statt einer Umbenennungsregel.