From 2b0e1f86334b24fe8717caff57b4fe1bc6f2421b Mon Sep 17 00:00:00 2001 From: Jonas Reith Date: Sun, 28 Jun 2026 21:44:41 +0200 Subject: [PATCH] Harden save loading and derived state resets --- BUILD.md | 6 +-- CLAUDE.md | 10 +++-- CMakeLists.txt | 27 +++++++++--- docs/design-notes.md | 7 +-- src/render/Viewer.cpp | 40 ++++++++++++++---- src/render/Viewer.hpp | 2 +- src/render/ViewerInput.cpp | 8 ++-- src/sim/Planet.cpp | 19 +++++++++ src/sim/Planet.hpp | 1 + src/sim/PlanetBiomes.cpp | 2 +- src/sim/PlanetIO.cpp | 87 +++++++++++++++++++++++++++++++------- test_logic.cpp | 87 ++++++++++++++++++++++++++++++++++++++ 12 files changed, 250 insertions(+), 46 deletions(-) diff --git a/BUILD.md b/BUILD.md index 8d88769..5985f71 100644 --- a/BUILD.md +++ b/BUILD.md @@ -76,9 +76,9 @@ CLI flags (applied before the first load/generate): planet.cfg human-editable key=value config of every PlanetConfig parameter; auto-created on first run, reload live with F2. Range-checked on load; an invalid file reverts to safe defaults (not overwritten). - planet.save binary snapshot (versioned, currently v12: +step-back history (~40 frames, so a - load can rewind storms); v11 +weather systems/storms; v10 +weather fields; v9 - +moons; v8 +Live World clock; v7 +biota): seed + config + full planet state; F5 + planet.save binary snapshot (versioned, currently v13: +Live World clock rate; v12 + +step-back history (~40 frames, so a load can rewind storms); v11 +weather + 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 the config is stored as a self-describing key=value block (like planet.cfg), so adding/removing config fields no longer breaks saves diff --git a/CLAUDE.md b/CLAUDE.md index 93d2871..142d6a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -606,19 +606,21 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and `Planet::writeState`/`readState`, resumes deterministically). Config is 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 -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 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** 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 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`); 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 -load with no step-back history (you can still step forward then back). A load drops any **stale** -pre-load `wxUndo` history and reloads the saved one. +load with no step-back history (you can still step forward then back); pre-v13 saves resume with +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 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 — diff --git a/CMakeLists.txt b/CMakeLists.txt index d6c70cb..43711a2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,9 +15,7 @@ set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(BUILD_GAMES OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(raylib) -add_executable(planetsim - src/main.cpp - # Engine (raylib-free, testable headless) -- src/sim +set(SIM_SOURCES src/sim/IcoSphere.cpp src/sim/Planet.cpp src/sim/PlanetTectonics.cpp @@ -34,7 +32,9 @@ add_executable(planetsim src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp - # Viewer (raylib) -- src/render +) + +set(RENDER_SOURCES src/render/Colors.cpp src/render/Map2D.cpp src/render/Overlays.cpp @@ -44,18 +44,33 @@ add_executable(planetsim src/render/ViewerInput.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. 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 # it the #pragma omp lines are ignored and the sim runs (correctly) serial. find_package(OpenMP) if(OpenMP_CXX_FOUND) - target_link_libraries(planetsim PRIVATE OpenMP::OpenMP_CXX) + target_link_libraries(planetsim_sim PUBLIC OpenMP::OpenMP_CXX) endif() # Linux system libs raylib needs at link time. if(UNIX AND NOT APPLE) target_link_libraries(planetsim PRIVATE m pthread dl) 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() diff --git a/docs/design-notes.md b/docs/design-notes.md index 36af118..79ed582 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -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 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 -can rewind storms past the saved moment. Weather is 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. +can rewind storms past the saved moment. v13 also saves the Live World clock rate. Weather is +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) @@ -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 and not analytically reversible, so a forward step snapshots the full weather state (`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 *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 diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index 9daac11..96554dd 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -261,6 +261,7 @@ void Viewer::saveGame(const char* path) { os.write(reinterpret_cast(&p3), sizeof p3); // v3: Phase-3 flag os.write(reinterpret_cast(&lw), sizeof lw); // v8: Live World flag os.write(reinterpret_cast(&liveTime), sizeof liveTime); // v8: live clock (hours) + os.write(reinterpret_cast(&liveRate), sizeof liveRate); // v13: live clock rate planet.writeState(os); // v12: persist the most recent step-back frames so a load can rewind storms past the moment. auto wD = [&](const std::vector& 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); 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; - 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(reinterpret_cast(&ver), sizeof ver); is.read(reinterpret_cast(&em), sizeof em); @@ -290,6 +291,7 @@ void Viewer::loadGame(const char* path) { if (ver >= 3) is.read(reinterpret_cast(&p3), sizeof p3); if (ver >= 8) { is.read(reinterpret_cast(&lw), sizeof lw); is.read(reinterpret_cast(&lh), sizeof lh); } // v8: Live World clock + if (ver >= 13) is.read(reinterpret_cast(&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 (!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 @@ -298,32 +300,54 @@ void Viewer::loadGame(const char* path) { phase3 = (p3 != 0); phase3Prompt = false; phase3PromptAt = phase3 ? elapsedMy : (elapsedMy + planet.cfg.phase3AfterMy); 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 dtMy = settled ? planet.cflDtMy() : 0.0; driftAccum = 0.0; formAccum = 0.0; 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) - auto rD = [&](std::vector& v){ uint64_t m = 0; is.read((char*)&m, 8); - if (!is || m > 4000000ull) { v.clear(); return; } v.resize((size_t)m); - if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(double))); }; + bool historyOk = true; + const uint64_t cellCount = planet.cells.size(); + auto rD = [&](std::vector& 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); + if (!is || hn > (uint32_t)wxUndoMax) historyOk = false; for (uint32_t k = 0; k < hn && is; ++k) { WxFrame f; is.read((char*)&f.t, 8); 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); 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); - if (is) wxUndo.push_back(std::move(f)); + auto sized = [&](const std::vector& 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(); buildBorders(planet, borderR, borders, ridgeBorders); buildDriftArrows(planet, driftR, driftArrows, plateLabels); buildMap2D(planet, mapRect, map2D); 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 diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index f98c670..011e0a9 100644 --- a/src/render/Viewer.hpp +++ b/src/render/Viewer.hpp @@ -15,7 +15,7 @@ // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing). struct Viewer { // ---- 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 const char* CONFIG_PATH = "planet.cfg"; const char* SAVE_PATH = "planet.save"; diff --git a/src/render/ViewerInput.cpp b/src/render/ViewerInput.cpp index 3158540..e125e1a 100644 --- a/src/render/ViewerInput.cpp +++ b/src/render/ViewerInput.cpp @@ -221,12 +221,12 @@ void Viewer::handleInput() { if (IsKeyPressed(KEY_EQUAL) && cfg.subdivisions < 7) { cfg.subdivisions++; regen(); } if (IsKeyPressed(KEY_MINUS) && cfg.subdivisions > 1) { cfg.subdivisions--; regen(); } if (IsKeyPressed(KEY_F2)) { - if (loadConfig(CONFIG_PATH, cfg)) { + if (loadConfig(configPath, 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(); - setStatus(cerr.empty() ? "Reloaded planet.cfg" : "Bad planet.cfg — using defaults"); - } else setStatus("No planet.cfg"); } + setStatus(cerr.empty() ? std::string("Reloaded ") + configPath : "Bad config — using defaults"); + } else setStatus(std::string("No ") + configPath); } if (IsKeyPressed(KEY_F5)) saveGame(SAVE_PATH); if (IsKeyPressed(KEY_F9)) loadGame(SAVE_PATH); if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); } diff --git a/src/sim/Planet.cpp b/src/sim/Planet.cpp index c3108df..4da6b46 100644 --- a/src/sim/Planet.cpp +++ b/src/sim/Planet.cpp @@ -49,12 +49,31 @@ void Planet::buildGeometry() { cells[i].unit = sphere.positions[i]; cells[i].neighbors = sphere.neighbors[i]; } + clearDerivedState(); sBiota.assign(cells.size(), {}); // empty biota population until generateBiota() sHasBiota = false; sHumidity.clear(); sCloud.clear(); sRain.clear(); // weather spins up on entering Live World 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() { plates.clear(); plates.resize(cfg.plateCount); diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index d68341f..89a30d5 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -159,6 +159,7 @@ private: uint32_t rnd(); double rndf(); // [0,1) void buildGeometry(); // build icosphere + per-cell unit/neighbors + void clearDerivedState(); // clear geometry-dependent scratch/derived fields void assignPlates(); void seedInitialRelief(); Vec3 driftVelocity(int plateId, const Vec3& pos) const; diff --git a/src/sim/PlanetBiomes.cpp b/src/sim/PlanetBiomes.cpp index 44e01c0..1b208cd 100644 --- a/src/sim/PlanetBiomes.cpp +++ b/src/sim/PlanetBiomes.cpp @@ -21,7 +21,7 @@ void Planet::classifyBiomes() { const double WETLAND_MOIST = cfg.biomeWetlandMoist, DESERT_MOIST = cfg.biomeDesertMoist; const double GRASS_MOIST = cfg.biomeGrassMoist, TAIGA_MOIST = cfg.biomeTaigaMoist; 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 // 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 diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index 1e33f9f..1183a21 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -7,6 +7,7 @@ #include #include #include +#include // --- Config file (text) + save/load (binary) -------------------------------- @@ -257,10 +258,18 @@ namespace { uint64_t n = v.size(); writePod(os, n); if (n) os.write(reinterpret_cast(v.data()), (std::streamsize)(n * sizeof(T))); } - template void readVec(std::istream& is, std::vector& v) { + template bool readVec(std::istream& is, std::vector& v, uint64_t maxCount) { static_assert(std::is_trivially_copyable::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::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(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 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 // 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. uint64_t clen = 0; readPod(is, clen); if (!is || clen > 1000000) return false; 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; PlanetConfig c; { std::istringstream cis(cfgText); parseConfigStream(cis, c); } + if (!validateConfig(c).empty()) return false; cfg = c; buildGeometry(); // rebuild unit/neighbors from cfg.subdivisions 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); uint8_t oc = 0; readPod(is, oc); cell.oceanic = (oc != 0); 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 if (!hasBiome) classifyBiomes(); // old (v3) save: reclassify from loaded state // 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); if (!is || nb != sBiota.size()) return false; 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& 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; } } } - // v10: Live World weather. Older saves leave it to spin up on entering Live World. The moving - // weather systems are transient (not saved): clear them + reseed the weather RNG from the seed. + // v10: Live World weather. Older saves leave it to spin up on entering Live World. Pre-v11 + // saves load without active systems; they respawn from the seed-backed weather RNG. 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) { uint8_t hasWx = 0; readPod(is, hasWx); if (hasWx) { - readVec(is, sHumidity); readVec(is, sCloud); readVec(is, sRain); - if (!is || (int)sHumidity.size() != (int)cells.size()) return false; + if (!readVec(is, sHumidity, 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; 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; + 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; } } } diff --git a/test_logic.cpp b/test_logic.cpp index fbdc98b..43c2cc7 100644 --- a/test_logic.cpp +++ b/test_logic.cpp @@ -14,6 +14,8 @@ #include #include #include +#include +#include static int failures = 0; @@ -26,6 +28,41 @@ static void check(bool cond, const char* what) { if (!cond) ++failures; } +template static void writePod(std::ostream& os, const T& v) { + os.write(reinterpret_cast(&v), sizeof(T)); +} +template 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(&pl), sizeof(Plate)); + writeEmptyVec(os); // sPrevCount + writeEmptyVec(os); // sStaleStreak + writeEmptyVec(os); // sFreePlateIds + writeEmptyVec(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). static bool eulerOk(const Planet& p, size_t vertCount) { const std::vector& tri = p.triIndices(); @@ -149,6 +186,56 @@ int main() { "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", failures == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", failures, failures == 1 ? "" : "s");