Files
InfiniTower/docs/adr/0003-save-format-ort-und-migration.md

22 KiB

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:

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.