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
Resourcevs.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:
- Pfadauflösung für vier Plattformen über
bevy_platform::dirs::preferences_dir(). - Atomares Schreiben — erst
<datei>.toml.new, dannfs::rename. - 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. - Web-Pfad über
localStoragestatt 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 jedeResourceist 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
ronundserde(beide laut ADR-0001 bereits imCargo.lock). Der Rückkanal wäre ein echter Gewinn — man hielteio::Resultin der Hand. Der Preis ist alles andere: eine eigenepreferences_dir()-Entsprechung für drei Betriebssysteme (Bevys Version ist nachdirs-sysmodelliert und braucht auf WindowsunsafeumSHGetKnownFolderPath), ein eigenerlocalStorage-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 derstore_fs-Bug stromaufwärts gefixt wird und beim nächsten Bevy-Bump gratis ankommt. dirs(6.0.0) oderdirectoriesals Dependency plus eigenes Schreiben. Löst nur den Ort, nicht Web, nicht Atomarität, nicht die Feld-Toleranz — undbevy_platform::dirsist 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
SaveSettingsDeferredfasst 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 eineResource. Die Plattformfallunterscheidung liegt vollständig inbevy_settings. - Web funktioniert ohne eigenen Code, weil
localStoragehinter 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.mdnennt 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 == 0kann „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
Reflectgebunden; 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.mdden Begriff festlegt. - Die Reihenfolge der Plugins:
SettingsPluginlä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:
- Truncated-Promote beobachtet: Ein Log-Eintrag
Error saving settings fileerscheint, oder eine Dateiprogress.toml.newbleibt im Einstellungsverzeichnis liegen. Beides ist der direkte Beleg, dass der fehlendereturninstore_fs.rsgegriffen hat. Dann wird das Schreiben übernommen — Eigenbau nur für den Schreibpfad, Laden bleibt beim Framework. - Mobile im Scope: Sobald ein Android- oder iOS-Build gebaut werden soll.
preferences_dir()liefert dortNone, und der Fortschritt verschwindet lautlos. Kein Grenzwert, ein Build reicht. - 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.
- 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.
- 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.