- ludic.ui cv_join (units_pieces.ludic:93): a var() value's pieces were joined by +, a text left behind per piece on every memo miss; it is written at its length at once (units_join.ludic), and the miss is its own declared function (cs_vars_miss), bounded by the memo. - ludic.hints hn_slots (rail.ludic:41): hints_reset made a new rail every time; the rail is emptied in place, made again only for another count (rail_slots.ludic). - value_spare_put (value.ludic:138): a list's spares grow only past the most it has ever cut off; declared as such. - ludic.save: save_read reads, parses and frees the file's text - the tree's strings are the parser's own - and says whether the text was whole (SaveRead.intact) for the words of a refusal. Maroon Lake's trip and home reads kept the whole file on every load and every menu card read. Tests: ludic.save (10), ludic.hints (8); the 36 ui examples; value_list_regrow, json_saves and alloc_fence_declared unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| tests | ||
| format.ludic | ||
| index.ludic | ||
| migrate.ludic | ||
| package.ludic | ||
| read.ludic | ||
| README.md | ||
| text.ludic | ||
| values.ludic | ||
| write.ludic | ||
ludic.save
A save file's contract across releases, generic over what the file holds. Every shipped game meets it on its second release: the tables a save is indexed by grow, keys are added, meanings shift, and a file written by 0.1 has to open in 0.2 - or be refused with a sentence the player can act on, never silently read as rubbish. Uses nothing.
(ludic.base's save tree is the other half: a mechanic's own section inside a file, by key. This
package is the file.)
import "ludic.save"
def SaveMigrations trip_3_4 { format: "trip", from: 3, run: fn trip_mig_3_4 }
let f = save_format("trip", 6) # this build writes 6
f.is_one = fn looks_like_a_trip # an object with a day in it
let r = save_open(path, backup, f) # read, check, carry forward, or the backup
if r.status == SAVE_OK { apply(r.value) }
...
save_write_tree(path, backup, v) # false unless it is on disk; keeps nothing
The contract
- A version in the file, under
version_key("version"); a file without one is 1. - A chain of migrations, one step per version, each rewriting the parsed object in place, so an
old file is brought to today's shape once and every reader after it sees only that shape - no
reader grows an
if version < n. The game declares its steps into the open registrySaveMigrations({ format, from, run }); a version with no step changed nothing. - A torn write is told from a whole one by the text (
save_text_intact: every brace, bracket and string closed). A best-effort JSON parser hands back half a file as a shorter, plausible one. - A backup, and only of a whole file:
save_writecopies the old file aside when it is intact, writes, and reads the text straight back. A caller about to delete something on the strength of the answer needs to know it is on disk.save_write_treedoes the same for a tree throughJson.write_file, so a save holds no text of its own afterwards. - A newer build's file is refused and read-only. It is never replaced by its backup (that is from
the newer build too), and
save_write_torefuses a read that saysreadonly. Both copies damaged is read-only as well. Never write over a file you could not read.
The package never words anything: a status is a number, and the game says it.
API
SaveFormat { name, version, oldest, version_key, is_one }, save_format(name, version) |
a kind of file |
SaveMigration { key, format, from, run }, open registry SaveMigrations as SMIG |
one step, declared by the game |
SaveRead { status, from, migrated, readonly, from_backup, value } |
how one read went |
SAVE_OK, SAVE_NONE, SAVE_CORRUPT, SAVE_FUTURE, SAVE_STATUS_COUNT |
the statuses; a game's own refusals (another map) start at the count |
save_parse(text, f), save_read(path, f) |
whole, parsed, an object, one of these |
save_upgrade(r, f) -> bool |
the version checks and the chain |
save_open(path, backup, f) -> SaveRead |
both, with the backup standing in for a damaged file |
save_migrate(v, f, from), save_step(format, n), save_steps_doubled(f) |
the chain outright; a step declared twice |
save_write(path, backup, text) -> bool, save_write_to(r, path, backup, text) |
the write, backed up and read back |
save_text_intact(s) |
the torn-write test |
save_strip(v, key), save_list_with(l, slot, n), save_list_without(l, slot), save_int(v, key, fallback) |
what migrations keep doing |
save_fold(h, s), save_fold_int(h, n) |
a fingerprint of the tables a file stores by position: an append refreshes it, an insert needs a step |
Tests
ludic test packages/ludic.save
A toy format at version 3 with two steps and another format's step beside them: torn files, rubbish and the wrong kind of file, the chain from 1, a newer file refused and read-only, the backup standing in and never over a newer file, a write that backs up only a whole file.