Fix load: save weather systems (v11) + clear stale step-back history on load

After loading, storms vanished on forward play/step but reappeared on step-back. Two causes:
the moving weather systems weren't saved (transient), so a load started with none; and loadGame
didn't clear the wxUndo step-back ring, so stepping back restored STALE snapshots from before
the load (which still held the old session's storms) -- hence 'back shows them, forward doesn't'.

Save bumped to v11: the weather block now also persists sStorms + sWeatherRng + sStormNextId,
so a load resumes the active storms and stepping forward continues them deterministically.
Pre-v11 saves load with no active storms (they respawn); pre-v10 still spin weather up live.
loadGame now clears wxUndo + followId so a load can't restore stale pre-load weather or follow a
gone storm. test_weather.cpp checks the storms round-trip; all five suites pass; GUI build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-06-28 19:11:39 +02:00
parent f748940004
commit b19d2a1703
8 changed files with 38 additions and 16 deletions

View File

@ -76,9 +76,9 @@ CLI flags (applied before the first load/generate):
planet.cfg human-editable key=value config of every PlanetConfig parameter; planet.cfg human-editable key=value config of every PlanetConfig parameter;
auto-created on first run, reload live with F2. Range-checked on auto-created on first run, reload live with F2. Range-checked on
load; an invalid file reverts to safe defaults (not overwritten). load; an invalid file reverts to safe defaults (not overwritten).
planet.save binary snapshot (versioned, currently v10: +weather; v9 +moons; v8 +Live planet.save binary snapshot (versioned, currently v11: +weather systems/storms; v10
World clock; v7 +biota): seed + config + full planet state; F5 writes it, F9 +weather fields; v9 +moons; v8 +Live World clock; v7 +biota): seed + config +
reloads and resumes deterministically. As of v6 full planet state; F5 writes it, F9 reloads and resumes deterministically. As of v6
the config is stored as a self-describing key=value block (like the config is stored as a self-describing key=value block (like
planet.cfg), so adding/removing config fields no longer breaks saves planet.cfg), so adding/removing config fields no longer breaks saves
(unknown keys ignored, missing keys default). v6 cannot load pre-v6 (unknown keys ignored, missing keys default). v6 cannot load pre-v6

View File

@ -93,7 +93,7 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it.
oceans, travel with the steering wind (poleward recurve), intensify over warm water, decay over oceans, travel with the steering wind (poleward recurve), intensify over warm water, decay over
land, and **stamp** travelling cloud/rain onto the grid — so the sky visibly evolves. The intense land, and **stamp** travelling cloud/rain onto the grid — so the sky visibly evolves. The intense
tropical ones are **hurricanes/typhoons** (spin by hemisphere, eye + animated spiral marker). tropical ones are **hurricanes/typhoons** (spin by hemisphere, eye + animated spiral marker).
Transient (not saved; respawn from the seed). This makes the weather visibly move (the base field Saved (v11, so a load resumes active storms). This makes the weather visibly move (the base field
alone relaxes to a static pattern). alone relaxes to a static pattern).
## Current state ## Current state
@ -400,7 +400,7 @@ Working and verified (logic tested headless):
fields in range, clouds form + rain falls, oceans moister than land, determinism, v10 round-trip. fields in range, clouds form + rain falls, oceans moister than land, determinism, v10 round-trip.
- **Live World — moving weather systems (lows / hurricanes / typhoons):** the base cloud/rain - **Live World — moving weather systems (lows / hurricanes / typhoons):** the base cloud/rain
field relaxes to a *static* pattern under fixed forcing, so `stepWeather` now also runs a field relaxes to a *static* pattern under fixed forcing, so `stepWeather` now also runs a
population of drifting **`WeatherSystem`** agents (PlanetTypes; transient, not saved; separate population of drifting **`WeatherSystem`** agents (PlanetTypes; **saved v11**; separate
`sWeatherRng` seeded from `cfg.seed` → tectonic determinism intact). Each step: **spawn** over `sWeatherRng` seeded from `cfg.seed` → tectonic determinism intact). Each step: **spawn** over
warm tropical ocean (525°, SST ≥ `weatherTropicalSST`) or a mid-latitude (3062°) ocean low warm tropical ocean (525°, SST ≥ `weatherTropicalSST`) or a mid-latitude (3062°) ocean low
(capped at `weatherSystemMax`, prob ∝ `weatherSpawnRate`); **move** along the steering wind (capped at `weatherSystemMax`, prob ∝ `weatherSpawnRate`); **move** along the steering wind
@ -609,10 +609,12 @@ save header is versioned (currently **9**; v2 adds the `[`/`]` drift rate, v3 a
key=value text block** instead of a raw POD dump, v7 appends the **biota population** key=value text block** instead of a raw POD dump, v7 appends the **biota population**
block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World** block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
clock — a flag byte + `liveTime`, v9 appends the **moons** block, v10 appends the **weather** block — clock — a flag byte + `liveTime`, v9 appends the **moons** block, v10 appends the **weather** block —
humidity/cloud/rain, flag-gated); newer-than-supported is humidity/cloud/rain, flag-gated, v11 also persists the **weather systems** + RNG so a load resumes
active storms); newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`); rejected. Older saves (no biota block) load fine with an empty population (press `L`);
pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10 pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10
saves spin weather up live. saves spin weather up live; pre-v11 saves load with no active storms (they respawn). `loadGame`
clears the step-back `wxUndo` history so a load can't restore stale pre-load weather.
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved **As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved
config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults), config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults),
written at `precision(17)` so doubles round-trip exactly. (v6 cannot load pre-v6 saves — written at `precision(17)` so doubles round-trip exactly. (v6 cannot load pre-v6 saves —

View File

@ -201,6 +201,9 @@ recurve (`weatherSystemSpeed`), **intensify** over warm sea / **decay+cull** ove
**stamp** a Gaussian cloud/rain shield onto the grid — so cloud clusters travel and dissipate **stamp** a Gaussian cloud/rain shield onto the grid — so cloud clusters travel and dissipate
behind them. Tropical systems past `weatherHurricaneStr` are hurricanes/typhoons; rendered as behind them. Tropical systems past `weatherHurricaneStr` are hurricanes/typhoons; rendered as
animated cyclonic spiral markers (eye for cyclones) spinning by hemisphere, in 3D + 2D, under `K`. animated cyclonic spiral markers (eye for cyclones) spinning by hemisphere, in 3D + 2D, under `K`.
The systems (+ their RNG/next-id) are **saved (v11)** alongside the humidity/cloud/rain fields, so a
load resumes active storms; `loadGame` clears the step-back `wxUndo` ring so a load can't restore a
stale pre-load weather state.
## Live World viewer controls (follow-cam, 2D zoom, clock stepper) ## Live World viewer controls (follow-cam, 2D zoom, clock stepper)

View File

@ -279,7 +279,7 @@ void Viewer::loadGame(const char* path) {
if (ver >= 8) { is.read(reinterpret_cast<char*>(&lw), sizeof lw); if (ver >= 8) { is.read(reinterpret_cast<char*>(&lw), sizeof lw);
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; } if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms
cfg = planet.cfg; // adopt the loaded config cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0); elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift planet.drifting = settled; // resume drift boosts iff mid-drift
@ -290,6 +290,7 @@ void Viewer::loadGame(const char* path) {
settleRun = settleNeed; // keep the settled latch consistent settleRun = settleNeed; // keep the settled latch consistent
dtMy = settled ? planet.cflDtMy() : 0.0; dtMy = settled ? planet.cflDtMy() : 0.0;
driftAccum = 0.0; formAccum = 0.0; driftAccum = 0.0; formAccum = 0.0;
wxUndo.clear(); followId = 0; // drop stale step-back history / follow target
paused = true; selectedCell = -1; subgrids.clear(); paused = true; selectedCell = -1; subgrids.clear();
buildBorders(planet, borderR, borders, ridgeBorders); buildBorders(planet, borderR, borders, ridgeBorders);
buildDriftArrows(planet, driftR, driftArrows, plateLabels); buildDriftArrows(planet, driftR, driftArrows, plateLabels);

View File

@ -15,7 +15,7 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing). // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer { struct Viewer {
// ---- Files / save format ------------------------------------------------ // ---- Files / save format ------------------------------------------------
static constexpr uint32_t SAVE_VERSION = 10; // v10: +weather; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3 static constexpr uint32_t SAVE_VERSION = 11; // v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3
const char* CONFIG_PATH = "planet.cfg"; const char* CONFIG_PATH = "planet.cfg";
const char* SAVE_PATH = "planet.save"; const char* SAVE_PATH = "planet.save";
std::string configPath = "planet.cfg"; // initial config (--config overrides) std::string configPath = "planet.cfg"; // initial config (--config overrides)

View File

@ -143,7 +143,7 @@ public:
// synthesize moons from the seed instead. hasWeather: the weather block (save v10+); // synthesize moons from the seed instead. hasWeather: the weather block (save v10+);
// older saves leave weather to spin up on entering Live World. // older saves leave weather to spin up on entering Live World.
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true, bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true,
bool hasMoons = true, bool hasWeather = true); bool hasMoons = true, bool hasWeather = true, bool hasStorms = true);
// Helpers for rendering / info. // Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing double cellWidthMeters() const; // approx lateral cell spacing

View File

@ -301,12 +301,18 @@ void Planet::writeState(std::ostream& os) const {
writeVec(os, cb.flora); writeVec(os, cb.fauna); writeVec(os, cb.funga); writeVec(os, cb.flora); writeVec(os, cb.fauna); writeVec(os, cb.funga);
} }
} }
// v10: Live World weather (humidity/cloud/rain). Flag-gated like biota. // v10: Live World weather (humidity/cloud/rain). Flag-gated like biota. v11 also persists the
// moving weather systems + their RNG/next-id, so loading restores active storms (and stepping
// forward continues them deterministically) instead of losing them.
uint8_t hasWx = (sHasWeather && sHumidity.size() == cells.size()) ? 1 : 0; writePod(os, hasWx); uint8_t hasWx = (sHasWeather && sHumidity.size() == cells.size()) ? 1 : 0; writePod(os, hasWx);
if (hasWx) { writeVec(os, sHumidity); writeVec(os, sCloud); writeVec(os, sRain); } if (hasWx) {
writeVec(os, sHumidity); writeVec(os, sCloud); writeVec(os, sRain);
writeVec(os, sStorms); writePod(os, sWeatherRng); writePod(os, sStormNextId); // v11
}
} }
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, bool hasWeather) { bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
bool hasWeather, bool hasStorms) {
// Read the length-prefixed key=value config block (see writeState). A default // Read the length-prefixed key=value config block (see writeState). A default
// PlanetConfig is parsed over, so fields absent from an older save keep their // PlanetConfig is parsed over, so fields absent from an older save keep their
// current defaults. The length guard rejects pre-v6 (raw-POD-config) saves. // current defaults. The length guard rejects pre-v6 (raw-POD-config) saves.
@ -362,6 +368,10 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
readVec(is, sHumidity); readVec(is, sCloud); readVec(is, sRain); readVec(is, sHumidity); readVec(is, sCloud); readVec(is, sRain);
if (!is || (int)sHumidity.size() != (int)cells.size()) return false; if (!is || (int)sHumidity.size() != (int)cells.size()) return false;
sHasWeather = true; sHasWeather = true;
if (hasStorms) { // v11: active weather systems + their RNG
readVec(is, sStorms); readPod(is, sWeatherRng); readPod(is, sStormNextId);
if (!is) return false;
}
} }
} }
computeBiotaDensity(); // derived density scalars for the colour views computeBiotaDensity(); // derived density scalars for the colour views

View File

@ -108,16 +108,22 @@ int main() {
check(ang > 1e-4 && ang < 0.3, "a weather system moves between steps"); check(ang > 1e-4 && ang < 0.3, "a weather system moves between steps");
} }
std::printf("Weather: save v10\n"); std::printf("Weather: save v11 (fields + storms)\n");
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
p.writeState(ss); p.writeState(ss);
Planet q; Planet q;
bool ok = q.readState(ss, true, true, true, true); bool ok = q.readState(ss, true, true, true, true, true);
bool rt = ok && (int)q.cloud().size() == n; bool rt = ok && (int)q.cloud().size() == n;
for (int i = 0; i < n && rt; ++i) for (int i = 0; i < n && rt; ++i)
if (q.humidity()[i] != p.humidity()[i] || q.cloud()[i] != p.cloud()[i] || q.rain()[i] != p.rain()[i]) if (q.humidity()[i] != p.humidity()[i] || q.cloud()[i] != p.cloud()[i] || q.rain()[i] != p.rain()[i])
rt = false; rt = false;
check(rt, "save v10 round-trips the weather state"); check(rt, "save round-trips the weather fields");
bool srt = ((int)q.storms().size() == (int)p.storms().size());
for (size_t i = 0; i < q.storms().size() && srt; ++i)
if (q.storms()[i].id != p.storms()[i].id
|| q.storms()[i].pos.dot(p.storms()[i].pos) < 1.0 - 1e-9
|| q.storms()[i].strength != p.storms()[i].strength) srt = false;
check(srt, "save v11 round-trips the active weather systems");
std::printf("Weather: snapshot round-trip (step-back undo)\n"); std::printf("Weather: snapshot round-trip (step-back undo)\n");
{ {