planetsim/src/sim/PlanetIO.cpp
Jonas Reith 4aacfdabdd Soft probabilistic peak cap: spread mountain heights, drop the 9000 m plateau
Drift-time peaks used to rail into the hard elevation clamp and flatten into a
9000 m plateau (the clamp lived in step(), erode() AND hydrology(), so erosion
re-flattened them every tick). Replace the hard ceiling with a probabilistic
soft cap: above peakSoftCapStart (7000 m) the chance a tick's uplift "takes"
falls linearly to 0 at peakSoftCapEnd (12000 m); a lost grow roll forfeits the
uplift and shaves a random 0..peakFailDrop (200 m) off. Peaks now spread smoothly
across a height band (strong orogeny reaches ~10-11 km, most cluster lower) with
zero cells pinned at the ceiling.

- The roll is a pure hash of (cellIndex, erodeIter, seed): never touches
  rngState, bit-identical across OpenMP thread counts, and since erodeIter is
  saved (step/erode run 1:1 in drift) F5/F9 resumes bit-identical -- no save bump.
- Drift-only (gated on Planet::drifting) so Phase-1 forming still auto-settles.
- The hard clamp's upper bound now tracks peakSoftCapEnd in all three places
  (step/erode/hydrology); lower -11000 m unchanged.
- New planet.cfg knobs peakSoftCapStart / peakSoftCapEnd / peakFailDrop with
  validation (+ start < end cross-rule). Docs updated (CLAUDE.md, BUILD.md).

Verified headless: smooth 8.5->10.5 km taper, 0 pinned, determinism + exact
resume intact, test_logic + test_biota pass, full app builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:26:38 +02:00

315 lines
16 KiB
C++

#include "Planet.hpp"
#include <algorithm>
#include <string>
#include <vector>
#include <type_traits>
#include <istream>
#include <ostream>
#include <fstream>
#include <sstream>
// --- Config file (text) + save/load (binary) --------------------------------
// One shared field table so saveConfig/loadConfig can never drift apart.
// D = double field, I = int field, U = uint32 field.
#define CONFIG_FIELDS(D, I, U) \
D(radius) D(seaLevel) D(axialTilt) D(continentBase) D(oceanBase) D(upliftGain) D(relax) \
D(collisionFactor) D(arcFactor) D(isostaticPersist) D(rootScale) \
D(peakSoftCapStart) D(peakSoftCapEnd) D(peakFailDrop) \
D(seafloorSubsidence) D(seafloorSeedAge) \
D(maxDriftSpeed) D(ridgeDepth) D(splitFraction) D(splitProbBase) D(splitProbSlope) \
D(stalemateEps) D(stalemateBoost) D(babyPromoteFrac) D(volcanicLandFrac) \
D(volcanicElev) D(landBand) D(erosionLandRate) D(erosionSeaRate) \
D(landFractionTarget) D(seaLevelStep) D(seaLevelTol) \
D(phase3AfterMy) D(phase3DtScale) D(rainfall) D(riverThreshold) D(riverIncision) \
D(riverDischargeExp) D(riverSlopeExp) D(riverTransport) D(depFrac) \
D(biomeEquatorTemp) D(biomePoleDrop) D(biomeLatExp) D(biomeElevLapse) \
D(biomeIceTemp) D(biomeTundraTemp) D(biomeTaigaTemp) D(biomeSavannaTemp) \
D(biomeMountainElev) D(biomeHillsElev) D(biomeBeachBand) D(biomeLowlandElev) \
D(biomeWetlandMoist) D(biomeDesertMoist) D(biomeGrassMoist) D(biomeTaigaMoist) \
D(biomeLakeMinDepth) \
D(climateOceanMoisture) D(climateRainEfficiency) D(climateOrographic) \
D(climateOroRefHeight) D(climateContinentality) \
D(bioVegTempMin) D(bioVegTempOpt) D(bioVegMoistRef) D(bioFaunaProductivity) \
D(bioCarnPreyMin) D(bioCarnScale) D(bioFungaMoistRef) D(bioFungaFloraWeight) \
D(bioFungaTempMin) D(bioRegionBonus) \
I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) \
U(seed)
// Write all config fields as `key = value` lines (no header). Shared by the text
// config file (saveConfig) and the self-describing config block embedded in saves.
static void writeConfigFields(std::ostream& os, const PlanetConfig& cfg) {
#define WRITE(name) os << #name " = " << cfg.name << "\n";
CONFIG_FIELDS(WRITE, WRITE, WRITE)
#undef WRITE
}
// Parse `key = value` lines from any stream into cfg (unknown keys ignored, missing
// keys keep cfg's existing value). Shared by loadConfig + readState. This is why
// adding/removing config fields no longer breaks saves.
static void parseConfigStream(std::istream& is, PlanetConfig& cfg) {
auto trim = [](std::string& s) {
size_t a = s.find_first_not_of(" \t\r\n"), b = s.find_last_not_of(" \t\r\n");
if (a == std::string::npos) s.clear(); else s = s.substr(a, b - a + 1);
};
std::string line;
while (std::getline(is, line)) {
size_t hash = line.find('#'); if (hash != std::string::npos) line.resize(hash);
size_t eq = line.find('='); if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq), val = line.substr(eq + 1);
trim(key); trim(val);
if (key.empty() || val.empty()) continue;
#define D(name) if (key == #name) { try { cfg.name = std::stod(val); } catch (...) {} continue; }
#define I(name) if (key == #name) { try { cfg.name = std::stoi(val); } catch (...) {} continue; }
#define U(name) if (key == #name) { try { cfg.name = (uint32_t)std::stoul(val); } catch (...) {} continue; }
CONFIG_FIELDS(D, I, U)
#undef D
#undef I
#undef U
}
}
bool saveConfig(const std::string& path, const PlanetConfig& cfg) {
std::ofstream os(path);
if (!os) return false;
os.precision(15); // enough for the (nice, decimal) defaults; trailing zeros trimmed
os << "# Planet config -- edit values, then reload in-app (F2) or restart.\n";
os << "# key = value; '#' starts a comment; unknown keys are ignored.\n\n";
writeConfigFields(os, cfg);
return (bool)os;
}
bool loadConfig(const std::string& path, PlanetConfig& cfg) {
std::ifstream is(path);
if (!is) return false;
parseConfigStream(is, cfg);
return true;
}
std::string validateConfig(const PlanetConfig& cfg) {
auto rng = [](double v, double lo, double hi, const char* name) -> std::string {
if (v >= lo && v <= hi) return {};
return std::string(name) + " = " + std::to_string(v) + " (expected " +
std::to_string(lo) + ".." + std::to_string(hi) + ")";
};
auto irng = [](int v, int lo, int hi, const char* name) -> std::string {
if (v >= lo && v <= hi) return {};
return std::string(name) + " = " + std::to_string(v) + " (expected " +
std::to_string(lo) + ".." + std::to_string(hi) + ")";
};
std::vector<std::string> bad;
auto E = [&](const std::string& s) { if (!s.empty()) bad.push_back(s); };
E(rng(cfg.radius, 1.0e3, 1.0e8, "radius"));
E(rng(cfg.seaLevel, -11000.0, 9000.0, "seaLevel"));
E(rng(cfg.axialTilt, 0.0, 180.0, "axialTilt"));
E(rng(cfg.continentBase, -2000.0, 6000.0, "continentBase"));
E(rng(cfg.oceanBase, -11000.0, 1000.0, "oceanBase"));
E(rng(cfg.upliftGain, 100.0, 1.0e7, "upliftGain"));
E(rng(cfg.relax, 0.001, 0.5, "relax"));
E(rng(cfg.collisionFactor, 0.0, 20.0, "collisionFactor"));
E(rng(cfg.arcFactor, 0.0, 20.0, "arcFactor"));
E(rng(cfg.isostaticPersist, 0.0, 0.95, "isostaticPersist"));
E(rng(cfg.rootScale, 100.0, 20000.0, "rootScale"));
E(rng(cfg.peakSoftCapStart, 0.0, 20000.0, "peakSoftCapStart"));
E(rng(cfg.peakSoftCapEnd, 0.0, 20000.0, "peakSoftCapEnd"));
E(rng(cfg.peakFailDrop, 0.0, 5000.0, "peakFailDrop"));
E(rng(cfg.seafloorSubsidence, 0.0, 2000.0, "seafloorSubsidence"));
E(rng(cfg.seafloorSeedAge, 0.0, 1000.0, "seafloorSeedAge"));
E(rng(cfg.maxDriftSpeed, 0.1, 100.0, "maxDriftSpeed"));
E(rng(cfg.ridgeDepth, -8000.0, 0.0, "ridgeDepth"));
E(rng(cfg.splitFraction, 0.0, 1.0, "splitFraction"));
E(rng(cfg.splitProbBase, 0.0, 1.0, "splitProbBase"));
E(rng(cfg.splitProbSlope, 0.0, 1.0, "splitProbSlope"));
E(rng(cfg.stalemateEps, 0.0, 1.0, "stalemateEps"));
E(rng(cfg.stalemateBoost, 1.0, 5.0, "stalemateBoost"));
E(rng(cfg.babyPromoteFrac, 0.001, 0.5, "babyPromoteFrac"));
E(rng(cfg.volcanicLandFrac, 0.0, 1.0, "volcanicLandFrac"));
E(rng(cfg.volcanicElev, -1000.0, 5000.0, "volcanicElev"));
E(rng(cfg.landBand, 0.0, 1.0, "landBand"));
E(rng(cfg.erosionLandRate, 0.0, 1.0, "erosionLandRate"));
E(rng(cfg.erosionSeaRate, 0.0, 1.0, "erosionSeaRate"));
E(rng(cfg.landFractionTarget, 0.01, 0.99, "landFractionTarget"));
E(rng(cfg.seaLevelStep, 1.0, 2000.0, "seaLevelStep"));
E(rng(cfg.seaLevelTol, 0.001, 0.5, "seaLevelTol"));
E(rng(cfg.phase3AfterMy, 0.0, 1.0e6, "phase3AfterMy"));
E(rng(cfg.phase3DtScale, 0.001, 1.0, "phase3DtScale"));
E(rng(cfg.rainfall, 0.0, 1.0e6, "rainfall"));
E(rng(cfg.riverThreshold, 0.0, 1.0e9, "riverThreshold"));
E(rng(cfg.riverIncision, 0.0, 1.0e6, "riverIncision"));
E(rng(cfg.riverDischargeExp, 0.0, 5.0, "riverDischargeExp"));
E(rng(cfg.riverSlopeExp, 0.0, 5.0, "riverSlopeExp"));
E(rng(cfg.riverTransport, 0.0, 1.0e6, "riverTransport"));
E(rng(cfg.depFrac, 0.0, 1.0, "depFrac"));
E(rng(cfg.biomeEquatorTemp, -50.0, 80.0, "biomeEquatorTemp"));
E(rng(cfg.biomePoleDrop, 0.0, 150.0, "biomePoleDrop"));
E(rng(cfg.biomeLatExp, 0.1, 6.0, "biomeLatExp"));
E(rng(cfg.biomeElevLapse, 0.0, 0.05, "biomeElevLapse"));
E(rng(cfg.biomeIceTemp, -60.0, 20.0, "biomeIceTemp"));
E(rng(cfg.biomeTundraTemp, -60.0, 40.0, "biomeTundraTemp"));
E(rng(cfg.biomeTaigaTemp, -60.0, 40.0, "biomeTaigaTemp"));
E(rng(cfg.biomeSavannaTemp, -20.0, 60.0, "biomeSavannaTemp"));
E(rng(cfg.biomeMountainElev, 0.0, 11000.0, "biomeMountainElev"));
E(rng(cfg.biomeHillsElev, 0.0, 11000.0, "biomeHillsElev"));
E(rng(cfg.biomeBeachBand, 0.0, 2000.0, "biomeBeachBand"));
E(rng(cfg.biomeLowlandElev, 0.0, 11000.0, "biomeLowlandElev"));
E(rng(cfg.biomeWetlandMoist, 0.0, 1.0, "biomeWetlandMoist"));
E(rng(cfg.biomeDesertMoist, 0.0, 1.0, "biomeDesertMoist"));
E(rng(cfg.biomeGrassMoist, 0.0, 1.0, "biomeGrassMoist"));
E(rng(cfg.biomeTaigaMoist, 0.0, 1.0, "biomeTaigaMoist"));
E(rng(cfg.biomeLakeMinDepth, 0.0, 5000.0, "biomeLakeMinDepth"));
E(rng(cfg.climateOceanMoisture, 0.0, 1.0e3, "climateOceanMoisture"));
E(rng(cfg.climateRainEfficiency, 0.0, 1.0, "climateRainEfficiency"));
E(rng(cfg.climateOrographic, 0.0, 50.0, "climateOrographic"));
E(rng(cfg.climateOroRefHeight, 1.0, 1.0e5, "climateOroRefHeight"));
E(rng(cfg.climateContinentality, 0.0, 1.0, "climateContinentality"));
E(rng(cfg.bioVegTempMin, -40.0, 30.0, "bioVegTempMin"));
E(rng(cfg.bioVegTempOpt, -20.0, 50.0, "bioVegTempOpt"));
E(rng(cfg.bioVegMoistRef, 0.01, 1.0, "bioVegMoistRef"));
E(rng(cfg.bioFaunaProductivity, 0.0, 2.0, "bioFaunaProductivity"));
E(rng(cfg.bioCarnPreyMin, 0.0, 1.0, "bioCarnPreyMin"));
E(rng(cfg.bioCarnScale, 0.0, 5.0, "bioCarnScale"));
E(rng(cfg.bioFungaMoistRef, 0.01, 1.0, "bioFungaMoistRef"));
E(rng(cfg.bioFungaFloraWeight, 0.0, 1.0, "bioFungaFloraWeight"));
E(rng(cfg.bioFungaTempMin, -50.0, 20.0, "bioFungaTempMin"));
E(rng(cfg.bioRegionBonus, 0.0, 10.0, "bioRegionBonus"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
E(irng(cfg.splitCheckEvery, 1, 10000, "splitCheckEvery"));
E(irng(cfg.stalemateWindows, 1, 100, "stalemateWindows"));
E(irng(cfg.miniPlateCells, 1, 10000, "miniPlateCells"));
E(irng(cfg.fuseMinPlates, 2, 50, "fuseMinPlates"));
E(irng(cfg.babyMinCells, 1, 1000, "babyMinCells"));
E(irng(cfg.seaLevelEvery, 1, 100000, "seaLevelEvery"));
E(irng(cfg.climateWindPasses, 1, 1000, "climateWindPasses"));
E(irng(cfg.climateMoistureSmooth, 0, 100, "climateMoistureSmooth"));
E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots"));
E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots"));
E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots"));
E(irng(cfg.bioFloraPoints, 1, 100000, "bioFloraPoints"));
E(irng(cfg.bioFaunaPoints, 1, 100000, "bioFaunaPoints"));
E(irng(cfg.bioFungaPoints, 1, 100000, "bioFungaPoints"));
if (cfg.oceanBase >= cfg.continentBase)
bad.push_back("oceanBase >= continentBase (ocean floor must be below continents)");
if (cfg.peakSoftCapStart >= cfg.peakSoftCapEnd)
bad.push_back("peakSoftCapStart >= peakSoftCapEnd (grow probability must span a band)");
if (bad.empty()) return {};
std::string msg = "Bad config:";
for (auto& s : bad) msg += "\n " + s;
return msg;
}
namespace {
template <class T> void writePod(std::ostream& os, const T& v) {
static_assert(std::is_trivially_copyable<T>::value, "writePod needs a POD type");
os.write(reinterpret_cast<const char*>(&v), sizeof(T));
}
template <class T> void readPod(std::istream& is, T& v) {
static_assert(std::is_trivially_copyable<T>::value, "readPod needs a POD type");
is.read(reinterpret_cast<char*>(&v), sizeof(T));
}
template <class T> void writeVec(std::ostream& os, const std::vector<T>& v) {
static_assert(std::is_trivially_copyable<T>::value, "writeVec needs POD elements");
uint64_t n = v.size(); writePod(os, n);
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) {
static_assert(std::is_trivially_copyable<T>::value, "readVec needs POD elements");
uint64_t n = 0; readPod(is, n); v.resize((size_t)n);
if (n) is.read(reinterpret_cast<char*>(v.data()), (std::streamsize)(n * sizeof(T)));
}
}
// Full simulation state. Geometry (unit/neighbors) is NOT stored -- it is rebuilt
// from cfg.subdivisions on load -- so only the dynamic per-cell fields are saved.
void Planet::writeState(std::ostream& os) const {
// Config is stored as a self-describing key=value text block (length-prefixed),
// not a raw POD dump, so adding/removing config fields never breaks old saves
// (unknown keys ignored, missing keys keep their defaults). precision(17) =
// max_digits10 for double, so values round-trip exactly (deterministic resume).
std::ostringstream cfgss; cfgss.precision(17);
writeConfigFields(cfgss, cfg);
std::string cfgText = cfgss.str();
uint64_t clen = cfgText.size(); writePod(os, clen);
os.write(cfgText.data(), (std::streamsize)clen);
writePod(os, rngState);
writePod(os, driftIter);
writePod(os, erodeIter);
writePod(os, targetLand);
uint64_t nc = cells.size(); writePod(os, nc);
for (const Cell& c : cells) {
writePod(os, c.elevation); writePod(os, c.plateId);
uint8_t oc = c.oceanic ? 1 : 0; writePod(os, oc);
writePod(os, c.geoAge); writePod(os, c.drift); writePod(os, c.invader);
uint8_t bm = (uint8_t)c.biome; writePod(os, bm); // save v4: per-cell biome
}
writeVec(os, plates);
writeVec(os, sPrevCount);
writeVec(os, sStaleStreak);
writeVec(os, sFreePlateIds);
// v7: discrete biota population (sBiota). A flag byte gates the block so a
// not-yet-populated world stays compact; otherwise three Organism lists per cell.
uint8_t hasBio = sHasBiota ? 1 : 0; writePod(os, hasBio);
if (hasBio) {
uint64_t nb = sBiota.size(); writePod(os, nb);
for (const CellBiota& cb : sBiota) {
writeVec(os, cb.flora); writeVec(os, cb.fauna); writeVec(os, cb.funga);
}
}
}
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota) {
// 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 (!is) return false;
PlanetConfig c;
{ std::istringstream cis(cfgText); parseConfigStream(cis, c); }
cfg = c;
buildGeometry(); // rebuild unit/neighbors from cfg.subdivisions
readPod(is, rngState);
readPod(is, driftIter);
readPod(is, erodeIter);
readPod(is, targetLand);
uint64_t nc = 0; readPod(is, nc);
if (!is || nc != cells.size()) return false; // subdivisions mismatch / corrupt file
for (Cell& cell : cells) {
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
}
readVec(is, plates);
readVec(is, sPrevCount);
readVec(is, sStaleStreak);
readVec(is, sFreePlateIds);
if (!hasBiome) classifyBiomes(); // old (v3) save: reclassify from loaded state
// v7: discrete biota population. buildGeometry() already sized sBiota empty;
// older saves (hasBiota=false) just keep the empty population (press L to fill).
sHasBiota = false;
if (hasBiota) {
uint8_t hasBio = 0; readPod(is, hasBio);
if (hasBio) {
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 (!cb.flora.empty() || !cb.fauna.empty() || !cb.funga.empty()) sHasBiota = true;
}
}
}
computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is;
}