Nimbin[12]?Games / Entropie / commits / 7182d5b

Entropie git · main

Entropie festival game; first steps

sdl c game entropie cmake · first commit 2026-10-10 · last commit 2026-11-02 (17 hours ago) · synced 8 hours ago

C 88.5% Markdown 9.1% GLSL 2.3%
git clone https://git.christianimmanuel.de/games/Entropie.gitwget https://git.christianimmanuel.de/games/Entropie/archive/Entropie.tar.gz

entropie 2.18.0: saving and loading, start menu

Christian Immanuel · 2026-10-20 17:21 · 7182d5be1ebb4a20d37881d9787b3c795f5e9ec2

- Start menu: continue (the autosave), new game, load a save, quit
- Esc menu: save as (type a name), quick save, load a save
- F5 quick save, F9 quick load; autosave every 5 minutes and on quit
- Saves: a versioned text file plus the ground (worn paths, wet ground);
  the world is rebuilt and everything put back (people, pockets, clothes,
  tents, crates, felled trees, the car, the weather, the time)
- Format version and migration steps so old saves keep working
- Fix: a freed people list could be used after rebuilding the world
 commit_message.txt           |  19 +-
 entropie/ARCHITECTURE.md     |  14 +-
 entropie/Makefile            |   2 +-
 entropie/README.md           |  12 +-
 entropie/STATE.md            |  11 +-
 entropie/src/core/input.c    |  10 +-
 entropie/src/core/input.h    |   4 +
 entropie/src/core/platform.c |  13 +
 entropie/src/core/platform.h |   5 +
 entropie/src/game/game.c     |  12 +-
 entropie/src/game/game.h     |   2 +
 entropie/src/game/save.c     | 629 +++++++++++++++++++++++++++++++++++++++++++
 entropie/src/game/save.h     |  44 +++
 entropie/src/main.c          | 264 +++++++++++++++---
 entropie/src/render/ui.h     |   2 +-
 entropie/src/world/car.c     |   8 +
 entropie/src/world/car.h     |   2 +
 entropie/src/world/clothes.c |  18 ++
 entropie/src/world/clothes.h |   4 +
 entropie/src/world/object.c  |   6 +
 entropie/src/world/object.h  |   8 +
 entropie/src/world/tent.c    |   5 +
 entropie/src/world/tent.h    |   2 +
 entropie/src/world/terrain.c |  13 +
 entropie/src/world/terrain.h |   4 +
 entropie/src/world/wetness.c |  40 +++
 entropie/src/world/wetness.h |   5 +
 entropie/src/world/world.c   |   2 +
 28 files changed, 1101 insertions(+), 59 deletions(-)

diff --git a/commit_message.txt b/commit_message.txt
index ff953ba..bd1ec9d 100644
--- a/commit_message.txt
+++ b/commit_message.txt
@@ -1,11 +1,10 @@
-entropie 2.17.0: arrival, car, seats, crates, tents
+entropie 2.18.0: saving and loading, start menu
 
-- Arriving: a stretch after the drive, then over to the fire together at a
-  stroll; Kurti walks slowly; "everybody" shouts leave out Kurti and sons
-- Volkram drives, you sit in the back; a new old estate car that fits the
-  farm; heads no longer through the roof, seats lean back
-- Nobody sits on the same spot as someone else (benches, logs, chairs)
-- Kurti: one rounded heavy build (chest into belly, not a ball)
-- Empty bottles go back into crates (you and the tidy NPCs)
-- NPCs walk as fast as you and slow down carrying crates like you
-- Tents sit on the ground (no hovering); move a tent without pegs with F
+- Start menu: continue (the autosave), new game, load a save, quit
+- Esc menu: save as (type a name), quick save, load a save
+- F5 quick save, F9 quick load; autosave every 5 minutes and on quit
+- Saves: a versioned text file plus the ground (worn paths, wet ground);
+  the world is rebuilt and everything put back (people, pockets, clothes,
+  tents, crates, felled trees, the car, the weather, the time)
+- Format version and migration steps so old saves keep working
+- Fix: a freed people list could be used after rebuilding the world
diff --git a/entropie/ARCHITECTURE.md b/entropie/ARCHITECTURE.md
index f196f39..ff35b49 100644
--- a/entropie/ARCHITECTURE.md
+++ b/entropie/ARCHITECTURE.md
@@ -12,7 +12,7 @@ same version when the structure changes (rules for working on it: [RULES.md](RUL
 | `src/render/` | renderer and its passes, shaders' uniforms, meshes, procedural shapes (`shapes*.c`), figures, camera, sky, UI | core, math, render |
 | `src/world/` | `Object`, `World`, physics, terrain, the map (`site.c`, `scene.c`, `manor.c`, `course.c`), tools, forestry, ladder, hose, camp, clothes, car, water, weather, grass, grid, day | everything but game |
 | `src/people/` | bodies (animation), hands, AI, talk, condition, dog, climbing | everything but game |
-| `src/game/` | the frame: input to actions, player, camera follow, talk and dev UI, the arrival by car at the start | all |
+| `src/game/` | the frame: input to actions, player, camera follow, talk and dev UI, the arrival by car at the start, saves (`save.c`) | all |
 
 - `render` knows nothing of the world: it gets meshes, matrices and plain numbers.
 - `world` and `people` are one layer: an `Object` owns its `Body`, `Ai` and `Hands`.
@@ -78,6 +78,18 @@ of view (objects, 8×8 terrain tiles).
   pass draws the outlines over it all.
 - New GL calls: add them to the list in `core/gl.h`.
 
+## Saves
+
+- `src/game/save.c`: `<name>.sav` (text: `key value` lines, `[game]`, then `[o id]` per object) and `<name>.ground` (binary: the wear map, the wet chunks). In the user's data folder (`Platform_saveDir`).
+- Loading builds the world as at the start (`Game_initWorld(game, false)`: no drive in), finds the scene's things by `scene_id` / `spawn` / `spawn_type` (set when the scene is built), makes the rest anew (`Object_create` with the saved type, palette, size and `make_arg`), then sets the fields and rebuilds what looks different (trees, tents, gear, crates, bags, bottles, ladders, pullovers, the car).
+- What's saved: the tables `THING` and `PERSON` in save.c (plus skills, likes, keys, hands, the car's boot, asleep). Buildings, walls, roads, bushes: only found again.
+- Keeping old saves working:
+  - A new field: add it to a table. Old saves don't have it: it keeps what the fresh world gives it.
+  - A key renamed or its meaning changed: raise `SAVE_FORMAT` (save.h) and add a step to `migrate()` that rewrites the old lines.
+  - A save from a newer format is refused, never guessed at.
+  - A thing the scene no longer makes is dropped; one it newly makes keeps its fresh state.
+- Not kept (comes back fresh): what anybody was in the middle of (AI plans, paths, poses; sitting: they stand), the hose (lies at its tap), sparks and smoke.
+
 ## Versions
 
 `Makefile` `VERSION`; the build id is an md5 of sources and assets
diff --git a/entropie/Makefile b/entropie/Makefile
index ce1d7d2..49a6ec8 100644
--- a/entropie/Makefile
+++ b/entropie/Makefile
@@ -1,5 +1,5 @@
 APP       := entropie
-VERSION   := 2.17.0
+VERSION   := 2.18.0
 
 PREFIX    ?= /usr
 BINDIR    ?= $(PREFIX)/bin
diff --git a/entropie/README.md b/entropie/README.md
index 1c8e93c..3fa2578 100644
--- a/entropie/README.md
+++ b/entropie/README.md
@@ -677,6 +677,15 @@ say no more often and don't come to you for a bit.
   your skills. Only pressing needs show otherwise
 - The others live the same day: they eat, drink, pee and sleep at night
 
+## Saving
+
+- At the start: continue (the autosave), a new game, or load a save
+- Esc: save as (type a name), quick save, load a save
+- F5 quick save, F9 load it
+- Autosave every 5 minutes and on quit
+- Saves live in your data folder (on Linux: `~/.local/share/entropie/entropie/saves`)
+- Old saves keep working with newer versions
+
 ## Controls
 
 | Input         | Action     |
@@ -717,7 +726,8 @@ say no more often and don't come to you for a bit.
 | I             | Show all keys in game |
 | Tab           | Your stats (Left / Right: other page) |
 | F1            | Dev menu: pick anyone, change body, needs, temperament, likes and skills (Up / Down, Left / Right); teleport you to the parkour or the yard; time of day, game speed, weather, wind, skip the start |
-| Esc           | Menu: resume, keys, quit (the game waits meanwhile) |
+| Esc           | Menu: resume, save, load, keys, quit (the game waits meanwhile) |
+| F5 / F9       | Quick save / quick load |
 
 The game starts in third person. Close to a wall the view moves in, never through it.
 With a tool and nothing to work on, a click drinks from the other hand.
diff --git a/entropie/STATE.md b/entropie/STATE.md
index 02a6946..f940ffe 100644
--- a/entropie/STATE.md
+++ b/entropie/STATE.md
@@ -266,6 +266,12 @@ Round 2.17:
 - Bottles back into crates (`Crate_putBottle`, `crate_empties`): left click at a crate with an empty in hand (or both hands full) puts it in; taking one out gives a full one ("Only empties left in this one"). The tidy carry empties to the nearest crate with room within 50 m instead of the bin (`ai_help.c`).
 - Tents stood on the sleeping bags lying where they were spread (`World_groundHeight` counted them): on the terrain now (also when blown about). Moving one (`game_actions.c` moveTent, `Tent_movable` / `Tent_startMove`, `tent_dragged`): no pegs in it, hold F: it comes along in front of you, turning with you, set down where you let go; then it goes up again from there (hold left click: the pegs). Nobody works on it while it's moved.
 
+Round 2.18:
+- Saves (`game/save.c`, `save.h`, SAVE_FORMAT 1; see ARCHITECTURE.md "Saves"): `Save_write` / `Save_read` / `Save_list` / `Save_blocked` (not while driving in, or in the car). Text `.sav` (field tables THING / PERSON by offsetof, references as save ids) + binary `.ground` (`Terrain_writeWear` / `readWear`, `Wetness_write` / `read`). Objects carry `palette`, `make_arg` (Object_create), `scene_id`, `spawn`, `spawn_type` (Game_initWorld), `cloth_gap`. Rebuild helpers: `Object_rebuildTree`, `Tent_refresh`, `Gear_refresh`, `Clothes_rebuild`, `Clothes_whereText`, `Ladder_setExtend`, `Object_fillBag`, `Object_setDrink`, `Object_shatter`. Held things come back in the hand (`Hands_receive`; too big for one hand: at their feet), sleepers asleep, people in tents / the car back in.
+- `Game_initWorld(game, arrive)`; Game_init = with the drive in. `World_init` / `World_destroy` forget the people list (Object_seePeople) - loading used the old world's freed array.
+- Menus (`main.c`): a start menu over the waiting world (Continue: the autosave, New game, Load a save, Quit); Esc: Resume, Save as (`Platform_typing`, `Input.text` / `erase`: letters, digits, - _), Quick save, Load a save, Keys, Quit (autosaves). F5 quick save, F9 quick load. Autosave every AUTOSAVE_EVERY 300 s of play and on quit / closing the window. Folder: `Platform_saveDir` (SDL_GetPrefPath + "saves").
+- Tested in the harness: a save written and read back (the same process, and another one): people, pockets, a tent half taken down, crates, rain, the wet ground, a pullover put down, a felled tree with fewer branches; under AddressSanitizer. The menus themselves aren't run here (no SDL library to link): compiled with all warnings only.
+
 Unfinished building (Shell_build, at SHELL_AT in the frame's frame): brick shell 6.4 x 4.7, walls 2.9, open front (2 m) to the frame (now only the front half: pillars at z -13.8 / -9.2), window holes, bare floor, tiled roof with slabs. Barn: brick courses on BUILDING_BRICK walls (`addBrickCourses`: mortar a shade lighter, so the ink doesn't trace it), ivy (`Shapes_ivy`, OBJ_STRUCTUREs) on both long sides; the woodpile (OBJ_WOODPILE "Woodpile", WOODPILE_A/B) left of the toilet, everybody's woodpile from the start (`scene.c` Scene_finish). Fireplace tripod: wooden poles, feet TRIPOD_OUT 0.9 m outside the stones, TRIPOD_PAST 0.4 m past the top, a lashing; each pole an OBJ_WALL along it ("Tripod pole"). Gates (FURN_GATE, green boards): in your garages' doorways (GATES: hinge, end), `gate_open` / `gate_t`, `hinge`, `gate_shut` / `gate_out`; Object_swingGate (1.2 s), driven by showProps; a click within 1.2 m opens / shuts both leaves.
 
 Props that show (1.89, `world.c` showProps every frame): the tin's mesh is built again when its butts change (`Shapes_ashtray`: up to 12 show, the ash rises, a heap from 14; `shown` = what's built).
@@ -415,6 +421,9 @@ Most important first.
 - Tents, still to do: NPCs taking theirs down on their own (leaving)
 - Weather, still to do: a sound for thunder, wind and rain (no sound in the game yet); a hat or anything held blown out of the hands
 
+**Saves, still to do**
+- The hose (its rope) and things being done (sitting, a job half way, a ladder being climbed) come back fresh, not as they were
+
 **Tools for us**
 - Optional local AI (llama.cpp, small model) for talking and tasks; the menu stays as fallback
 
@@ -422,4 +431,4 @@ Most important first.
 - Drawing many figures on a real graphics card: not measured (software GL: 300 people cost 3x the rest of the scene)
 
 ---
-State of `entropie 2.17.0 (build 603c526)`
+State of `entropie 2.18.0 (build a8a2692)`
diff --git a/entropie/src/core/input.c b/entropie/src/core/input.c
index b5c5bc0..36fff03 100644
--- a/entropie/src/core/input.c
+++ b/entropie/src/core/input.c
@@ -1,6 +1,8 @@
 #include "core/input.h"
 
 #include <SDL3/SDL.h>
+#include <stdio.h>
+#include <string.h>
 
 #define MOUSE_SENSITIVITY 0.1f /* degrees per pixel */
 
@@ -12,6 +14,10 @@ void Input_poll(Input *input) {
         if (event.type == SDL_EVENT_QUIT) input->quit = true;
         if (event.type == SDL_EVENT_MOUSE_BUTTON_DOWN && event.button.button == SDL_BUTTON_LEFT) input->click = true;
         if (event.type == SDL_EVENT_MOUSE_BUTTON_DOWN && event.button.button == SDL_BUTTON_RIGHT) input->right_click = true;
+        if (event.type == SDL_EVENT_TEXT_INPUT) {
+            size_t have = strlen(input->text);
+            snprintf(input->text + have, sizeof(input->text) - have, "%s", event.text.text);
+        }
         if (event.type != SDL_EVENT_KEY_DOWN || event.key.repeat) continue;
         switch (event.key.scancode) {
         case SDL_SCANCODE_ESCAPE: input->escape = true;      break;
@@ -26,7 +32,7 @@ void Input_poll(Input *input) {
         case SDL_SCANCODE_C:      input->sit = true;         break;
         case SDL_SCANCODE_R:      input->call = true;        break;
         case SDL_SCANCODE_H:      input->dismiss_help = true; break;
-        case SDL_SCANCODE_BACKSPACE: input->cancel = true;   break;
+        case SDL_SCANCODE_BACKSPACE: input->cancel = input->erase = true; break;
         case SDL_SCANCODE_I:      input->show_keys = true;   break;
         case SDL_SCANCODE_TAB:    input->show_stats = true;  break;
         case SDL_SCANCODE_P:      input->pee = true;         break;
@@ -44,6 +50,8 @@ void Input_poll(Input *input) {
         case SDL_SCANCODE_DOWN:   input->menu_move = 1;      break;
         case SDL_SCANCODE_UP:     input->menu_move = -1;     break;
         case SDL_SCANCODE_F1:     input->dev_menu = true;    break;
+        case SDL_SCANCODE_F5:     input->quick_save = true;  break;
+        case SDL_SCANCODE_F9:     input->quick_load = true;  break;
         default: break;
         }
     }
diff --git a/entropie/src/core/input.h b/entropie/src/core/input.h
index 9bcd5cd..4c56220 100644
--- a/entropie/src/core/input.h
+++ b/entropie/src/core/input.h
@@ -43,6 +43,10 @@ typedef struct {
     int   menu_move;     /* Down / Up arrow pressed: next / previous line (dev menu) */
     bool  dev_menu;      /* F1 pressed: dev menu on / off */
     bool  pockets;       /* B pressed: your pockets open / shut */
+    char  text[32];      /* typed this frame (only while typing a name: see Platform_typing) */
+    bool  erase;         /* Backspace pressed (typing: one letter back) */
+    bool  quick_save;    /* F5 pressed */
+    bool  quick_load;    /* F9 pressed */
 } Input;
 
 /* Pumps SDL events and fills in the current frame's input. */
diff --git a/entropie/src/core/platform.c b/entropie/src/core/platform.c
index 74ebb77..4b3a095 100644
--- a/entropie/src/core/platform.c
+++ b/entropie/src/core/platform.c
@@ -100,3 +100,16 @@ void Platform_windowSize(const Platform *p, int *width, int *height) {
 void Platform_captureMouse(const Platform *p, bool captured) {
     SDL_SetWindowRelativeMouseMode(p->window, captured);
 }
+
+bool Platform_saveDir(char *out, size_t out_size) {
+    char *base = SDL_GetPrefPath("entropie", "entropie");
+    if (!base) return false;
+    snprintf(out, out_size, "%ssaves", base);
+    SDL_free(base);
+    return true;
+}
+
+void Platform_typing(const Platform *p, bool on) {
+    if (on) SDL_StartTextInput(p->window);
+    else SDL_StopTextInput(p->window);
+}
diff --git a/entropie/src/core/platform.h b/entropie/src/core/platform.h
index dd64e8d..126aa51 100644
--- a/entropie/src/core/platform.h
+++ b/entropie/src/core/platform.h
@@ -35,4 +35,9 @@ void  Platform_captureMouse(const Platform *p, bool captured);
  */
 bool  Platform_assetPath(const char *relative, char *out, size_t out_size);
 
+/* Where the saves go (the user's data folder: e.g. ~/.local/share/entropie/entropie/saves). */
+bool  Platform_saveDir(char *out, size_t out_size);
+/* Typing text on / off (a save's name): letters come as Input.text, not as keys. */
+void  Platform_typing(const Platform *p, bool on);
+
 #endif /* CORE_PLATFORM_H */
diff --git a/entropie/src/game/game.c b/entropie/src/game/game.c
index 9882dd9..a6dc4b9 100644
--- a/entropie/src/game/game.c
+++ b/entropie/src/game/game.c
@@ -212,7 +212,9 @@ int Game_stats(const Game *game, StatBar *bars, int max, const char **title, cha
     return count;
 }
 
-bool Game_init(Game *game) {
+bool Game_init(Game *game) { return Game_initWorld(game, true); }
+
+bool Game_initWorld(Game *game, bool arrive) {
     game->dark = 0.0f;
     game->show_keys = false;
     game->fast_sleep = false;
@@ -244,8 +246,14 @@ bool Game_init(Game *game) {
     }
     Camera_init(&game->camera, game->camera.position, look * 180.0f / PI_F, 0.0f, CAMERA_FOV);
     game->marker = Shapes_marker(1.8f, 0.8f);
+    for (int i = 0; i < game->world.count; i++) { /* (what the scene made: a save finds it again by this) */
+        Object *o = game->world.objects[i];
+        o->scene_id = i + 1;
+        o->spawn = o->position;
+        o->spawn_type = o->type;
+    }
     memset(&game->arrival, 0, sizeof(game->arrival));
-    if (ARRIVE_BY_CAR && !START_AT_COURSE) game_arrivalStart(game);
+    if (arrive && ARRIVE_BY_CAR && !START_AT_COURSE) game_arrivalStart(game);
     followPlayer(game, 0.0f);
     return true;
 }
diff --git a/entropie/src/game/game.h b/entropie/src/game/game.h
index 3d40071..d8fcabb 100644
--- a/entropie/src/game/game.h
+++ b/entropie/src/game/game.h
@@ -99,6 +99,8 @@ int  Game_choices(const Game *game, const char *out[TALK_CHOICES]);
 const char *Game_hint(Game *game);
 
 bool Game_init(Game *game);
+/* The world as at the start; arrive: and the drive in (a fresh game). A save is loaded onto it without. */
+bool Game_initWorld(Game *game, bool arrive);
 void Game_shutdown(Game *game);
 
 void Game_update(Game *game, const Input *input, float dt, float aspect);
diff --git a/entropie/src/game/save.c b/entropie/src/game/save.c
new file mode 100644
index 0000000..3975626
--- /dev/null
+++ b/entropie/src/game/save.c
@@ -0,0 +1,629 @@
+#define _POSIX_C_SOURCE 200809L
+#include "game/save.h"
+#include "game/game_internal.h"
+#include "world/car.h"
+#include "world/clothes.h"
+#include "world/ladder.h"
+#include "world/tent.h"
+#include "world/terrain.h"
+#include "world/weather.h"
+#include "world/wetness.h"
+#include "people/hands.h"
+#include "build_info.h"
+
+#include <dirent.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <time.h>
+
+/* ---------- What is saved: field tables ---------- */
+
+typedef enum { F_FLOAT, F_INT, F_BOOL, F_VEC3, F_REF, F_BYTE } FieldKind;
+typedef struct {
+    const char *key;
+    FieldKind   kind;
+    size_t      off;
+} Field;
+
+#define FL(k, m) { k, F_FLOAT, offsetof(Object, m) }
+#define IN(k, m) { k, F_INT,   offsetof(Object, m) }
+#define BO(k, m) { k, F_BOOL,  offsetof(Object, m) }
+#define VE(k, m) { k, F_VEC3,  offsetof(Object, m) }
+#define RE(k, m) { k, F_REF,   offsetof(Object, m) }
+#define BY(k, m) { k, F_BYTE,  offsetof(Object, m) }
+
+/* Every thing (not people) */
+static const Field THING[] = {
+    VE("pos", position), VE("rot", rotation), VE("size", size), FL("weight", weight), BO("static", is_static), BO("broken", broken),
+    IN("variant", variant), IN("sips", sips), FL("alcohol", alcohol), FL("quench", quench), FL("nourish", nourish), FL("refresh", refresh),
+    FL("wake", wake), IN("stored", stored), FL("work", work), FL("cut_at", cut_at), FL("mark", mark), VE("mark_at", mark_at),
+    VE("mark_out", mark_out), IN("branches", branches), BO("trunk_cut", trunk_cut), BO("toppled", toppled), FL("topple", topple),
+    VE("fall_dir", fall_dir), FL("fall_base_y", fall_base_y), VE("fall_origin", fall_origin), FL("fall_ground_y", fall_ground_y),
+    FL("fall_slide", fall_slide), VE("fell_plan", fell_plan), BO("fell_blocked", fell_blocked), BO("keep", keep), FL("charred", charred),
+    FL("glow", glow), FL("fuel", fuel), BO("burning", burning), FL("water", water), FL("grill", grill), BO("grilled", grilled),
+    BO("burnt", burnt), FL("extend", extend), FL("raised", raised), BO("leaning", leaning), VE("lean_normal", lean_normal),
+    FL("lean_top", lean_top), FL("stability", stability), IN("crate_empties", crate_empties), IN("tent_stage", tent_stage),
+    FL("tent_work", tent_work), IN("tent_beds", tent_beds), BO("tent_down", tent_down), RE("owner", owner), RE("owner2", owner2),
+    RE("stump", stump), RE("stink", stink), RE("on_grill", on_grill), RE("cloth_of", cloth_of), RE("cloth_host", cloth_host),
+    FL("cloth_gap", cloth_gap), BO("gate_open", gate_open), FL("gate_t", gate_t), FL("chair_out", chair_out),
+    FL("chair_goal", chair_goal), VE("tucked", tucked), BO("tucked_set", tucked_set), BO("has_home", has_home),
+    VE("home_spot", home_spot), VE("home_rot", home_rot), BO("spot_checked", spot_checked), BO("boot_open", boot_open),
+};
+
+/* People (you, everybody, the dog: what's theirs) */
+static const Field PERSON[] = {
+    VE("pos", position), VE("rot", rotation), FL("weight", weight), FL("strength", strength),
+    FL("stamina", needs.stamina), FL("hunger", needs.hunger), FL("thirst", needs.thirst), FL("bladder", needs.bladder),
+    FL("sleepiness", needs.sleepiness), FL("drunk", drunk), BO("passed_out", passed_out), FL("tolerance", tolerance),
+    FL("bladder_size", bladder_size), FL("last_drunk", last_drunk), FL("dirt", dirt), IN("scrapes", scrapes), FL("chill", chill),
+    FL("pride", temperament.pride), FL("energy", temperament.energy), FL("nerves", temperament.nerves),
+    VE("skin", look.skin), VE("hair", look.hair), VE("shirt", look.shirt), VE("pants", look.pants), VE("shoes", look.shoes),
+    FL("height", look.height), IN("hair_style", look.hair_style), BO("long_sleeves", look.long_sleeves), IN("garment", look.garment),
+    IN("top", look.top), VE("jacket", look.jacket), BO("jacket_tied", look.jacket_tied), FL("belly", look.belly),
+    BO("keeps_shirt", look.keeps_shirt), FL("wet", look.wet),
+    FL("ladder_skill", ladder_skill), FL("fitness", fitness), FL("wits", wits), FL("worn", worn), FL("height_fear", height_fear),
+    FL("top_speed", top_speed), FL("jump_speed", jump_speed), FL("pace", pace), FL("tempo", tempo), IN("main_hand", main_hand),
+    BO("female", female), BO("urinella", urinella), IN("urinella_tried", urinella_tried), IN("urinellas", urinellas),
+    BO("lighter", lighter), RE("lighter_of", lighter_of), IN("lighter_uses", lighter_uses), IN("cigarettes", cigarettes),
+    IN("tobacco", tobacco), IN("papers", papers), IN("filters", filters), BO("filter_habit", filter_habit),
+    BO("pocket_tray", pocket_tray), IN("pocket_butts", pocket_butts), BO("work_trousers", work_trousers), FL("smoker", smoker),
+    FL("craving", craving), RE("pullover", pullover), BO("pullover_down", pullover_down), RE("in_tent", in_tent),
+    BO("rain_nap", rain_nap), RE("riding", riding), IN("ride_seat", ride_seat), FL("indoors", indoors), BO("lives_here", lives_here),
+    IN("ai_mode", ai.mode), IN("ai_order", ai.order), VE("ai_area", ai.work_area), VE("ai_home", ai.home),
+    RE("ai_woodpile", ai.woodpile), BY("ai_banned", ai.banned), BY("ai_defies", ai.defies), BO("ai_unload", ai.unload),
+    BO("ai_greet", ai.greet), FL("willing", ai.character.willing),
+};
+
+#define COUNT(a) ((int)(sizeof(a) / sizeof((a)[0])))
+
+/* Things that never change while you play (buildings, walls, roads, bushes): only found again, nothing written. */
+static bool fixedThing(const Object *o) {
+    switch (o->type) {
+    case OBJ_HOUSE: case OBJ_BARN: case OBJ_WALL: case OBJ_STRUCTURE: case OBJ_DECAL: case OBJ_BUSH: case OBJ_BIN:
+    case OBJ_BOX_STATIC: case OBJ_CYLINDER_STATIC: case OBJ_SUPPLY: case OBJ_HOSE:
+        return o->scene_id > 0;
+    default:
+        return false;
+    }
+}
+
+static bool person(const Object *o) { return o->is_character || o->type == OBJ_DOG; }
+
+/* Used up and gone (not a thing anybody points at any more): not written. */
+static bool gone(const Object *o) {
+    return !person(o) && o->scene_id == 0 && o->type != OBJ_CLOTHES && o->broken && !o->mesh.vao;
+}
+
+/* ---------- Writing ---------- */
+
+typedef struct {
+    Object *const *objects;
+    int count;
+    int *id; /* save id per world index (-1: not written) */
+} Ids;
+
+static int idOf(const Ids *ids, const Object *o) {
+    if (!o) return -1;
+    for (int i = 0; i < ids->count; i++)
+        if (ids->objects[i] == o) return ids->id[i];
+    return -1;
+}
+
+static void writeFields(FILE *f, const Object *o, const Field *t, int n, const Ids *ids) {
+    const char *base = (const char *)o;
+    for (int k = 0; k < n; k++) {
+        const void *p = base + t[k].off;
+        switch (t[k].kind) {
+        case F_FLOAT: fprintf(f, "%s %.9g\n", t[k].key, (double)*(const float *)p); break;
+        case F_INT:   fprintf(f, "%s %d\n", t[k].key, *(const int *)p); break;
+        case F_BOOL:  fprintf(f, "%s %d\n", t[k].key, *(const bool *)p ? 1 : 0); break;
+        case F_BYTE:  fprintf(f, "%s %d\n", t[k].key, (int)*(const unsigned char *)p); break;
+        case F_VEC3: { const Vec3 *v = p; fprintf(f, "%s %.9g %.9g %.9g\n", t[k].key, (double)v->x, (double)v->y, (double)v->z); break; }
+        case F_REF:   fprintf(f, "%s %d\n", t[k].key, idOf(ids, *(Object *const *)p)); break;
+        }
+    }
+}
+
+static void path(char *out, size_t n, const char *dir, const char *name, const char *ext) {
+    snprintf(out, n, "%s/%s%s", dir, name, ext);
+}
+
+static bool makeDir(const char *dir) {
+    char tmp[512];
+    snprintf(tmp, sizeof tmp, "%s", dir);
+    for (char *p = tmp + 1; *p; p++)
+        if (*p == '/') { *p = '\0'; mkdir(tmp, 0755); *p = '/'; }
+    return mkdir(tmp, 0755) == 0 || errno == EEXIST;
+}
+
+const char *Save_blocked(const Game *game) {
+    if (game->arrival.stage == ARRIVAL_DRIVING) return "Not while you're driving in";
+    if (game->player && game->player->riding) return "Not while you're in the car";
+    return NULL;
+}
+
+bool Save_write(const Game *game, const char *dir, const char *name, char *err, size_t err_size) {
+    const char *why = Save_blocked(game);
+    if (why) { snprintf(err, err_size, "%s", why); return false; }
+    if (!makeDir(dir)) { snprintf(err, err_size, "Can't make the folder %s", dir); return false; }
+    char file[600], tmp[620], ground[600];
+    path(file, sizeof file, dir, name, ".sav");
+    path(ground, sizeof ground, dir, name, ".ground");
+    snprintf(tmp, sizeof tmp, "%s.tmp", file);
+    FILE *f = fopen(tmp, "w");
+    if (!f) { snprintf(err, err_size, "Can't write %s", tmp); return false; }
+
+    const World *w = &game->world;
+    Ids ids = { w->objects, w->count, calloc((size_t)(w->count > 0 ? w->count : 1), sizeof(int)) };
+    if (!ids.id) { fclose(f); snprintf(err, err_size, "Out of memory"); return false; }
+    int next = 0;
+    for (int i = 0; i < w->count; i++) ids.id[i] = gone(w->objects[i]) ? -1 : next++;
+
+    char clock[16];
+    DayCycle_format(&game->daycycle, clock, sizeof clock);
+    clock[5] = '\0'; /* (HH:MM) */
+    time_t now = time(NULL);
+    char date[32];
+    strftime(date, sizeof date, "%Y-%m-%d %H:%M", localtime(&now));
+    fprintf(f, "entropie-save %d\n", SAVE_FORMAT);
+    fprintf(f, "version %s\n", APP_VERSION);
+    fprintf(f, "label %s in the game - saved %s - %s\n", clock, date, APP_VERSION);
+
+    fprintf(f, "[game]\n");
+    fprintf(f, "time %.9g\nscale %.9g\nthird %d\n", (double)game->daycycle.seconds, (double)game->daycycle.time_scale, game->third_person);
+    fprintf(f, "cam %.9g %.9g\n", (double)game->camera.yaw, (double)game->camera.pitch);
+    fprintf(f, "rain %.9g %.9g %.9g %d %.9g\n", (double)w->rain, (double)w->rain_goal, (double)w->rain_left, w->weather, (double)w->wet);
+    fprintf(f, "wind %.9g %.9g %.9g %.9g %.9g %d\n", (double)w->wind_goal, (double)w->wind_level, (double)w->wind_angle,
+            (double)w->wind_left, (double)w->gust_t, w->wind_kind);
+    const Arrival *a = &game->arrival;
+    fprintf(f, "arrival %d %d %d %.9g %d %.9g %d %.9g %d %d\n", a->stage, a->task, a->step, (double)a->time, a->welcome,
+            (double)a->welcome_t, a->chat, (double)a->chat_wait, a->greeted, idOf(&ids, a->car));
+    fprintf(f, "told %d %d\n", game->rain_told, game->wet_told);
+
+    for (int i = 0; i < w->count; i++) {
+        const Object *o = w->objects[i];
+        if (ids.id[i] < 0) continue;
+        fprintf(f, "[o %d]\n", ids.id[i]);
+        if (o->scene_id > 0)
+            fprintf(f, "scene %d %d %.9g %.9g %.9g\n", o->scene_id, o->spawn_type, (double)o->spawn.x, (double)o->spawn.y, (double)o->spawn.z);
+        fprintf(f, "type %d\npalette %d\nmake %.9g\n", o->type, o->palette, (double)o->make_arg);
+        if (fixedThing(o)) continue;
+        if (o->name[0]) fprintf(f, "name %s\n", o->name);
+        if (!person(o) && o->broken && !o->mesh.vao) fprintf(f, "dead 1\n");
+        if (person(o)) {
+            writeFields(f, o, PERSON, COUNT(PERSON), &ids);
+            fprintf(f, "skill");
+            for (int k = 0; k < ORDER_COUNT; k++) fprintf(f, " %.9g", (double)o->skill[k]);
+            fprintf(f, "\nlikes");
+            for (int k = 0; k < ORDER_COUNT; k++) fprintf(f, " %.9g", (double)o->ai.character.likes[k]);
+            fprintf(f, "\n");
+            if (o->keys) fprintf(f, "keys %s\n", o->keys);
+            if (o->body.asleep) fprintf(f, "asleep 1\n");
+            for (int h = 0; h < 2; h++)
+                if (o->hands.item[h] && o->hands.state[h] == HAND_HOLDING) fprintf(f, "hand %d %d\n", h, idOf(&ids, o->hands.item[h]));
+        } else {
+            writeFields(f, o, THING, COUNT(THING), &ids);
+            if (o->cloth_where) fprintf(f, "where %s\n", o->cloth_where);
+            if (o->type == OBJ_CAR && o->load) {
+                fprintf(f, "load %d", o->load->n);
+                for (int k = 0; k < o->load->n; k++) fprintf(f, " %d %d", o->load->kind[k], idOf(&ids, o->load->owner[k]));
+                fprintf(f, "\n");
+            }
+        }
+    }
+    fprintf(f, "[end]\n");
+    free(ids.id);
+    bool ok = fflush(f) == 0 && !ferror(f);
+    fclose(f);
+    if (!ok) { remove(tmp); snprintf(err, err_size, "Writing %s failed", tmp); return false; }
+
+    FILE *g = fopen(ground, "wb");
+    ok = g && Terrain_writeWear(&w->terrain, g) && Wetness_write(&w->terrain.wet, g);
+    if (g) fclose(g);
+    if (!ok) { remove(tmp); snprintf(err, err_size, "Writing %s failed", ground); return false; }
+    if (rename(tmp, file) != 0) { snprintf(err, err_size, "Can't write %s", file); return false; }
+    return true;
+}
+
+/* ---------- Reading ---------- */
+
+typedef struct {
+    int        id, scene_id;
+    ObjectType spawn_type, type;
+    Vec3       spawn;
+    Palette    palette;
+    float      make;
+    int        first, lines;  /* its key lines */
+    Object    *obj;           /* found again or made anew */
+    bool       remade;
+} Rec;
+
+typedef struct {
+    char **line;
+    int    n;
+    Rec   *rec;
+    int    recs;
+    int    game_first, game_lines;
+    int    format;
+} Parsed;
+
+static char *word(char **s) {
+    while (**s == ' ') (*s)++;
+    char *start = *s;
+    while (**s && **s != ' ') (*s)++;
+    if (**s) *(*s)++ = '\0';
+    return start;
+}
+
+static float numF(char **s) { return strtof(word(s), NULL); }
+static int numI(char **s) { return (int)strtol(word(s), NULL, 10); }
+static Vec3 numV(char **s) { float x = numF(s), y = numF(s), z = numF(s); return vec3(x, y, z); }
+
+/* Old saves to the current format, step by step (none yet: format 1 is the first).
+   A step rewrites keys / values in p->line before anything is read from them. */
+static bool migrate(Parsed *p, char *err, size_t err_size) {
+    (void)err; (void)err_size;
+    /* e.g.: if (p->format < 2) { rename "foo" to "bar" in every object's lines }; p->format = 2; */
+    p->format = SAVE_FORMAT;
+    return true;
+}
+
+static bool parse(char *text, Parsed *p, char *err, size_t err_size) {
+    int cap = 1024;
+    p->line = malloc(sizeof(char *) * (size_t)cap);
+    if (!p->line) return false;
+    for (char *s = text; *s;) {
+        char *e = strchr(s, '\n');
+        if (e) *e = '\0';
+        if (*s) {
+            if (p->n == cap) {
+                cap *= 2;
+                char **more = realloc(p->line, sizeof(char *) * (size_t)cap);
+                if (!more) return false;
+                p->line = more;
+            }
+            p->line[p->n++] = s;
+        }
+        if (!e) break;
+        s = e + 1;
+    }
+    if (p->n < 1 || sscanf(p->line[0], "entropie-save %d", &p->format) != 1) { snprintf(err, err_size, "Not a save file"); return false; }
+    if (p->format > SAVE_FORMAT) { snprintf(err, err_size, "This save is from a newer version (format %d) - update the game", p->format); return false; }
+    int objs = 0;
+    for (int i = 0; i < p->n; i++) objs += !strncmp(p->line[i], "[o ", 3);
+    p->rec = calloc((size_t)(objs > 0 ? objs : 1), sizeof(Rec));
+    if (!p->rec) return false;
+    Rec *r = NULL;
+    bool in_game = false;
+    for (int i = 1; i < p->n; i++) {
+        char *l = p->line[i];
+        if (l[0] == '[') {
+            r = NULL;
+            in_game = !strcmp(l, "[game]");
+            if (in_game) { p->game_first = i + 1; p->game_lines = 0; }
+            if (!strncmp(l, "[o ", 3)) {
+                r = &p->rec[p->recs++];
+                r->id = atoi(l + 3);
+                r->first = i + 1;
+            }
+            continue;
+        }
+        if (r) r->lines++;
+        else if (in_game) p->game_lines++;
+    }
+    return migrate(p, err, err_size);
+}
+
+/* Reads the identity lines (scene, type, palette, make) of each record. */
+static void identities(Parsed *p) {
+    for (int k = 0; k < p->recs; k++) {
+        Rec *r = &p->rec[k];
+        r->type = OBJ_BOX;
+        for (int i = r->first; i < r->first + r->lines; i++) {
+            char buf[256], *s = buf;
+            snprintf(buf, sizeof buf, "%s", p->line[i]);
+            char *key = word(&s);
+            if (!strcmp(key, "scene")) { r->scene_id = numI(&s); r->spawn_type = (ObjectType)numI(&s); r->spawn = numV(&s); }
+            else if (!strcmp(key, "type")) r->type = (ObjectType)numI(&s);
+            else if (!strcmp(key, "palette")) r->palette = (Palette)numI(&s);
+            else if (!strcmp(key, "make")) r->make = numF(&s);
+        }
+    }
+}
+
+static Object *byId(const Parsed *p, int id) {
+    if (id < 0) return NULL;
+    if (id < p->recs && p->rec[id].id == id) return p->rec[id].obj;
+    for (int k = 0; k < p->recs; k++) if (p->rec[k].id == id) return p->rec[k].obj;
+    return NULL;
+}
+
+static bool applyField(Object *o, const Field *t, int n, const char *key, char *s, const Parsed *p) {
+    for (int k = 0; k < n; k++) {
+        if (strcmp(t[k].key, key)) continue;
+        void *at = (char *)o + t[k].off;
+        switch (t[k].kind) {
+        case F_FLOAT: *(float *)at = numF(&s); break;
+        case F_INT:   *(int *)at = numI(&s); break;
+        case F_BOOL:  *(bool *)at = numI(&s) != 0; break;
+        case F_BYTE:  *(unsigned char *)at = (unsigned char)numI(&s); break;
+        case F_VEC3:  *(Vec3 *)at = numV(&s); break;
+        case F_REF:   *(Object **)at = byId(p, numI(&s)); break;
+        }
+        return true;
+    }
+    return false;
+}
+
+/* A thing that turned into something else (a tree into a log) or changed
+   shape: its mesh and colliders made again for what it is now. */
+static void remake(Object *o, ObjectType type, Palette palette, Vec3 size, float make) {
+    Object *fresh = Object_create(type, palette, o->position, size, make);
+    if (!fresh) return;
+    Mesh_destroy(&o->mesh);
+    o->mesh = fresh->mesh;
+    fresh->mesh = (Mesh){ 0 };
+    o->type = type;
+    o->pivot = fresh->pivot;
+    o->size = fresh->size;
+    o->is_static = fresh->is_static;
+    memcpy(o->colliders, fresh->colliders, sizeof o->colliders);
+    o->collider_count = fresh->collider_count;
+    Object_destroy(fresh);
+}
+
+static const char *keysText(const char *saved) {
+    static const char *const ALL[] = { "Car keys", "House keys" };
+    for (size_t i = 0; i < sizeof ALL / sizeof ALL[0]; i++) if (!strcmp(saved, ALL[i])) return ALL[i];
+    return NULL;
+}
+
+/* Finds the scene's thing again: by its number, else by what and where it was made. */
+static Object *findScene(World *w, const Rec *r, bool *taken) {
+    int i = r->scene_id - 1;
+    if (i >= 0 && i < w->count && !taken[i]) {
+        Object *o = w->objects[i];
+        if (o->scene_id == r->scene_id && o->spawn_type == r->spawn_type && vec3_length(vec3_sub(o->spawn, r->spawn)) < 0.05f) { taken[i] = true; return o; }
+    }
+    for (int k = 0; k < w->count; k++) {
+        Object *o = w->objects[k];
+        if (taken[k] || o->scene_id <= 0 || o->spawn_type != r->spawn_type || vec3_length(vec3_sub(o->spawn, r->spawn)) > 0.05f) continue;
+        taken[k] = true;
+        return o;
+    }
+    return NULL;
+}
+
+static void applyGame(Game *game, const Parsed *p) {
+    World *w = &game->world;
+    for (int i = p->game_first; i < p->game_first + p->game_lines; i++) {
+        char buf[512], *s = buf;
+        snprintf(buf, sizeof buf, "%s", p->line[i]);
+        char *key = word(&s);
+        if (!strcmp(key, "time")) game->daycycle.seconds = numF(&s);
+        else if (!strcmp(key, "scale")) game->daycycle.time_scale = numF(&s);
+        else if (!strcmp(key, "third")) game->third_person = numI(&s) != 0;
+        else if (!strcmp(key, "cam")) { game->camera.yaw = numF(&s); game->camera.pitch = numF(&s); Camera_rotate(&game->camera, 0.0f, 0.0f); }
+        else if (!strcmp(key, "rain")) { w->rain = numF(&s); w->rain_goal = numF(&s); w->rain_left = numF(&s); w->weather = numI(&s); w->wet = numF(&s); }
+        else if (!strcmp(key, "wind")) {
+            w->wind_goal = numF(&s); w->wind_level = numF(&s); w->wind_angle = numF(&s); w->wind_left = numF(&s); w->gust_t = numF(&s); w->wind_kind = numI(&s);
+        } else if (!strcmp(key, "arrival")) {
+            Arrival *a = &game->arrival;
+            a->stage = (ArrivalStage)numI(&s); a->task = (ArrivalTask)numI(&s); a->step = numI(&s); a->time = numF(&s);
+            a->welcome = numI(&s); a->welcome_t = numF(&s); a->chat = numI(&s); a->chat_wait = numF(&s); a->greeted = numI(&s) != 0;
+            a->car = byId(p, numI(&s));
+        } else if (!strcmp(key, "told")) { game->rain_told = numI(&s) != 0; game->wet_told = numI(&s) != 0; }
+    }
+}
+
+bool Save_read(Game *game, const char *dir, const char *name, char *err, size_t err_size) {
+    char file[600], ground[600];
+    path(file, sizeof file, dir, name, ".sav");
+    path(ground, sizeof ground, dir, name, ".ground");
+    FILE *f = fopen(file, "rb");
+    if (!f) { snprintf(err, err_size, "Can't open %s", file); return false; }
+    fseek(f, 0, SEEK_END);
+    long bytes = ftell(f);
+    fseek(f, 0, SEEK_SET);
+    char *text = malloc((size_t)bytes + 1);
+    if (!text) { fclose(f); snprintf(err, err_size, "Out of memory"); return false; }
+    size_t got = fread(text, 1, (size_t)bytes, f);
+    text[got] = '\0';
+    fclose(f);
+
+    Parsed p = { 0 };
+    if (!parse(text, &p, err, err_size)) { free(p.line); free(p.rec); free(text); return false; }
+    identities(&p);
+
+    /* The world as at the start, without the drive in */
+    Game_shutdown(game);
+    memset(game, 0, sizeof(*game));
+    if (!Game_initWorld(game, false)) { snprintf(err, err_size, "Building the world failed"); free(p.line); free(p.rec); free(text); return false; }
+    World *w = &game->world;
+    bool *taken = calloc((size_t)w->count + 1, sizeof(bool));
+
+    /* Each thing: found again (the scene's) or made anew */
+    for (int k = 0; k < p.recs; k++) {
+        Rec *r = &p.rec[k];
+        Vec3 pos = vec3_zero(), size = vec3_zero(), rot = vec3_zero();
+        for (int i = r->first; i < r->first + r->lines; i++) {
+            char buf[256], *s = buf;
+            snprintf(buf, sizeof buf, "%s", p.line[i]);
+            char *key = word(&s);
+            if (!strcmp(key, "pos")) pos = numV(&s);
+            else if (!strcmp(key, "size")) size = numV(&s);
+            else if (!strcmp(key, "rot")) rot = numV(&s);
+        }
+        if (r->scene_id > 0) {
+            r->obj = taken ? findScene(w, r, taken) : NULL;
+            if (!r->obj) continue; /* (the scene changed since: that thing isn't there any more) */
+            bool shape = (r->type == OBJ_LOG || r->type == OBJ_FIREWOOD || r->type == OBJ_STUMP || r->type == OBJ_BRANCH) &&
+                         vec3_length(vec3_sub(size, r->obj->size)) > 1e-4f;
+            if (r->obj->type != r->type || shape) { r->obj->position = pos; remake(r->obj, r->type, r->palette, size, r->make); r->remade = true; }
+            continue;
+        }
+        Object *o = NULL;
+        switch (r->type) {
+        case OBJ_CAR:      o = Car_create(pos, rot.y); break;
+        case OBJ_CLOTHES:  o = Object_createDecal((Mesh){ 0 }, pos); if (o) o->type = OBJ_CLOTHES; break;
+        case OBJ_DECAL: case OBJ_STRUCTURE: case OBJ_WALL: case OBJ_HUMAN: case OBJ_PLAYER: case OBJ_DOG: break; /* (made by the scene only) */
+        default:           o = Object_create(r->type, r->palette, pos, size, r->make); break;
+        }
+        if (!o) continue;
+        o->palette = r->palette;
+        o->make_arg = r->make;
+        if (!World_add(w, o)) { Object_destroy(o); continue; }
+        r->obj = o;
+    }
+    free(taken);
+
+    /* Everything as it was (references now that everything is there) */
+    for (int k = 0; k < p.recs; k++) {
+        Rec *r = &p.rec[k];
+        Object *o = r->obj;
+        if (!o) continue;
+        bool is_person = person(o);
+        for (int i = r->first; i < r->first + r->lines; i++) {
+            char buf[1024], *s = buf;
+            snprintf(buf, sizeof buf, "%s", p.line[i]);
+            char *key = word(&s);
+            if (applyField(o, is_person ? PERSON : THING, is_person ? COUNT(PERSON) : COUNT(THING), key, s, &p)) continue;
+            if (!strcmp(key, "skill")) for (int q = 0; q < ORDER_COUNT; q++) o->skill[q] = numF(&s);
+            else if (!strcmp(key, "likes")) for (int q = 0; q < ORDER_COUNT; q++) o->ai.character.likes[q] = numF(&s);
+            else if (!strcmp(key, "keys")) o->keys = keysText(s);
+            else if (!strcmp(key, "where")) o->cloth_where = Clothes_whereText(s);
+            else if (!strcmp(key, "name")) snprintf(o->name, sizeof o->name, "%s", s);
+            else if (!strcmp(key, "load") && o->type == OBJ_CAR && o->load) {
+                int n = numI(&s);
+                o->load->n = 0;
+                for (int q = 0; q < n && q < CAR_LOAD_MAX; q++) {
+                    o->load->kind[q] = (CargoKind)numI(&s);
+                    o->load->owner[q] = byId(&p, numI(&s));
+                    o->load->n++;
+                }
+            }
+        }
+    }
+
+    /* How it looks again, and people back in their skins */
+    for (int k = 0; k < p.recs; k++) {
+        Rec *r = &p.rec[k];
+        Object *o = r->obj;
+        if (!o || person(o)) continue;
+        bool dead = false;
+        for (int i = r->first; i < r->first + r->lines; i++) if (!strcmp(p.line[i], "dead 1")) dead = true;
+        if (dead) { Object_useUp(o); continue; }
+        switch (o->type) {
+        case OBJ_TREE:
+            if (o->trunk_cut || o->toppled || r->remade) Object_rebuildTree(o);
+            break;
+        case OBJ_WATER:
+            if (!o->broken) { int sips = o->sips; Object_setDrink(o, o->variant); o->sips = sips; }
+            break;
+        case OBJ_BAG:      Object_fillBag(o, 0.0f); break;
+        case OBJ_TENT:     Tent_refresh(o); break;
+        case OBJ_GEAR:     Gear_refresh(o); break;
+        case OBJ_CRATE:    o->shown = -1; break;
+        case OBJ_LADDER:   Ladder_setExtend(o, o->extend); break;
+        case OBJ_CAR:
+            if (o->boot_open) { o->boot_open = false; Car_openBoot(o, true); }
+            break;
+        default: break;
+        }
+        if (Object_isBottle(o) && o->broken && o->mesh.vao) { o->broken = false; Object_shatter(o, o->position.y); }
+        o->velocity = o->angular_velocity = vec3_zero();
+        Object_syncColliders(o);
+    }
+    for (int k = 0; k < p.recs; k++) {
+        Rec *r = &p.rec[k];
+        Object *o = r->obj;
+        if (!o || !person(o)) continue;
+        o->velocity = o->movement = vec3_zero();
+        o->sitting = false;
+        o->seat = NULL;
+        o->body.replant = true;
+        o->ai.timer = 0.0f;
+        o->ai.target = o->ai.leader = NULL;
+        for (int q = 0; q < AI_SKIPS; q++) o->ai.skip[q] = NULL;
+        if (o->ai.mode != AI_IDLE && o->ai.mode != AI_WANDER && o->ai.mode != AI_WORK) o->ai.mode = AI_IDLE;
+        Object_syncColliders(o);
+        bool asleep = false;
+        for (int i = r->first; i < r->first + r->lines; i++) {
+            char buf[256], *s = buf;
+            snprintf(buf, sizeof buf, "%s", p.line[i]);
+            char *key = word(&s);
+            if (!strcmp(key, "asleep")) asleep = true;
+            else if (!strcmp(key, "hand")) {
+                int h = numI(&s);
+                Object *item = byId(&p, numI(&s));
+                if (h >= 0 && h < 2 && item && !item->is_held && !item->broken && !Hands_receive(o, h, item)) {
+                    Vec3 ahead = Object_facing(o); /* (too big for one hand: at their feet) */
+                    item->position = vec3_add(o->position, vec3(ahead.x * 0.6f, 0.05f, ahead.z * 0.6f));
+                    Object_syncColliders(item);
+                }
+            }
+        }
+        if (o->riding) {
+            Object *car = o->riding;
+            o->riding = NULL;
+            Car_getIn(car, o, o->ride_seat);
+        } else if (o->in_tent) {
+            Object *tent = o->in_tent;
+            o->in_tent = NULL;
+            Tent_crawlIn(tent, o);
+        } else if (asleep) Body_lieDown(&o->body);
+    }
+    for (int k = 0; k < p.recs; k++) /* (pullovers last: they need their owners' look) */
+        if (p.rec[k].obj && p.rec[k].obj->type == OBJ_CLOTHES) Clothes_rebuild(p.rec[k].obj);
+    if (game->arrival.car) Car_place(w, game->arrival.car, game->arrival.car->position, game->arrival.car->rotation.y);
+    applyGame(game, &p);
+
+    /* The ground: worn paths, then the wet (it lies on the paths) */
+    FILE *g = fopen(ground, "rb");
+    if (g) {
+        if (Terrain_readWear(&w->terrain, g)) Wetness_read(&w->terrain, g);
+        fclose(g);
+    }
+    free(p.line);
+    free(p.rec);
+    free(text);
+    return true;
+}
+
+/* ---------- The list ---------- */
+
+int Save_list(const char *dir, SaveInfo *out, int max) {
+    DIR *d = opendir(dir);
+    if (!d) return 0;
+    int n = 0;
+    struct dirent *e;
+    while ((e = readdir(d)) && n < max) {
+        size_t len = strlen(e->d_name);
+        if (len < 5 || strcmp(e->d_name + len - 4, ".sav") || len - 4 >= SAVE_NAME_MAX) continue;
+        SaveInfo *s = &out[n];
+        memset(s, 0, sizeof(*s));
+        memcpy(s->name, e->d_name, len - 4);
+        char file[600];
+        path(file, sizeof file, dir, s->name, ".sav");
+        struct stat st;
+        if (stat(file, &st) == 0) s->when = (long long)st.st_mtime;
+        FILE *f = fopen(file, "r");
+        if (f) {
+            char line[160];
+            for (int k = 0; k < 4 && fgets(line, sizeof line, f); k++)
+                if (!strncmp(line, "label ", 6)) {
+                    line[strcspn(line, "\n")] = '\0';
+                    snprintf(s->label, sizeof s->label, "%.95s", line + 6);
+                }
+            fclose(f);
+        }
+        n++;
+    }
+    closedir(d);
+    for (int i = 1; i < n; i++) /* (newest first) */
+        for (int j = i; j > 0 && out[j].when > out[j - 1].when; j--) { SaveInfo t = out[j]; out[j] = out[j - 1]; out[j - 1] = t; }
+    return n;
+}
diff --git a/entropie/src/game/save.h b/entropie/src/game/save.h
new file mode 100644
index 0000000..ddaa2c7
--- /dev/null
+++ b/entropie/src/game/save.h
@@ -0,0 +1,44 @@
+#ifndef GAME_SAVE_H
+#define GAME_SAVE_H
+
+/*
+ * Saving and loading a game: a text file (<name>.sav, "key value" lines: the
+ * game, then every object) and a binary one (<name>.ground: worn paths and
+ * the wet ground). Loading builds the world as at the start (no drive in),
+ * finds the scene's things again by where they were made, makes the rest
+ * anew, and puts everything back as it was.
+ *
+ * Keeping old saves working (see ARCHITECTURE.md "Saves"):
+ * - A new field: just add it to a table below. Old saves don't have it: it
+ *   keeps what the fresh world (or Object_create) gives it.
+ * - A field renamed, or its meaning changed: raise SAVE_FORMAT and add a step
+ *   to migrate() in save.c that rewrites the old keys / values.
+ * - Saves from a newer format are refused (not guessed at).
+ */
+
+#include <stdbool.h>
+#include <stddef.h>
+
+#include "game/game.h"
+
+#define SAVE_FORMAT   1
+#define SAVE_NAME_MAX 48
+#define SAVE_LIST_MAX 32
+#define SAVE_AUTO     "autosave"
+
+typedef struct {
+    char      name[SAVE_NAME_MAX];  /* file name without .sav */
+    char      label[96];            /* "Day 1, 14:05 - 2.18.0" */
+    long long when;                 /* written (seconds since 1970): newest first */
+} SaveInfo;
+
+/* Writes the game as <dir>/<name>.sav (and .ground). False: why, in err. */
+bool Save_write(const Game *game, const char *dir, const char *name, char *err, size_t err_size);
+/* Loads <dir>/<name>.sav into game: it is shut down and built again first. */
+bool Save_read(Game *game, const char *dir, const char *name, char *err, size_t err_size);
+/* The saves in dir, newest first; how many. */
+int  Save_list(const char *dir, SaveInfo *out, int max);
+/* Saving isn't possible right now (driving in): why, or NULL. */
+const char *Save_blocked(const Game *game);
+
+#endif /* GAME_SAVE_H */
diff --git a/entropie/src/main.c b/entropie/src/main.c
index baf8c78..3dcb529 100644
--- a/entropie/src/main.c
+++ b/entropie/src/main.c
@@ -5,7 +5,9 @@
  * Layers:  core (platform, gl, input)  ->  render  ->  game  ->  main
  */
 
+#include <math.h>
 #include <stdio.h>
+#include <string.h>
 #include <SDL3/SDL.h>
 
 #include "build_info.h" /* generated by the Makefile */
@@ -15,6 +17,7 @@
 #include "render/renderer.h"
 #include "render/skybox.h"
 #include "render/ui.h"
+#include "game/save.h"
 
 #define APP_NAME      "Entropie"
 #define WINDOW_WIDTH  800
@@ -31,7 +34,7 @@
 #define STATS_LABEL   48  /* the stats panel */
 #define STATS_MAX     12
 #define DEV_LABEL     64  /* the dev menu (F1) */
-#define MENU_LABEL    80  /* the menu (Esc) */
+#define MENU_LABEL    104 /* the menus (start, Esc, load, save as): up to 16 slots */
 #define POCKET_LABEL  88  /* your pockets (B) */
 
 static const char *const KEYS[] = { /* proportional font: no column padding */
@@ -64,7 +67,7 @@ static const char *const KEYS[] = { /* proportional font: no column padding */
     "F1:  dev menu (change anybody's stats)",
     "Tab:  your stats (arrow keys: next page)",
     "K:  smoke;   B:  your pockets (Up / Down, Enter: take out or give)",
-    "Esc:  menu (resume, keys, quit)",
+    "Esc:  menu (resume, save, load, keys, quit);  F5 quick save, F9 quick load",
 };
 #define KEY_COUNT ((int)(sizeof(KEYS) / sizeof(KEYS[0])))
 #define MAX_FRAME_DT  0.1f /* avoids tunneling after stalls (window drag etc.) */
@@ -128,46 +131,134 @@ static void drawPockets(Ui *ui, Game *game, int width, int height) {
     }
 }
 
-/* ---------- The menu (Esc): the game waits meanwhile ---------- */
+/* ---------- Menus: the start, Esc (the game waits meanwhile), loading, naming a save ---------- */
 
-enum { MENU_RESUME, MENU_KEYS, MENU_QUIT, MENU_COUNT };
-static const char *const MENU_TEXT[MENU_COUNT] = { "Resume", "Keys (show / hide)", "Quit" };
-#define MENU_W   380.0f
-#define MENU_BH   52.0f  /* button height */
-#define MENU_GAP  14.0f
+typedef enum { SCREEN_NONE, SCREEN_START, SCREEN_PAUSE, SCREEN_LOAD, SCREEN_NAME } Screen;
+typedef enum { ACT_NONE, ACT_NEW, ACT_CONTINUE, ACT_LOAD_LIST, ACT_QUIT, ACT_RESUME, ACT_SAVE_AS, ACT_QUICK_SAVE,
+               ACT_KEYS, ACT_LOAD, ACT_BACK, ACT_NAME, ACT_SAVE_NAMED } Action;
+#define MENU_ITEMS     14
+#define MENU_W         520.0f
+#define MENU_BH        44.0f  /* button height */
+#define MENU_GAP       10.0f
+#define AUTOSAVE_EVERY 300.0f /* s of play between autosaves */
+#define QUICK_SAVE     "quicksave"
+
+typedef struct {
+    Screen   screen, back;            /* back: where "Back" / Esc goes from the load list or the name */
+    int      chosen, count;
+    char     item[MENU_ITEMS][UI_TEXT_MAX];
+    Action   act[MENU_ITEMS];
+    int      arg[MENU_ITEMS];         /* ACT_LOAD: which save */
+    const char *title;
+    SaveInfo saves[SAVE_LIST_MAX];
+    int      save_count;
+    char     name[SAVE_NAME_MAX];     /* a save's name being typed */
+    char     dir[512];                /* where the saves go */
+} Menu;
+
+static void menuAdd(Menu *m, Action act, int arg, const char *text) {
+    if (m->count >= MENU_ITEMS) return;
+    snprintf(m->item[m->count], UI_TEXT_MAX, "%s", text);
+    m->act[m->count] = act;
+    m->arg[m->count] = arg;
+    m->count++;
+}
+
+/* Fills the menu for a screen. */
+static void menuOpen(Menu *m, Screen screen) {
+    m->screen = screen;
+    m->count = 0;
+    m->chosen = 0;
+    char line[UI_TEXT_MAX];
+    switch (screen) {
+    case SCREEN_START: {
+        m->title = APP_NAME " " APP_VERSION;
+        m->save_count = Save_list(m->dir, m->saves, SAVE_LIST_MAX);
+        int autosave = -1;
+        for (int i = 0; i < m->save_count; i++) if (!strcmp(m->saves[i].name, SAVE_AUTO)) autosave = i;
+        if (autosave >= 0) {
+            snprintf(line, sizeof line, "Continue  (%.60s)", m->saves[autosave].label);
+            menuAdd(m, ACT_CONTINUE, autosave, line);
+        }
+        menuAdd(m, ACT_NEW, 0, "New game");
+        if (m->save_count > 0) menuAdd(m, ACT_LOAD_LIST, 0, "Load a save");
+        menuAdd(m, ACT_QUIT, 0, "Quit");
+        break;
+    }
+    case SCREEN_PAUSE:
+        m->title = "Paused";
+        menuAdd(m, ACT_RESUME, 0, "Resume");
+        menuAdd(m, ACT_SAVE_AS, 0, "Save as ...");
+        menuAdd(m, ACT_QUICK_SAVE, 0, "Quick save  (F5; F9 loads it)");
+        menuAdd(m, ACT_LOAD_LIST, 0, "Load a save");
+        menuAdd(m, ACT_KEYS, 0, "Keys (show / hide)");
+        menuAdd(m, ACT_QUIT, 0, "Quit  (autosaves)");
+        break;
+    case SCREEN_LOAD:
+        m->title = "Load a save";
+        m->save_count = Save_list(m->dir, m->saves, SAVE_LIST_MAX);
+        for (int i = 0; i < m->save_count && m->count < MENU_ITEMS - 1; i++) {
+            snprintf(line, sizeof line, "%.24s  -  %.60s", m->saves[i].name, m->saves[i].label);
+            menuAdd(m, ACT_LOAD, i, line);
+        }
+        menuAdd(m, ACT_BACK, 0, "Back");
+        break;
+    case SCREEN_NAME:
+        m->title = "Save as (type a name, Enter)";
+        snprintf(line, sizeof line, "%s_", m->name);
+        menuAdd(m, ACT_NAME, 0, line);
+        menuAdd(m, ACT_SAVE_NAMED, 0, "Save");
+        menuAdd(m, ACT_BACK, 0, "Back");
+        m->chosen = 1;
+        break;
+    case SCREEN_NONE:
+        break;
+    }
+}
 
 /* Button k's box (pixels, y from the bottom). */
-static void menuButton(int k, int width, int height, float *x, float *y) {
-    float total = (float)MENU_COUNT * (MENU_BH + MENU_GAP) - MENU_GAP;
+static void menuButton(const Menu *m, int k, int width, int height, float *x, float *y) {
+    float total = (float)m->count * (MENU_BH + MENU_GAP) - MENU_GAP;
     *x = ((float)width - MENU_W) * 0.5f + 20.0f;
     *y = (float)height * 0.5f + total * 0.5f - (float)(k + 1) * MENU_BH - (float)k * MENU_GAP - 20.0f;
 }
 
 /* The button under the pointer, or -1. */
-static int menuHit(const Platform *platform, const Input *in, int width, int height) {
+static int menuHit(const Menu *m, const Platform *platform, const Input *in, int width, int height) {
     int ww, wh;
     Platform_windowSize(platform, &ww, &wh);
     if (ww <= 0 || wh <= 0) return -1;
     float mx = in->mouse_x * (float)width / (float)ww, my = (float)height - in->mouse_y * (float)height / (float)wh;
-    for (int k = 0; k < MENU_COUNT; k++) {
+    for (int k = 0; k < m->count; k++) {
         float x, y;
-        menuButton(k, width, height, &x, &y);
+        menuButton(m, k, width, height, &x, &y);
         if (mx >= x && mx <= x + MENU_W - 40.0f && my >= y && my <= y + MENU_BH) return k;
     }
     return -1;
 }
 
-static void drawMenu(Ui *ui, int chosen, int width, int height) {
-    float total = (float)MENU_COUNT * (MENU_BH + MENU_GAP) - MENU_GAP + 40.0f + 50.0f;
-    float px = ((float)width - MENU_W) * 0.5f, py = (float)height * 0.5f - total * 0.5f + 25.0f;
+static void drawMenu(Ui *ui, const Menu *m, const char *note, int width, int height) {
+    float total = (float)m->count * (MENU_BH + MENU_GAP) - MENU_GAP + 40.0f + 50.0f + (note ? 36.0f : 0.0f);
+    float px = ((float)width - MENU_W) * 0.5f, py = (float)height * 0.5f - total * 0.5f + 25.0f - (note ? 36.0f : 0.0f);
     Ui_drawPatch(ui, px, py - 20.0f, MENU_W, total, 0.93f, 0.85f, 0.67f, 0.95f, 1.0f, width, height);
-    Ui_drawTextLeft(ui, MENU_LABEL, "Paused", px + 24.0f, py + total - 70.0f, width, height);
-    for (int k = 0; k < MENU_COUNT; k++) {
+    Ui_drawTextLeft(ui, MENU_LABEL, m->title, px + 24.0f, py + total - 70.0f, width, height);
+    for (int k = 0; k < m->count; k++) {
         float x, y;
-        menuButton(k, width, height, &x, &y);
-        bool on = k == chosen;
+        menuButton(m, k, width, height, &x, &y);
+        bool on = k == m->chosen;
         Ui_drawPatch(ui, x, y, MENU_W - 40.0f, MENU_BH, on ? 0.95f : 0.86f, on ? 0.72f : 0.80f, on ? 0.40f : 0.66f, 1.0f, 0.6f, width, height);
-        Ui_drawTextLeft(ui, MENU_LABEL + 1 + k, MENU_TEXT[k], x + 22.0f, y + 12.0f, width, height);
+        Ui_drawTextLeft(ui, MENU_LABEL + 1 + k, m->item[k], x + 18.0f, y + 9.0f, width, height);
+    }
+    if (note) Ui_drawTextLeft(ui, MENU_LABEL + 1 + MENU_ITEMS, note, px + 24.0f, py - 6.0f, width, height);
+}
+
+/* A name typed in: letters, digits, - and _ (spaces become -). */
+static void typeName(char *name, const Input *in) {
+    size_t n = strlen(name);
+    if (in->erase && n > 0) name[--n] = '\0';
+    for (const char *c = in->text; *c && n + 1 < SAVE_NAME_MAX - 1; c++) {
+        char ch = *c == ' ' ? '-' : *c;
+        if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') { name[n++] = ch; name[n] = '\0'; }
     }
 }
 
@@ -194,48 +285,137 @@ int main(int argc, char **argv) {
 
     Platform_frameDelta(&platform); /* reset timer after loading */
     float anim_time = 0.0f;         /* real seconds, drives the painted animation */
-    bool  menu_open = false, look_settle = false;
-    int   menu_choice = 0;
-    float menu_mouse_x = -1.0f, menu_mouse_y = -1.0f;
+    bool  look_settle = false;
+    float menu_mouse_x = -1.0f, menu_mouse_y = -1.0f, autosave_t = 0.0f, note_time = 0.0f;
+    char  note[UI_TEXT_MAX] = "";
+    static Menu menu;
+    if (!Platform_saveDir(menu.dir, sizeof menu.dir)) snprintf(menu.dir, sizeof menu.dir, "saves");
+    menuOpen(&menu, SCREEN_START);  /* (the world waits behind it: a new game starts from here) */
+    Platform_captureMouse(&platform, false);
 
     for (;;) {
         float dt = Platform_frameDelta(&platform);
         if (dt > MAX_FRAME_DT) dt = MAX_FRAME_DT;
         anim_time += dt;
+        note_time = fmaxf(0.0f, note_time - dt);
 
         Input input;
         Input_poll(&input);
-        if (input.quit) break;
+        bool playing = menu.screen == SCREEN_NONE || menu.screen == SCREEN_PAUSE || menu.back == SCREEN_PAUSE;
+        if (input.quit) {
+            char err[160];
+            if (playing && menu.screen != SCREEN_START) Save_write(&game, menu.dir, SAVE_AUTO, err, sizeof err); /* (closing the window: autosaved) */
+            break;
+        }
 
         int width, height;
         Platform_drawableSize(&platform, &width, &height);
 
-        if (input.escape) { /* the menu, or back to the game */
-            menu_open = !menu_open;
-            menu_choice = MENU_RESUME;
-            Platform_captureMouse(&platform, !menu_open);
+        Action act = ACT_NONE;
+        int arg = 0;
+        if (input.escape) { /* the menu, or back to the game (from a list: back a step) */
+            if (menu.screen == SCREEN_NONE) menuOpen(&menu, SCREEN_PAUSE);
+            else if (menu.screen == SCREEN_PAUSE) act = ACT_RESUME;
+            else if (menu.screen == SCREEN_LOAD || menu.screen == SCREEN_NAME) act = ACT_BACK;
+            Platform_captureMouse(&platform, menu.screen == SCREEN_NONE);
             look_settle = true;
         }
-        if (menu_open) {
-            int hit = menuHit(&platform, &input, width, height);
-            if (hit >= 0 && (input.mouse_x != menu_mouse_x || input.mouse_y != menu_mouse_y)) menu_choice = hit;
+        if (menu.screen == SCREEN_NONE && (input.quick_save || input.quick_load)) {
+            char err[160];
+            if (input.quick_save) {
+                bool ok = Save_write(&game, menu.dir, QUICK_SAVE, err, sizeof err);
+                snprintf(note, sizeof note, ok ? "Quick saved" : "Not saved: %s", err);
+            } else {
+                bool ok = Save_read(&game, menu.dir, QUICK_SAVE, err, sizeof err);
+                snprintf(note, sizeof note, ok ? "Quick save loaded" : "Not loaded: %s", err);
+                look_settle = true;
+            }
+            note_time = 3.0f;
+        }
+        if (menu.screen != SCREEN_NONE && act == ACT_NONE) {
+            int hit = menuHit(&menu, &platform, &input, width, height);
+            if (hit >= 0 && (input.mouse_x != menu_mouse_x || input.mouse_y != menu_mouse_y)) menu.chosen = hit;
             menu_mouse_x = input.mouse_x;
             menu_mouse_y = input.mouse_y;
-            if (input.menu_move) menu_choice = (menu_choice + input.menu_move + MENU_COUNT) % MENU_COUNT;
-            int pick = input.enter ? menu_choice : (input.click && hit >= 0 ? hit : -1);
-            if (pick == MENU_QUIT) break;
-            if (pick == MENU_KEYS) game.show_keys = !game.show_keys;
-            if (pick == MENU_RESUME) {
-                menu_open = false;
+            if (input.menu_move) menu.chosen = (menu.chosen + input.menu_move + menu.count) % menu.count;
+            if (menu.screen == SCREEN_NAME) { /* typing the name */
+                typeName(menu.name, &input);
+                snprintf(menu.item[0], UI_TEXT_MAX, "%s_", menu.name);
+            }
+            int pick = input.enter ? menu.chosen : (input.click && hit >= 0 ? hit : -1);
+            if (menu.screen == SCREEN_NAME && input.enter) pick = 1; /* (Enter: save it) */
+            if (pick >= 0 && pick < menu.count) { act = menu.act[pick]; arg = menu.arg[pick]; }
+        }
+
+        char err[160] = "";
+        switch (act) {
+        case ACT_NEW: case ACT_RESUME:
+            menu.screen = SCREEN_NONE;
+            menu.back = SCREEN_NONE;
+            Platform_captureMouse(&platform, true);
+            look_settle = true;
+            break;
+        case ACT_CONTINUE: case ACT_LOAD:
+            if (Save_read(&game, menu.dir, menu.saves[arg].name, err, sizeof err)) {
+                snprintf(note, sizeof note, "Loaded %s", menu.saves[arg].name);
+                menu.screen = SCREEN_NONE;
+                menu.back = SCREEN_NONE;
                 Platform_captureMouse(&platform, true);
                 look_settle = true;
-            }
-        } else {
+                autosave_t = 0.0f;
+            } else snprintf(note, sizeof note, "Not loaded: %.80s", err);
+            note_time = 3.0f;
+            break;
+        case ACT_LOAD_LIST:
+            menu.back = menu.screen;
+            menuOpen(&menu, SCREEN_LOAD);
+            break;
+        case ACT_SAVE_AS:
+            if (Save_blocked(&game)) { snprintf(note, sizeof note, "%s", Save_blocked(&game)); note_time = 3.0f; break; }
+            menu.back = SCREEN_PAUSE;
+            menu.name[0] = '\0';
+            menuOpen(&menu, SCREEN_NAME);
+            Platform_typing(&platform, true);
+            break;
+        case ACT_SAVE_NAMED:
+            if (!menu.name[0]) break;
+            if (Save_write(&game, menu.dir, menu.name, err, sizeof err)) snprintf(note, sizeof note, "Saved as %s", menu.name);
+            else snprintf(note, sizeof note, "Not saved: %.80s", err);
+            note_time = 3.0f;
+            Platform_typing(&platform, false);
+            menuOpen(&menu, SCREEN_PAUSE);
+            menu.back = SCREEN_NONE;
+            break;
+        case ACT_QUICK_SAVE:
+            if (Save_write(&game, menu.dir, QUICK_SAVE, err, sizeof err)) snprintf(note, sizeof note, "Quick saved");
+            else snprintf(note, sizeof note, "Not saved: %.80s", err);
+            note_time = 3.0f;
+            break;
+        case ACT_KEYS:
+            game.show_keys = !game.show_keys;
+            break;
+        case ACT_BACK:
+            if (menu.screen == SCREEN_NAME) Platform_typing(&platform, false);
+            menuOpen(&menu, menu.back == SCREEN_NONE ? SCREEN_PAUSE : menu.back);
+            menu.back = SCREEN_NONE;
+            break;
+        case ACT_QUIT:
+            if (menu.screen == SCREEN_PAUSE) Save_write(&game, menu.dir, SAVE_AUTO, err, sizeof err);
+            goto done;
+        case ACT_NONE: case ACT_NAME:
+            break;
+        }
+
+        if (menu.screen == SCREEN_NONE) {
             if (look_settle) { /* (the first mouse move after the menu is a jump: ignored) */
                 input.look_yaw = input.look_pitch = 0.0f;
                 look_settle = false;
             }
             Game_update(&game, &input, dt, (float)width / (float)height);
+            if ((autosave_t += dt) >= AUTOSAVE_EVERY && !Save_blocked(&game)) { /* (every few minutes, quietly) */
+                autosave_t = 0.0f;
+                if (Save_write(&game, menu.dir, SAVE_AUTO, err, sizeof err)) { snprintf(note, sizeof note, "Autosaved"); note_time = 2.0f; }
+            }
         }
 
         Renderer_beginFrame(&renderer, width, height, anim_time);
@@ -274,10 +454,12 @@ int main(int argc, char **argv) {
         }
         const char *hint = Game_hint(&game);
         if (hint) Ui_drawTextLine(&ui, HINT_LABEL, hint, answers, UI_BOTTOM_LEFT, width, height);
-        if (menu_open) drawMenu(&ui, menu_choice, width, height);
+        if (menu.screen != SCREEN_NONE) drawMenu(&ui, &menu, note_time > 0.0f ? note : NULL, width, height);
+        else if (note_time > 0.0f) Ui_drawText(&ui, MENU_LABEL + 1 + MENU_ITEMS, note, UI_CENTER, width, height);
 
         Platform_swap(&platform);
     }
+done:
     exit_code = 0;
 
 cleanup:
diff --git a/entropie/src/render/ui.h b/entropie/src/render/ui.h
index 86aa0d5..395f77b 100644
--- a/entropie/src/render/ui.h
+++ b/entropie/src/render/ui.h
@@ -9,7 +9,7 @@
 #include "core/gl.h"
 
 #define UI_TEXT_MAX 96
-#define UI_LABELS   100
+#define UI_LABELS   140
 
 typedef enum {
     UI_TOP_LEFT,
diff --git a/entropie/src/world/car.c b/entropie/src/world/car.c
index 0772dca..4c65cbf 100644
--- a/entropie/src/world/car.c
+++ b/entropie/src/world/car.c
@@ -97,6 +97,14 @@ Object *Gear_create(GearKind kind, Object *owner, Vec3 at) {
     return item;
 }
 
+void Gear_refresh(Object *gear) {
+    if (gear->type != OBJ_GEAR) return;
+    Mesh mesh = Shapes_gear(gear->variant, gearColor((GearKind)gear->variant, gear->owner));
+    if (!mesh.vao) return;
+    Mesh_destroy(&gear->mesh);
+    gear->mesh = mesh;
+}
+
 Object *Car_takeOut(World *world, Object *car) {
     CarLoad *l = car->load;
     if (!l || l->n == 0 || !car->boot_open) return NULL;
diff --git a/entropie/src/world/car.h b/entropie/src/world/car.h
index 6157cad..893f733 100644
--- a/entropie/src/world/car.h
+++ b/entropie/src/world/car.h
@@ -66,5 +66,7 @@ void        Car_serve(World *world);
 
 /* Somebody's camping thing (OBJ_GEAR) lying at `at` (not added to the world yet), or NULL. */
 Object *Gear_create(GearKind kind, Object *owner, Vec3 at);
+/* Its look again in its owner's colours (after loading a save). */
+void    Gear_refresh(Object *gear);
 
 #endif /* WORLD_CAR_H */
diff --git a/entropie/src/world/clothes.c b/entropie/src/world/clothes.c
index 75cf4ee..02109b6 100644
--- a/entropie/src/world/clothes.c
+++ b/entropie/src/world/clothes.c
@@ -143,6 +143,7 @@ bool Clothes_putDown(World *world, Object *who, const ClothSpot *s) {
         c->position = s->at;
     }
     c->variant     = (int)s->lay;
+    c->cloth_gap   = fmaxf(s->gap, 0.04f);
     c->rotation    = vec3(0.0f, s->yaw, 0.0f);
     c->cloth_of    = who;
     c->cloth_host  = s->host;
@@ -168,6 +169,23 @@ void Clothes_takeBack(Object *who, bool wear) {
     who->look.jacket_tied = who->look.top != TOP_JACKET;
 }
 
+const char *Clothes_whereText(const char *saved) {
+    static const char *const ALL[] = { "over the chair's back", "on the ground", "over the railing", "over the wall", "over the bench",
+                                       "over the chair", "over it", "on the table", "on the bench", "on the chair", "on top of it" };
+    for (size_t i = 0; saved && i < sizeof ALL / sizeof ALL[0]; i++)
+        if (!strcmp(saved, ALL[i])) return ALL[i];
+    return "somewhere";
+}
+
+void Clothes_rebuild(Object *c) {
+    const Object *who = c->cloth_of;
+    if (c->type != OBJ_CLOTHES || !who || !who->pullover_down || who->pullover != c) return;
+    Mesh mesh = Shapes_pullover(c->variant, who->look.jacket, fmaxf(c->cloth_gap, 0.04f));
+    if (!mesh.vao) return;
+    Mesh_destroy(&c->mesh);
+    c->mesh = mesh;
+}
+
 const char *Clothes_where(const Object *who) {
     return who->pullover_down && who->pullover ? who->pullover->cloth_where : NULL;
 }
diff --git a/entropie/src/world/clothes.h b/entropie/src/world/clothes.h
index d5d162f..4995c80 100644
--- a/entropie/src/world/clothes.h
+++ b/entropie/src/world/clothes.h
@@ -32,6 +32,10 @@ bool        Clothes_putDown(World *world, Object *who, const ClothSpot *spot);
 void        Clothes_takeBack(Object *who, bool wear);
 /* Where theirs is ("over the chair's back"), or NULL: with them. */
 const char *Clothes_where(const Object *who);
+/* The same words as said when it was put down (loading a save: the text back to one of ours). */
+const char *Clothes_whereText(const char *saved);
+/* A pullover put down: its look again (after loading a save). */
+void        Clothes_rebuild(Object *cloth);
 /* Somebody's pullover lying near this ray (from the eye, dir unit) within reach, or NULL. */
 Object     *Clothes_alongRay(const World *world, Vec3 eye, Vec3 dir, float reach);
 /* Every frame: pullovers go along with the chair they hang on; people sitting
diff --git a/entropie/src/world/object.c b/entropie/src/world/object.c
index 61d6043..6b8eaae 100644
--- a/entropie/src/world/object.c
+++ b/entropie/src/world/object.c
@@ -97,6 +97,8 @@ Object *Object_create(ObjectType type, Palette palette, Vec3 position, Vec3 size
     if (!obj) return NULL;
 
     obj->type     = type;
+    obj->palette  = palette;
+    obj->make_arg = weight;
     obj->hands.drink_hand = -1;
     obj->work_state.since = 1e3f; /* hasn't worked yet */
     obj->main_hand = HAND_RIGHT;
@@ -1005,6 +1007,10 @@ static void rebuildTree(Object *obj) {
     Object_syncColliders(obj);
 }
 
+void Object_rebuildTree(Object *obj) {
+    if (obj->type == OBJ_TREE) rebuildTree(obj);
+}
+
 void Object_cutTrunk(Object *obj, float height) {
     if (obj->type != OBJ_TREE) return;
     height = clampf(height, 0.1f, obj->size.y - 0.5f);
diff --git a/entropie/src/world/object.h b/entropie/src/world/object.h
index 9e04483..38c66eb 100644
--- a/entropie/src/world/object.h
+++ b/entropie/src/world/object.h
@@ -127,6 +127,12 @@ typedef struct {
 
 typedef struct Object {
     ObjectType type;
+    Palette    palette;       /* as made (Object_create) ... */
+    float      make_arg;      /* ... and the weight it was made with (crates, tents, gear: which one) */
+    int        scene_id;      /* made by the scene (1..): saves find it again by this ... */
+    Vec3       spawn;         /* ... and where it was made ... */
+    ObjectType spawn_type;    /* ... as what */
+    float      cloth_gap;     /* clothes: hung over an edge this thick */
     Vec3       position;      /* base point (bottom center) */
     Vec3       rotation;      /* Euler radians, applied Y, X, Z */
     Vec3       pivot;         /* mesh origin relative to position */
@@ -472,6 +478,8 @@ void    Object_cutTrunk(Object *obj, float height);
 void    Object_setBranches(Object *obj, int branches);
 
 /* Bottles and trees: starts falling over so the top lands in direction dir (horizontal). */
+/* A tree's mesh and colliders again from its trunk, crown and branches (after loading a save). */
+void    Object_rebuildTree(Object *obj);
 void    Object_topple(Object *obj, Vec3 dir);
 /* Turns a fallen tree (or a log) into a log of this length lying along axis,
    centered at center. Weight follows from the wood volume. */
diff --git a/entropie/src/world/tent.c b/entropie/src/world/tent.c
index fd013df..f7dd374 100644
--- a/entropie/src/world/tent.c
+++ b/entropie/src/world/tent.c
@@ -96,6 +96,11 @@ void Tent_takeDown(Object *const *objects, int count, Object *tent) {
     reshape(tent);
 }
 
+void Tent_refresh(Object *tent) {
+    tent->shown = -1;
+    reshape(tent);
+}
+
 bool Tent_movable(const Object *tent) {
     return tent && tent->type == OBJ_TENT && !tent->broken &&
            (tent->tent_stage < TENT_PEGS || (tent->tent_stage == TENT_PEGS && tent->stored == 0) || (tent->tent_down && tent->tent_stage <= TENT_COVER));
diff --git a/entropie/src/world/tent.h b/entropie/src/world/tent.h
index aa91650..232cefb 100644
--- a/entropie/src/world/tent.h
+++ b/entropie/src/world/tent.h
@@ -26,6 +26,8 @@ int         Tent_work(Object *tent, Object *who, float dt);
 void        Tent_takeDown(Object *const *objects, int count, Object *tent);
 /* No pegs in it (spread out, on its poles, the cover on, the pegs out): it can be moved. */
 bool        Tent_movable(const Object *tent);
+/* Its look and box again for how far it's up (after loading a save). */
+void        Tent_refresh(Object *tent);
 /* Picked up to move it: from now on it goes up again (a tent being taken down
    with its pegs out stays as it is, waiting for its pegs). */
 void        Tent_startMove(Object *tent);
diff --git a/entropie/src/world/terrain.c b/entropie/src/world/terrain.c
index b607a04..c73fbea 100644
--- a/entropie/src/world/terrain.c
+++ b/entropie/src/world/terrain.c
@@ -334,6 +334,19 @@ void Terrain_destroy(Terrain *t) {
     memset(t, 0, sizeof(*t));
 }
 
+bool Terrain_writeWear(const Terrain *t, FILE *f) {
+    unsigned res = TERRAIN_WEAR_RES;
+    return fwrite(&res, 4, 1, f) == 1 && fwrite(t->wear, sizeof(float), (size_t)res * res, f) == (size_t)res * res;
+}
+
+bool Terrain_readWear(Terrain *t, FILE *f) {
+    unsigned res;
+    if (fread(&res, 4, 1, f) != 1 || res != TERRAIN_WEAR_RES) return false;
+    if (fread(t->wear, sizeof(float), (size_t)res * res, f) != (size_t)res * res) return false;
+    t->wear_dirty = true;
+    return true;
+}
+
 /* Bilinear sample of the wear map, matching GPU linear filtering. */
 static float wearAt(const Terrain *t, float x, float z) {
     float gx = clampf((x + HALF) / WEAR_CELL - 0.5f, 0.0f, (float)(TERRAIN_WEAR_RES - 1));
diff --git a/entropie/src/world/terrain.h b/entropie/src/world/terrain.h
index 6421b53..137af15 100644
--- a/entropie/src/world/terrain.h
+++ b/entropie/src/world/terrain.h
@@ -15,6 +15,7 @@
  */
 
 #include <stdbool.h>
+#include <stdio.h>
 
 #include "core/gl.h"
 #include "world/object.h"
@@ -73,6 +74,9 @@ Vec3  Terrain_grassColor(float x, float z);
 
 /* How trampled (0 meadow .. 1 bare path). */
 float Terrain_wear(const Terrain *t, float x, float z);
+/* Saves: the worn paths out to / in from a file. */
+bool  Terrain_writeWear(const Terrain *t, FILE *f);
+bool  Terrain_readWear(Terrain *t, FILE *f);
 
 /* How hard the surface is (0 meadow .. 1 tar or concrete; gravel in between). */
 float Terrain_hardness(const Terrain *t, float x, float z);
diff --git a/entropie/src/world/wetness.c b/entropie/src/world/wetness.c
index 427f30d..60f68ff 100644
--- a/entropie/src/world/wetness.c
+++ b/entropie/src/world/wetness.c
@@ -90,6 +90,46 @@ static WetChunk *newChunk(Terrain *t, int cx, int cz) {
     return c;
 }
 
+#define WET_SAVE_TAG 0x57455431u /* "WET1" */
+
+bool Wetness_write(const Wetness *w, FILE *f) {
+    unsigned tag = WET_SAVE_TAG, n = 0, res = WET_RES, chunk = WET_CHUNK;
+    for (int i = 0; i < w->live_count; i++) { const WetChunk *c = w->chunks[w->live[i]]; if (c && !c->dead) n++; }
+    if (fwrite(&tag, 4, 1, f) != 1 || fwrite(&res, 4, 1, f) != 1 || fwrite(&chunk, 4, 1, f) != 1 || fwrite(&n, 4, 1, f) != 1) return false;
+    for (int i = 0; i < w->live_count; i++) {
+        const WetChunk *c = w->chunks[w->live[i]];
+        if (!c || c->dead) continue;
+        int head[2] = { c->cx, c->cz };
+        if (fwrite(head, sizeof head, 1, f) != 1 || fwrite(c->water, sizeof c->water, 1, f) != 1 || fwrite(c->soil, sizeof c->soil, 1, f) != 1 ||
+            fwrite(c->pee, sizeof c->pee, 1, f) != 1 || fwrite(c->mud, sizeof c->mud, 1, f) != 1) return false;
+    }
+    return true;
+}
+
+bool Wetness_read(Terrain *t, FILE *f) {
+    unsigned tag, n, res, chunk;
+    if (fread(&tag, 4, 1, f) != 1 || tag != WET_SAVE_TAG || fread(&res, 4, 1, f) != 1 || fread(&chunk, 4, 1, f) != 1 || fread(&n, 4, 1, f) != 1) return false;
+    if (res != WET_RES || chunk != WET_CHUNK) return false; /* (another grid: the ground starts dry) */
+    for (unsigned k = 0; k < n; k++) {
+        int head[2];
+        WetChunk tmp;
+        if (fread(head, sizeof head, 1, f) != 1 || fread(tmp.water, sizeof tmp.water, 1, f) != 1 || fread(tmp.soil, sizeof tmp.soil, 1, f) != 1 ||
+            fread(tmp.pee, sizeof tmp.pee, 1, f) != 1 || fread(tmp.mud, sizeof tmp.mud, 1, f) != 1) return false;
+        if (head[0] < 0 || head[1] < 0 || head[0] >= WET_CHUNKS || head[1] >= WET_CHUNKS) continue;
+        WetChunk *c = chunkAt(&t->wet, head[0], head[1]);
+        if (!c) c = newChunk(t, head[0], head[1]);
+        if (!c) return false;
+        memcpy(c->water, tmp.water, sizeof c->water);
+        memcpy(c->soil, tmp.soil, sizeof c->soil);
+        memcpy(c->pee, tmp.pee, sizeof c->pee);
+        memcpy(c->mud, tmp.mud, sizeof c->mud);
+        c->running = true;
+        c->dirty = true;
+        c->dead = false;
+    }
+    return true;
+}
+
 /* The chunk and index of cell (gx, gz), made if missing and make is set. */
 static WetChunk *cellAt(Terrain *t, int gx, int gz, bool make, int *index) {
     if (gx < 0 || gz < 0 || gx >= WET_RES || gz >= WET_RES) return NULL;
diff --git a/entropie/src/world/wetness.h b/entropie/src/world/wetness.h
index c396061..42a0da5 100644
--- a/entropie/src/world/wetness.h
+++ b/entropie/src/world/wetness.h
@@ -16,6 +16,7 @@
  */
 
 #include <stdbool.h>
+#include <stdio.h>
 
 #include "core/gl.h"
 
@@ -45,6 +46,10 @@ typedef struct {
 struct Terrain;
 
 bool  Wetness_init(Wetness *w);
+/* Saves: the wet ground (each chunk in use: water, soil, pee, mud) out to /
+   in from a file. Reading needs the terrain (and its wear) in place first. */
+bool  Wetness_write(const Wetness *w, FILE *f);
+bool  Wetness_read(struct Terrain *t, FILE *f);
 void  Wetness_destroy(Wetness *w);
 
 /* Liters landing on the ground around (x, z); pee: how much of it is pee (0..1). */
diff --git a/entropie/src/world/world.c b/entropie/src/world/world.c
index 6ce2142..f462d95 100644
--- a/entropie/src/world/world.c
+++ b/entropie/src/world/world.c
@@ -1157,12 +1157,14 @@ static void pushOffCarried(World *world, Object *who) {
 
 void World_init(World *world) {
     memset(world, 0, sizeof(*world));
+    Object_seePeople(NULL, 0); /* (not the people of a world gone: loading a save builds a new one) */
     world->rng = 0x2545F491u;
     world->hour = 12.0f;
     Weather_init(world);
 }
 
 void World_destroy(World *world) {
+    Object_seePeople(NULL, 0);
     for (int i = 0; i < world->count; i++)
         Object_destroy(world->objects[i]);
     free(world->objects);