Harden save loading and derived state resets

This commit is contained in:
Jonas Reith 2026-06-28 21:44:41 +02:00
parent 695e07ba99
commit 2b0e1f8633
12 changed files with 250 additions and 46 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 v12: +step-back history (~40 frames, so a planet.save binary snapshot (versioned, currently v13: +Live World clock rate; v12
load can rewind storms); v11 +weather systems/storms; v10 +weather fields; v9 +step-back history (~40 frames, so a load can rewind storms); v11 +weather
+moons; v8 +Live World clock; v7 +biota): seed + config + full planet state; F5 systems/storms; v10 +weather fields; v9 +moons; v8 +Live World clock; v7 +biota): seed + config + full planet state; F5
writes it, F9 reloads and resumes deterministically. As of v6 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

View File

@ -606,19 +606,21 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and
`Planet::writeState`/`readState`, resumes deterministically). Config is `Planet::writeState`/`readState`, resumes deterministically). Config is
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe
defaults (without overwriting your `planet.cfg`) and shows a status message. The defaults (without overwriting your `planet.cfg`) and shows a status message. The
save header is versioned (currently **9**; v2 adds the `[`/`]` drift rate, v3 a save header is versioned (currently **13**; v2 adds the `[`/`]` drift rate, v3 a
`phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing `phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing
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, v11 also persists the **weather systems** + RNG so a load resumes humidity/cloud/rain, flag-gated, v11 also persists the **weather systems** + RNG so a load resumes
active storms, v12 appends the most recent **step-back frames**`wxSaveMax`(40) weather snapshots active storms, v12 appends the most recent **step-back frames**`wxSaveMax`(40) weather snapshots
— so a load can rewind storms past the saved moment); newer-than-supported is — so a load can rewind storms past the saved moment, v13 appends the Live World clock rate);
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; pre-v11 saves load with no active storms (they respawn); pre-v12 saves saves spin weather up live; pre-v11 saves load with no active storms (they respawn); pre-v12 saves
load with no step-back history (you can still step forward then back). A load drops any **stale** load with no step-back history (you can still step forward then back); pre-v13 saves resume with
pre-load `wxUndo` history and reloads the saved one. the default live clock rate. A load drops any **stale** pre-load `wxUndo` history and reloads the
saved one.
**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

@ -15,9 +15,7 @@ set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(BUILD_GAMES OFF CACHE BOOL "" FORCE) set(BUILD_GAMES OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(raylib) FetchContent_MakeAvailable(raylib)
add_executable(planetsim set(SIM_SOURCES
src/main.cpp
# Engine (raylib-free, testable headless) -- src/sim
src/sim/IcoSphere.cpp src/sim/IcoSphere.cpp
src/sim/Planet.cpp src/sim/Planet.cpp
src/sim/PlanetTectonics.cpp src/sim/PlanetTectonics.cpp
@ -34,7 +32,9 @@ add_executable(planetsim
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFaunaGen.cpp
src/sim/PlanetFungiGen.cpp src/sim/PlanetFungiGen.cpp
src/sim/PlanetIO.cpp src/sim/PlanetIO.cpp
# Viewer (raylib) -- src/render )
set(RENDER_SOURCES
src/render/Colors.cpp src/render/Colors.cpp
src/render/Map2D.cpp src/render/Map2D.cpp
src/render/Overlays.cpp src/render/Overlays.cpp
@ -44,18 +44,33 @@ add_executable(planetsim
src/render/ViewerInput.cpp src/render/ViewerInput.cpp
src/render/ViewerRender.cpp src/render/ViewerRender.cpp
) )
add_library(planetsim_sim STATIC ${SIM_SOURCES})
target_include_directories(planetsim_sim PUBLIC src/sim)
add_executable(planetsim
src/main.cpp
${RENDER_SOURCES}
)
# Flat includes ("Planet.hpp", "Viewer.hpp", ...) resolve across both folders. # Flat includes ("Planet.hpp", "Viewer.hpp", ...) resolve across both folders.
target_include_directories(planetsim PRIVATE src/sim src/render) target_include_directories(planetsim PRIVATE src/sim src/render)
target_link_libraries(planetsim PRIVATE raylib) target_link_libraries(planetsim PRIVATE planetsim_sim raylib)
# OpenMP parallelizes the per-cell passes in Planet::step(). Optional: without # OpenMP parallelizes the per-cell passes in Planet::step(). Optional: without
# it the #pragma omp lines are ignored and the sim runs (correctly) serial. # it the #pragma omp lines are ignored and the sim runs (correctly) serial.
find_package(OpenMP) find_package(OpenMP)
if(OpenMP_CXX_FOUND) if(OpenMP_CXX_FOUND)
target_link_libraries(planetsim PRIVATE OpenMP::OpenMP_CXX) target_link_libraries(planetsim_sim PUBLIC OpenMP::OpenMP_CXX)
endif() endif()
# Linux system libs raylib needs at link time. # Linux system libs raylib needs at link time.
if(UNIX AND NOT APPLE) if(UNIX AND NOT APPLE)
target_link_libraries(planetsim PRIVATE m pthread dl) target_link_libraries(planetsim PRIVATE m pthread dl)
endif() endif()
enable_testing()
foreach(test_name logic biota ocean live weather)
add_executable(test_${test_name} test_${test_name}.cpp)
target_link_libraries(test_${test_name} PRIVATE planetsim_sim)
add_test(NAME ${test_name} COMMAND test_${test_name})
endforeach()

View File

@ -204,8 +204,9 @@ animated cyclonic spiral markers (eye for cyclones) spinning by hemisphere, in 3
The systems (+ their RNG/next-id) are **saved (v11)** alongside the humidity/cloud/rain fields, so a The systems (+ their RNG/next-id) are **saved (v11)** alongside the humidity/cloud/rain fields, so a
load resumes active storms; a load also drops any stale pre-load `wxUndo` step-back ring and (v12) load resumes active storms; a load also drops any stale pre-load `wxUndo` step-back ring and (v12)
restores the most recent `wxSaveMax`(40) step-back frames from the file, so stepping back after a load restores the most recent `wxSaveMax`(40) step-back frames from the file, so stepping back after a load
can rewind storms past the saved moment. Weather is integrated/path-dependent, so reversing it past can rewind storms past the saved moment. v13 also saves the Live World clock rate. Weather is
a save is only possible via this stored history — it can't be re-derived from the loaded moment. integrated/path-dependent, so reversing it past a save is only possible via this stored history — it
can't be re-derived from the loaded moment.
## Live World viewer controls (follow-cam, 2D zoom, clock stepper) ## Live World viewer controls (follow-cam, 2D zoom, clock stepper)
@ -228,7 +229,7 @@ Three viewer-only controls over the Live World sim:
overlay). `.`/`,` step ±`liveRate` hours and **auto-pause** (frame-step). Weather is integrated overlay). `.`/`,` step ±`liveRate` hours and **auto-pause** (frame-step). Weather is integrated
and not analytically reversible, so a forward step snapshots the full weather state and not analytically reversible, so a forward step snapshots the full weather state
(`Planet::captureWeather`/`restoreWeather` — humidity/cloud/rain/storms/RNG) into a bounded (`Planet::captureWeather`/`restoreWeather` — humidity/cloud/rain/storms/RNG) into a bounded
`wxUndo` ring; **`,` restores the most recent snapshot before now**, reversing clouds/rain/storms `wxUndo` ring; **`,` restores the newest snapshot at or before now**, reversing clouds/rain/storms
exactly as well as the deterministic sky. `liveAdvance` records a snapshot at ~one-step cadence on exactly as well as the deterministic sky. `liveAdvance` records a snapshot at ~one-step cadence on
*any* forward advance — continuous run or manual step — so storms born during a run also rewind *any* forward advance — continuous run or manual step — so storms born during a run also rewind
(the ring is bounded, ~one snapshot per real second since the interval scales with `liveRate`). The (the ring is bounded, ~one snapshot per real second since the interval scales with `liveRate`). The

View File

@ -261,6 +261,7 @@ void Viewer::saveGame(const char* path) {
os.write(reinterpret_cast<const char*>(&p3), sizeof p3); // v3: Phase-3 flag os.write(reinterpret_cast<const char*>(&p3), sizeof p3); // v3: Phase-3 flag
os.write(reinterpret_cast<const char*>(&lw), sizeof lw); // v8: Live World flag os.write(reinterpret_cast<const char*>(&lw), sizeof lw); // v8: Live World flag
os.write(reinterpret_cast<const char*>(&liveTime), sizeof liveTime); // v8: live clock (hours) os.write(reinterpret_cast<const char*>(&liveTime), sizeof liveTime); // v8: live clock (hours)
os.write(reinterpret_cast<const char*>(&liveRate), sizeof liveRate); // v13: live clock rate
planet.writeState(os); planet.writeState(os);
// v12: persist the most recent step-back frames so a load can rewind storms past the moment. // v12: persist the most recent step-back frames so a load can rewind storms past the moment.
auto wD = [&](const std::vector<double>& v){ uint64_t m = v.size(); os.write((char*)&m, 8); if (m) os.write((const char*)v.data(), (std::streamsize)(m * sizeof(double))); }; auto wD = [&](const std::vector<double>& v){ uint64_t m = v.size(); os.write((char*)&m, 8); if (m) os.write((const char*)v.data(), (std::streamsize)(m * sizeof(double))); };
@ -281,7 +282,7 @@ void Viewer::loadGame(const char* path) {
std::ifstream is(path, std::ios::binary); std::ifstream is(path, std::ios::binary);
if (!is) { setStatus(std::string("No ") + path); return; } if (!is) { setStatus(std::string("No ") + path); return; }
char magic[4] = {0}; uint32_t ver = 0; double em = 0; uint8_t st = 0; double dr = 4.0; uint8_t p3 = 0; char magic[4] = {0}; uint32_t ver = 0; double em = 0; uint8_t st = 0; double dr = 4.0; uint8_t p3 = 0;
uint8_t lw = 0; double lh = 0.0; uint8_t lw = 0; double lh = 0.0; double lr = 1.0;
is.read(magic, 4); is.read(magic, 4);
is.read(reinterpret_cast<char*>(&ver), sizeof ver); is.read(reinterpret_cast<char*>(&ver), sizeof ver);
is.read(reinterpret_cast<char*>(&em), sizeof em); is.read(reinterpret_cast<char*>(&em), sizeof em);
@ -290,6 +291,7 @@ void Viewer::loadGame(const char* path) {
if (ver >= 3) is.read(reinterpret_cast<char*>(&p3), sizeof p3); if (ver >= 3) is.read(reinterpret_cast<char*>(&p3), sizeof p3);
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 (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate
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, ver >= 11)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms 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
@ -298,32 +300,54 @@ void Viewer::loadGame(const char* path) {
phase3 = (p3 != 0); phase3Prompt = false; phase3 = (p3 != 0); phase3Prompt = false;
phase3PromptAt = phase3 ? elapsedMy : (elapsedMy + planet.cfg.phase3AfterMy); phase3PromptAt = phase3 ? elapsedMy : (elapsedMy + planet.cfg.phase3AfterMy);
driftRate = dr; driftRate = dr;
liveWorld = (lw != 0); liveTime = lh; // v8: resume the Live World clock liveWorld = (lw != 0); liveTime = lh; liveRate = std::clamp(lr, 0.25, 720.0); // v8/v13: resume the Live World clock
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 wxUndo.clear(); followId = 0; // drop stale step-back history / follow target
bool skippedHistory = false;
if (ver >= 12) { // v12: restore the saved step-back frames (rewind past load) if (ver >= 12) { // v12: restore the saved step-back frames (rewind past load)
auto rD = [&](std::vector<double>& v){ uint64_t m = 0; is.read((char*)&m, 8); bool historyOk = true;
if (!is || m > 4000000ull) { v.clear(); return; } v.resize((size_t)m); const uint64_t cellCount = planet.cells.size();
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(double))); }; auto rD = [&](std::vector<double>& v){
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || (m != 0 && m != cellCount)) { historyOk = false; v.clear(); return; }
v.resize((size_t)m);
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(double)));
if (!is) historyOk = false;
};
uint32_t hn = 0; is.read((char*)&hn, 4); uint32_t hn = 0; is.read((char*)&hn, 4);
if (!is || hn > (uint32_t)wxUndoMax) historyOk = false;
for (uint32_t k = 0; k < hn && is; ++k) { for (uint32_t k = 0; k < hn && is; ++k) {
WxFrame f; is.read((char*)&f.t, 8); WxFrame f; is.read((char*)&f.t, 8);
rD(f.w.humidity); rD(f.w.cloud); rD(f.w.rain); rD(f.w.humidity); rD(f.w.cloud); rD(f.w.rain);
uint64_t sc = 0; is.read((char*)&sc, 8); if (sc > 1000000ull) sc = 0; uint64_t sc = 0; is.read((char*)&sc, 8);
if (!is || sc > (uint64_t)planet.cfg.weatherSystemMax) { historyOk = false; break; }
f.w.storms.resize((size_t)sc); f.w.storms.resize((size_t)sc);
if (sc) is.read((char*)f.w.storms.data(), (std::streamsize)(sc * sizeof(WeatherSystem))); if (sc) is.read((char*)f.w.storms.data(), (std::streamsize)(sc * sizeof(WeatherSystem)));
is.read((char*)&f.w.rng, 4); is.read((char*)&f.w.nextId, 4); is.read((char*)&f.w.rng, 4); is.read((char*)&f.w.nextId, 4);
if (is) wxUndo.push_back(std::move(f)); auto sized = [&](const std::vector<double>& v) { return v.empty() || v.size() == planet.cells.size(); };
if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain)
|| f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size())
historyOk = false;
for (const WeatherSystem& ws : f.w.storms)
if (!std::isfinite(ws.pos.x) || !std::isfinite(ws.pos.y) || !std::isfinite(ws.pos.z)
|| std::fabs(ws.pos.length() - 1.0) > 1e-6
|| !std::isfinite(ws.strength) || ws.strength < 0.0 || ws.strength > 1.0
|| !std::isfinite(ws.radius) || ws.radius <= 0.0
|| !std::isfinite(ws.age) || !std::isfinite(ws.life)
|| !std::isfinite(ws.spin)) historyOk = false;
if (historyOk) wxUndo.push_back(std::move(f));
} }
if (!historyOk) { wxUndo.clear(); skippedHistory = true; }
} }
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);
buildMap2D(planet, mapRect, map2D); buildMap2D(planet, mapRect, map2D);
refreshView(); refreshView();
setStatus(std::string("Loaded ") + path); setStatus(skippedHistory ? std::string("Loaded ") + path + " (history skipped)"
: std::string("Loaded ") + path);
} }
// Advance the simulation this frame: Phase-1 forming (paced ticks toward // Advance the simulation this frame: Phase-1 forming (paced ticks toward

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 = 12; // v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3 static constexpr uint32_t SAVE_VERSION = 13; // v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3
static constexpr int wxSaveMax = 40; // most recent step-back frames persisted in a save static constexpr int wxSaveMax = 40; // most recent step-back frames persisted in a save
const char* CONFIG_PATH = "planet.cfg"; const char* CONFIG_PATH = "planet.cfg";
const char* SAVE_PATH = "planet.save"; const char* SAVE_PATH = "planet.save";

View File

@ -221,12 +221,12 @@ void Viewer::handleInput() {
if (IsKeyPressed(KEY_EQUAL) && cfg.subdivisions < 7) { cfg.subdivisions++; regen(); } if (IsKeyPressed(KEY_EQUAL) && cfg.subdivisions < 7) { cfg.subdivisions++; regen(); }
if (IsKeyPressed(KEY_MINUS) && cfg.subdivisions > 1) { cfg.subdivisions--; regen(); } if (IsKeyPressed(KEY_MINUS) && cfg.subdivisions > 1) { cfg.subdivisions--; regen(); }
if (IsKeyPressed(KEY_F2)) { if (IsKeyPressed(KEY_F2)) {
if (loadConfig(CONFIG_PATH, cfg)) { if (loadConfig(configPath, cfg)) {
std::string cerr = validateConfig(cfg); std::string cerr = validateConfig(cfg);
if (!cerr.empty()) { cfg = PlanetConfig{}; setStatus("Bad planet.cfg — using defaults"); } if (!cerr.empty()) { cfg = PlanetConfig{}; setStatus("Bad config — using defaults"); }
regen(); regen();
setStatus(cerr.empty() ? "Reloaded planet.cfg" : "Bad planet.cfg — using defaults"); setStatus(cerr.empty() ? std::string("Reloaded ") + configPath : "Bad config — using defaults");
} else setStatus("No planet.cfg"); } } else setStatus(std::string("No ") + configPath); }
if (IsKeyPressed(KEY_F5)) saveGame(SAVE_PATH); if (IsKeyPressed(KEY_F5)) saveGame(SAVE_PATH);
if (IsKeyPressed(KEY_F9)) loadGame(SAVE_PATH); if (IsKeyPressed(KEY_F9)) loadGame(SAVE_PATH);
if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); } if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); }

View File

@ -49,12 +49,31 @@ void Planet::buildGeometry() {
cells[i].unit = sphere.positions[i]; cells[i].unit = sphere.positions[i];
cells[i].neighbors = sphere.neighbors[i]; cells[i].neighbors = sphere.neighbors[i];
} }
clearDerivedState();
sBiota.assign(cells.size(), {}); // empty biota population until generateBiota() sBiota.assign(cells.size(), {}); // empty biota population until generateBiota()
sHasBiota = false; sHasBiota = false;
sHumidity.clear(); sCloud.clear(); sRain.clear(); // weather spins up on entering Live World sHumidity.clear(); sCloud.clear(); sRain.clear(); // weather spins up on entering Live World
sHasWeather = false; sStorms.clear(); sHasWeather = false; sStorms.clear();
} }
void Planet::clearDerivedState() {
sStress.clear(); sBelt.clear(); sBeltNext.clear(); sDelta.clear();
sSmoothed.clear(); sOldElev.clear(); sErode.clear();
sSub.clear(); sOver.clear(); sColl.clear();
sFill.clear(); sLakeDepth.clear(); sDischarge.clear();
sFlowTo.clear(); sHydroOrder.clear();
sTemp.clear(); sPrecip.clear(); sMoist.clear();
sTempSummer.clear(); sTempWinter.clear();
sWind.clear(); sUpwind.clear();
sInsolation.clear(); sLiveTemp.clear(); sTide.clear();
sCurrent.clear();
sFloraDensity.clear(); sFaunaDensity.clear(); sFungaDensity.clear();
}
void Planet::assignPlates() { void Planet::assignPlates() {
plates.clear(); plates.clear();
plates.resize(cfg.plateCount); plates.resize(cfg.plateCount);

View File

@ -159,6 +159,7 @@ private:
uint32_t rnd(); uint32_t rnd();
double rndf(); // [0,1) double rndf(); // [0,1)
void buildGeometry(); // build icosphere + per-cell unit/neighbors void buildGeometry(); // build icosphere + per-cell unit/neighbors
void clearDerivedState(); // clear geometry-dependent scratch/derived fields
void assignPlates(); void assignPlates();
void seedInitialRelief(); void seedInitialRelief();
Vec3 driftVelocity(int plateId, const Vec3& pos) const; Vec3 driftVelocity(int plateId, const Vec3& pos) const;

View File

@ -21,7 +21,7 @@ void Planet::classifyBiomes() {
const double WETLAND_MOIST = cfg.biomeWetlandMoist, DESERT_MOIST = cfg.biomeDesertMoist; const double WETLAND_MOIST = cfg.biomeWetlandMoist, DESERT_MOIST = cfg.biomeDesertMoist;
const double GRASS_MOIST = cfg.biomeGrassMoist, TAIGA_MOIST = cfg.biomeTaigaMoist; const double GRASS_MOIST = cfg.biomeGrassMoist, TAIGA_MOIST = cfg.biomeTaigaMoist;
const double LAKE_MIN_DEPTH = cfg.biomeLakeMinDepth; const double LAKE_MIN_DEPTH = cfg.biomeLakeMinDepth;
const bool haveLake = !sLakeDepth.empty(); const bool haveLake = ((int)sLakeDepth.size() == n);
// Seasons: blend winter temperature into the cold (Tundra/Taiga) cutoffs so cold-winter // Seasons: blend winter temperature into the cold (Tundra/Taiga) cutoffs so cold-winter
// continental interiors turn boreal/tundra. The seasonal amplitude is itself geographically // continental interiors turn boreal/tundra. The seasonal amplitude is itself geographically
// shaped (large only at high-latitude interiors), so this expands cold biomes where seasons // shaped (large only at high-latitude interiors), so this expands cold biomes where seasons

View File

@ -7,6 +7,7 @@
#include <ostream> #include <ostream>
#include <fstream> #include <fstream>
#include <sstream> #include <sstream>
#include <limits>
// --- Config file (text) + save/load (binary) -------------------------------- // --- Config file (text) + save/load (binary) --------------------------------
@ -257,10 +258,18 @@ namespace {
uint64_t n = v.size(); writePod(os, n); uint64_t n = v.size(); writePod(os, n);
if (n) os.write(reinterpret_cast<const char*>(v.data()), (std::streamsize)(n * sizeof(T))); if (n) os.write(reinterpret_cast<const char*>(v.data()), (std::streamsize)(n * sizeof(T)));
} }
template <class T> void readVec(std::istream& is, std::vector<T>& v) { template <class T> bool readVec(std::istream& is, std::vector<T>& v, uint64_t maxCount) {
static_assert(std::is_trivially_copyable<T>::value, "readVec needs POD elements"); static_assert(std::is_trivially_copyable<T>::value, "readVec needs POD elements");
uint64_t n = 0; readPod(is, n); v.resize((size_t)n); uint64_t n = 0; readPod(is, n);
if (!is || n > maxCount) { is.setstate(std::ios::failbit); v.clear(); return false; }
uint64_t maxBytes = (uint64_t)std::numeric_limits<std::streamsize>::max();
if (sizeof(T) != 0 && n > maxBytes / sizeof(T)) { is.setstate(std::ios::failbit); v.clear(); return false; }
v.resize((size_t)n);
if (n) is.read(reinterpret_cast<char*>(v.data()), (std::streamsize)(n * sizeof(T))); if (n) is.read(reinterpret_cast<char*>(v.data()), (std::streamsize)(n * sizeof(T)));
return (bool)is;
}
bool validBiomeByte(uint8_t b) {
return b <= (uint8_t)Biome::Mountains;
} }
} }
@ -313,16 +322,22 @@ void Planet::writeState(std::ostream& os) const {
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
bool hasWeather, bool hasStorms) { bool hasWeather, bool hasStorms) {
// Save blocks are append-only by version. If a caller asks for an older prefix,
// later blocks cannot exist in that stream even if the default arguments say otherwise.
if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; }
if (!hasWeather) hasStorms = false;
// 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.
uint64_t clen = 0; readPod(is, clen); uint64_t clen = 0; readPod(is, clen);
if (!is || clen > 1000000) return false; if (!is || clen > 1000000) return false;
std::string cfgText(clen, '\0'); std::string cfgText(clen, '\0');
is.read(&cfgText[0], (std::streamsize)clen); if (clen) is.read(cfgText.data(), (std::streamsize)clen);
if (!is) return false; if (!is) return false;
PlanetConfig c; PlanetConfig c;
{ std::istringstream cis(cfgText); parseConfigStream(cis, c); } { std::istringstream cis(cfgText); parseConfigStream(cis, c); }
if (!validateConfig(c).empty()) return false;
cfg = c; cfg = c;
buildGeometry(); // rebuild unit/neighbors from cfg.subdivisions buildGeometry(); // rebuild unit/neighbors from cfg.subdivisions
readPod(is, rngState); readPod(is, rngState);
@ -335,13 +350,31 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
readPod(is, cell.elevation); readPod(is, cell.plateId); readPod(is, cell.elevation); readPod(is, cell.plateId);
uint8_t oc = 0; readPod(is, oc); cell.oceanic = (oc != 0); uint8_t oc = 0; readPod(is, oc); cell.oceanic = (oc != 0);
readPod(is, cell.geoAge); readPod(is, cell.drift); readPod(is, cell.invader); readPod(is, cell.geoAge); readPod(is, cell.drift); readPod(is, cell.invader);
if (hasBiome) { uint8_t bm = 0; readPod(is, bm); cell.biome = (Biome)bm; } // save v4 if (hasBiome) {
uint8_t bm = 0; readPod(is, bm);
if (!validBiomeByte(bm)) return false;
cell.biome = (Biome)bm;
} // save v4
}
if (!readVec(is, plates, cells.size())) return false;
if (plates.empty()) return false;
for (const Cell& cell : cells)
if (cell.plateId < 0 || cell.plateId >= (int)plates.size()) return false;
for (const Plate& p : plates)
if (!std::isfinite(p.driftAxis.x) || !std::isfinite(p.driftAxis.y) || !std::isfinite(p.driftAxis.z)
|| !std::isfinite(p.driftSpeed) || !std::isfinite(p.angSpeed)
|| !std::isfinite(p.speedCmYr)) return false;
if (!readVec(is, sPrevCount, plates.size())) return false;
if (!readVec(is, sStaleStreak, plates.size())) return false;
if (!readVec(is, sFreePlateIds, plates.size())) return false;
for (int id : sFreePlateIds) if (id < 0 || id >= (int)plates.size()) return false;
if (hasMoons) {
if (!readVec(is, moons, 16)) return false; // v9: natural satellites
for (const Moon& m : moons)
if (!std::isfinite(m.orbitRadius) || !std::isfinite(m.periodDays)
|| !std::isfinite(m.phase) || !std::isfinite(m.inclination)
|| !std::isfinite(m.tideWeight) || !std::isfinite(m.dispRadius)) return false;
} }
readVec(is, plates);
readVec(is, sPrevCount);
readVec(is, sStaleStreak);
readVec(is, sFreePlateIds);
if (hasMoons) readVec(is, moons); // v9: natural satellites
else generateMoons(); // pre-v9 save: synthesize moons from the seed else generateMoons(); // pre-v9 save: synthesize moons from the seed
if (!hasBiome) classifyBiomes(); // old (v3) save: reclassify from loaded state if (!hasBiome) classifyBiomes(); // old (v3) save: reclassify from loaded state
// v7: discrete biota population. buildGeometry() already sized sBiota empty; // v7: discrete biota population. buildGeometry() already sized sBiota empty;
@ -353,24 +386,46 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
uint64_t nb = 0; readPod(is, nb); uint64_t nb = 0; readPod(is, nb);
if (!is || nb != sBiota.size()) return false; if (!is || nb != sBiota.size()) return false;
for (CellBiota& cb : sBiota) { for (CellBiota& cb : sBiota) {
readVec(is, cb.flora); readVec(is, cb.fauna); readVec(is, cb.funga); if (!readVec(is, cb.flora, (uint64_t)cfg.bioFloraSlots)) return false;
if (!readVec(is, cb.fauna, (uint64_t)cfg.bioFaunaSlots)) return false;
if (!readVec(is, cb.funga, (uint64_t)cfg.bioFungaSlots)) return false;
auto validOrg = [](const std::vector<Organism>& v) {
for (const Organism& o : v)
if (o.archetype >= biotaArchetypes().size() || !validBiomeByte(o.biome)) return false;
return true;
};
if (!validOrg(cb.flora) || !validOrg(cb.fauna) || !validOrg(cb.funga)) return false;
if (!cb.flora.empty() || !cb.fauna.empty() || !cb.funga.empty()) sHasBiota = true; if (!cb.flora.empty() || !cb.fauna.empty() || !cb.funga.empty()) sHasBiota = true;
} }
} }
} }
// v10: Live World weather. Older saves leave it to spin up on entering Live World. The moving // v10: Live World weather. Older saves leave it to spin up on entering Live World. Pre-v11
// weather systems are transient (not saved): clear them + reseed the weather RNG from the seed. // saves load without active systems; they respawn from the seed-backed weather RNG.
sHasWeather = false; sHumidity.clear(); sCloud.clear(); sRain.clear(); sHasWeather = false; sHumidity.clear(); sCloud.clear(); sRain.clear();
sStorms.clear(); sWeatherRng = cfg.seed ? (cfg.seed ^ 0x5701A123u) : 0x5701A123u; sStorms.clear(); sWeatherRng = cfg.seed ? (cfg.seed ^ 0x5701A123u) : 0x5701A123u; sStormNextId = 1;
if (hasWeather) { if (hasWeather) {
uint8_t hasWx = 0; readPod(is, hasWx); uint8_t hasWx = 0; readPod(is, hasWx);
if (hasWx) { if (hasWx) {
readVec(is, sHumidity); readVec(is, sCloud); readVec(is, sRain); if (!readVec(is, sHumidity, cells.size())) return false;
if (!is || (int)sHumidity.size() != (int)cells.size()) return false; if (!readVec(is, sCloud, cells.size())) return false;
if (!readVec(is, sRain, cells.size())) return false;
if ((int)sHumidity.size() != (int)cells.size()
|| sCloud.size() != sHumidity.size() || sRain.size() != sHumidity.size()) return false;
for (size_t i = 0; i < sHumidity.size(); ++i)
if (!std::isfinite(sHumidity[i]) || !std::isfinite(sCloud[i]) || !std::isfinite(sRain[i]))
return false;
sHasWeather = true; sHasWeather = true;
if (hasStorms) { // v11: active weather systems + their RNG if (hasStorms) { // v11: active weather systems + their RNG
readVec(is, sStorms); readPod(is, sWeatherRng); readPod(is, sStormNextId); if (!readVec(is, sStorms, (uint64_t)cfg.weatherSystemMax)) return false;
readPod(is, sWeatherRng); readPod(is, sStormNextId);
if (!is) return false; if (!is) return false;
for (const WeatherSystem& ws : sStorms)
if (!std::isfinite(ws.pos.x) || !std::isfinite(ws.pos.y) || !std::isfinite(ws.pos.z)
|| std::fabs(ws.pos.length() - 1.0) > 1e-6
|| !std::isfinite(ws.strength) || ws.strength < 0.0 || ws.strength > 1.0
|| !std::isfinite(ws.radius) || ws.radius <= 0.0
|| !std::isfinite(ws.age) || !std::isfinite(ws.life)
|| !std::isfinite(ws.spin)) return false;
} }
} }
} }

View File

@ -14,6 +14,8 @@
#include <map> #include <map>
#include <cmath> #include <cmath>
#include <algorithm> #include <algorithm>
#include <sstream>
#include <cstdint>
static int failures = 0; static int failures = 0;
@ -26,6 +28,41 @@ static void check(bool cond, const char* what) {
if (!cond) ++failures; if (!cond) ++failures;
} }
template <class T> static void writePod(std::ostream& os, const T& v) {
os.write(reinterpret_cast<const char*>(&v), sizeof(T));
}
template <class T> static void writeEmptyVec(std::ostream& os) {
uint64_t n = 0; writePod(os, n);
}
static void writeManualStatePrefix(std::ostream& os, const std::string& cfgText, int plateId) {
uint64_t clen = cfgText.size(); writePod(os, clen);
os.write(cfgText.data(), (std::streamsize)cfgText.size());
uint32_t rng = 1; int driftIter = 0, erodeIter = 0, targetLand = -1;
writePod(os, rng); writePod(os, driftIter); writePod(os, erodeIter); writePod(os, targetLand);
uint64_t nc = 12; writePod(os, nc); // subdivisions=0
for (int i = 0; i < 12; ++i) {
double elevation = 0.0, geoAge = 0.0, drift = 0.0;
uint8_t oceanic = 1, biome = (uint8_t)Biome::Ocean;
int invader = -1;
writePod(os, elevation); writePod(os, plateId); writePod(os, oceanic);
writePod(os, geoAge); writePod(os, drift); writePod(os, invader); writePod(os, biome);
}
}
static void finishManualState(std::ostream& os) {
uint64_t pn = 1; writePod(os, pn);
Plate pl{}; pl.id = 0; pl.driftAxis = Vec3{0, 1, 0};
os.write(reinterpret_cast<const char*>(&pl), sizeof(Plate));
writeEmptyVec<int>(os); // sPrevCount
writeEmptyVec<int>(os); // sStaleStreak
writeEmptyVec<int>(os); // sFreePlateIds
writeEmptyVec<Moon>(os); // moons
uint8_t hasBio = 0, hasWx = 0;
writePod(os, hasBio);
writePod(os, hasWx);
}
// Euler characteristic for the icosphere: V - E + F == 2 (sphere topology). // Euler characteristic for the icosphere: V - E + F == 2 (sphere topology).
static bool eulerOk(const Planet& p, size_t vertCount) { static bool eulerOk(const Planet& p, size_t vertCount) {
const std::vector<int>& tri = p.triIndices(); const std::vector<int>& tri = p.triIndices();
@ -149,6 +186,56 @@ int main() {
"subgrid is finite, continuous at center, and reaches neighbors"); "subgrid is finite, continuous at center, and reaches neighbors");
} }
// ---- Derived state reset + save hardening ----------------------------
{
Planet stale; stale.generate(cfg);
stale.computeHydrology();
stale.computeInsolation(0.25, 0.5);
stale.computeLiveSeason(0.25);
stale.computeTides(0.25, 0.5, 10.0);
check(!stale.lakeDepth().empty() && !stale.liveTemp().empty() && !stale.tide().empty(),
"derived hydrology/live fields can be populated");
stale.generate(cfg);
check(stale.lakeDepth().empty() && stale.discharge().empty() && stale.flowTo().empty()
&& stale.insolation().empty() && stale.liveTemp().empty() && stale.tide().empty(),
"generate() clears stale hydrology/live derived fields");
PlanetConfig smaller = cfg; smaller.subdivisions = 4;
stale.computeHydrology();
stale.generate(smaller);
check(stale.lakeDepth().empty() && stale.cells.size() != planet.cells.size(),
"generate() clears derived fields across subdivision changes");
Planet saved; saved.generate(cfg); saved.computeHydrology();
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
saved.writeState(ss);
Planet loaded;
bool ok = loaded.readState(ss);
check(ok && loaded.lakeDepth().empty() && loaded.discharge().empty() && loaded.flowTo().empty(),
"readState() does not keep unsaved hydrology scratch");
}
{
std::stringstream badCfg(std::ios::in | std::ios::out | std::ios::binary);
std::string cfgText = "subdivisions = 99\nplateCount = 1\n";
uint64_t clen = cfgText.size(); writePod(badCfg, clen);
badCfg.write(cfgText.data(), (std::streamsize)cfgText.size());
Planet r;
check(!r.readState(badCfg), "readState() rejects invalid embedded config before geometry rebuild");
std::stringstream hugeVec(std::ios::in | std::ios::out | std::ios::binary);
writeManualStatePrefix(hugeVec, "subdivisions = 0\nplateCount = 1\n", 0);
uint64_t tooManyPlates = 13; writePod(hugeVec, tooManyPlates); // cells.size() is 12
Planet rv;
check(!rv.readState(hugeVec), "readState() rejects oversized vectors");
std::stringstream badPlate(std::ios::in | std::ios::out | std::ios::binary);
writeManualStatePrefix(badPlate, "subdivisions = 0\nplateCount = 1\n", 77);
finishManualState(badPlate);
Planet rp;
check(!rp.readState(badPlate), "readState() rejects invalid cell plate ids");
}
std::printf("\n%s (%d failure%s)\n", std::printf("\n%s (%d failure%s)\n",
failures == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", failures == 0 ? "ALL TESTS PASSED" : "TESTS FAILED",
failures, failures == 1 ? "" : "s"); failures, failures == 1 ? "" : "s");