planetsim/CLAUDE.md
Jonas Reith acc0e5eec9 Initial commit: fanworgen planet sim
C++/raylib semi-realistic fantasy/sci-fi planet generator on a fixed icosphere
grid (Eulerian: properties flow over fixed cells). World-creation stages:
tectonics, continental drift & erosion, hydrology (rivers/lakes), climate
(temperature + orographic precipitation), and biome classification. Engine in
src/sim (raylib-free, headless-testable), viewer in src/render. See CLAUDE.md
and docs/ (design-notes.md, fauna-flora-plan.md = next step).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:08:25 +02:00

521 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CLAUDE.md
Guidance for Claude Code when working in this repository.
## Project
A C++/raylib simulation/game for creating semi-realistic fantasy & sci-fi
worlds/planets. Built in phases. Between phases the user can edit the world
or trigger events (meteor impact, magical events, sci-fi terraforming).
**Core principle:** the planet geometry is FIXED. Cells (vertices/faces) never
move. Only their *properties* flow over the fixed grid (Eulerian, not
Lagrangian). This keeps the data structure stable across all phases and makes
erosion, climate and drift far simpler to implement later.
## Roadmap
The project has two big arcs. **World Creation** builds a plausible planet through a
set of geological/environmental stages that **overlap and run together on a geological
clock** (My) — they are not strict sequential "phases" (the code keeps `phase*` names
internally for save/config compatibility, but think of them as continuous stages). Once
a world is "done", the long-term goal is a separate **Live World** mode that runs the
finished planet at a *much* slower, real-time-ish clock (hours/days/weeks/months) with
dynamic weather and life.
**World Creation (geological clock, mostly done):**
- **Tectonics & landmass** *(done)* — icosphere geometry, plate assignment, boundary
stress forming mountains (convergent) and trenches/rifts (divergent). The initial
"forming" pass settles to isostatic equilibrium, then plate motion continues.
- **Continental drift & erosion** *(done)* — real plate motion (cm/yr, My), the plate
lifecycle (fission, stalemate kick, spreading-born plates, merging), diffusive
erosion + a sea-level controller (~30% land), taller persistent mountains
(collision + arc + isostatic persistence), seafloor aging→depth. `planet.cfg` +
`planet.save`. See [[phase2-design-direction]].
- **Hydrology** *(done)* — rivers, lakes and fluvial erosion as a macro drainage network
(depression-fill→lakes, steepest-descent→rivers, mass-conserving stream-power
incision). Runs at a finer timestep alongside continuing drift. See [[phase3-hydrology]].
- **Climate** *(done)* — continuous per-cell temperature + orographic precipitation
(`Planet::computeClimate`); runs from forming onward (a live "base climate" that
updates as terrain changes). Color modes `6`/`7`.
- **Biomes** *(done)* — per-cell `Cell.biome` (13 biomes incl. polar Ice) from elevation
+ the climate fields (`Planet::classifyBiomes`), color mode `5`, saved per cell.
- **Fauna & flora** *(next, planned — see `docs/fauna-flora-plan.md`)* — derived
carrying-capacity densities: vegetation (flora) + a herbivore/carnivore food chain (fauna),
computed from the climate fields each tick (the living/evolving ecosystem is reserved for
Live World). Other follow-ups: feed precipitation into hydrology rainfall; seasons (obliquity).
> Durable design context (module layout, save format, climate/biome model, conventions)
> lives in **`docs/design-notes.md`** — important because Claude's auto-memory does not
> travel with the repo.
**Live World (future):** with planet creation finished, run the world at a slow real-time
scale with dynamic **weather** (clouds, rain, storms, fronts), day/night, and living
**ecosystems / civilization** evolving in real time. This is a separate large effort;
the fixed-grid Eulerian model + the climate fields are the groundwork for it.
## Current state
Working and verified (logic tested headless):
- Icosphere level 5 → 10242 cells, ~223 km/cell, topologically correct
(exactly 12 degree-5 vertices, rest degree-6).
- Plate flood-fill, drift as rotation on the sphere, boundary-stress uplift.
- Relief builds *gradually* toward an isostatic equilibrium (no clamp-rail
saturation): after ~40 ticks graded mountain belts (>2000 m) and deep
subduction trenches (<-6000 m), 0% of cells pinned to the clamp.
- `test_logic.cpp` asserts geometry, plate assignment, non-saturation and
graded relief; run it after any `Planet::step()` change (see below).
- raylib render: 3D globe (top) + 2D Equal Earth map (bottom strip), orbit
camera, 4 color modes. Phase 1 is a **generator**: it paces tectonic ticks
toward isostatic equilibrium (watchable, ~3 s), renders live, and
**auto-pauses** once the terrain settles (max per-tick change < 2 m). No
background thread that's Phase-2 (continuous drift/erosion) groundwork.
- **Phase 2 increment 1 (drift):** after forming settles, the app switches to
continuous **drift mode** on a My clock. Each plate has a real speed (1..20
cm/yr); `cflDtMy()` sets the timestep so the fastest plate advances ~half a
cell/step. `advect(dt)` moves plate membership + carried crust (plateId,
elevation, oceanic, geoAge) by an accumulation scheme: a boundary cell builds
`drift` (signed convergence distance) with its dominant other-plate neighbor;
+1 cell -> overrun (take that crust; but oceanic can't overrun a buoyant
continent -> it subducts under), -1 cell -> new young ridge crust (spreading).
Crust type now lives on the **cell** (`Cell.oceanic`), not the plate. The HUD
shows elapsed My; `[`/`]` set My/sec; auto-settle still gates Phase 1 -> 2.
- **Phase 2 increment 1.5 (plate lifecycle):** without this the count collapsed
to 1-3 and the world froze (a giant continental plate that can't shed cells).
Every `splitCheckEvery`(10) advect iterations (gated by `driftIter`) `advect()`
runs a Wilson-cycle lifecycle: **fission** (a plate over `splitFraction`(20%)
of cells splits along a random great circle through its centroid; prob ramps
`0.05+0.05*(pct-20)`, the new half gets a random drift — self-regulating, so
the count equilibrates ~8-12), **stalemate kick** (a plate whose size barely
changed over a window gets a new direction + speed boost to break deadlocks),
and **spreading plates** (rift cells become a `Plate.baby` young-ridge strip;
`coalesceBabyPlates()` merges connected blobs and dissolves tiny noise ones;
a strip past `babyPromoteFrac`(0.7%) is promoted to a real plate with random
drift + `volcanicLandFrac` of its interior turned to volcanic-island land).
The hard land clamp became a **soft band** (`landBand`) so volcanic land
persists; `acquirePlate()` reuses dead plate slots to keep `plates` bounded.
Stats panel separates real plates from "Young ridges: N strips"; plate-color
mode tints baby cells a uniform ridge grey. Verified by a headless soak.
- **Phase 2 increment 1.6 (calmer + cleaner plate map):** kicks were too twitchy
("strange border movement") so a plate must now stall for `stalemateWindows`(4)
consecutive check windows (`sStaleStreak`) before a kick. `deleteEnclosedPlates()`
absorbs any plate ringed by a single other plate into it (on a mutual pair only
the smaller). `fuseMiniPlates()` lets >= `fuseMinPlates`(3) clustered mini plates
(non-baby, < `miniPlateCells`) fuse into the largest member and steal one ring of
cells from their largest big neighbour (terrane amalgamation). Borders now draw
in two colors: real plate borders yellow, young spreading-ridge borders red
(3D + 2D, both via `B`). Soak: mini plates ~0, no enclosed slivers, ~8-12 plates.
- **Phase 2 increment 2 (erosion + sea level):** `Planet::erode(dtMy)` runs each
drift step after advect+step — a mass-conserving, slope-weighted downhill
sediment transport (one double-buffered gather pass): the higher cell of each
edge gives material to the lower, faster above sea level (`erosionLandRate`)
than below (`erosionSeaRate`). Highs wear down toward an uplift<->erosion
equilibrium; sediment fills basins and builds coastal shelves/deltas. No river
carving (sub-cell at 223 km -> subgrid later). `adjustSeaLevel()` (every
`seaLevelEvery` erode calls) eases `cfg.seaLevel` toward the percentile
elevation leaving `landFractionTarget` (30%) of cells above water — percentile
targeting (`nth_element`) because a proportional nudge oscillates across the
flat continental-base elevation. Two "land" notions coexist: crust type (plate
buoyancy) vs geographic (elev > seaLevel). Stats headline + water:land are now
geographic, with separate "Sea level" and "Crust %" lines. Headless: land -> 30%
all seeds, erosion conserves sum(elevation), deterministic.
- **Phase 2 increment 3 (gradual sea level + config/save files):** the sea-level
controller is now gradual — checked every `seaLevelEvery` (100) erode calls, it
nudges sea level by a fixed `seaLevelStep` (100 m) when outside a `seaLevelTol`
(2%) deadband, and only if the nudge reduces the error (so it rests near a flat
"cliff" instead of oscillating). A human-editable **`planet.cfg`** (key=value)
holds all PlanetConfig params (auto-created on first run, `F2` reloads +
regenerates); `loadConfig`/`saveConfig` share one `CONFIG_FIELDS` X-macro. A
**`planet.save`** binary holds seed + config + full planet state
(`Planet::writeState`/`readState`; geometry rebuilt from subdivisions via
`buildGeometry()`); `F5` saves, `F9` loads and resumes (deterministic
continuation, verified headless). File I/O lives in Planet (raylib-free).
- **Phase 2 polish (config validation, QoL, sim cleanups):** `validateConfig()`
range-checks every field (+ the cross-rule `oceanBase < continentBase`) on load
and `F2`; an invalid `planet.cfg` reverts to safe defaults without overwriting
it. Save bumped to **version 2** (now also persists the `[`/`]` drift rate;
version-gated reads accept older saves). Viewer adds a **crust-type color mode**
(`4`: continental warm brown / oceanic deep blue), `F` **fast-forward** (runs
`step()` to settled instantly), `F12` screenshot, `--seed`/`--config` CLI flags,
a `P<id>` label per plate on the drift arrows, and a min subdivision level of 1
(level 0 disallowed). Two sim refinements: `step()` re-anchors boundary (source)
cells to their original stress each dilation ring so adjacent belts don't
cross-inflate (sharper peaks; land flank cells 509->499); and the soft land-band
rift/accrete nudge now reads a frozen snapshot of pre-nudge `oceanic` so flipping
one cell can't cascade along vertex-index chains into linear "snakes". The
earlier drift-direction "inward" fix was reverted (it worsened snaking).
- **Phase 2 increment 4 (taller mountains + seafloor aging):** mountains used to
cap ~5000 m because collisions were under-weighted and `relax` snapped uplifted
crust back to `continentBase` once a migrating front passed. Now `step()` adds a
real **continent-continent collision** factor (`colliding[]` = continental cell
facing continental, `cfg.collisionFactor`) and an **Andes-class continental arc**
factor (`cfg.arcFactor`), and high continental crust gets **isostatic
persistence**: `relaxEff = relax*(1 - isostaticPersist*clamp((elev-continentBase)
/rootScale,0,1))`, so thick ranges stand and become *erosion-limited* (by the
drift-loop `erode()`) instead of relaxing away. These three boosts are **gated on
the `Planet::drifting` flag — active only in Phase-2 drift, OFF during Phase-1
forming** (which keeps the original mild factors + full relax). This is
deliberate: Phase-1 forming runs `step()` with no erosion, so if the strong
uplift + weak relax were active there it would never settle (uplift never
balanced) and would rail the clamp — gating to drift, where `erode()` runs every
tick, avoids both. main.cpp sets `planet.drifting=true` when forming settles (and
in `loadGame` from the saved phase), `false` on reseed/regen. **Seafloor aging->depth:**
oceanic crust subsides with `geoAge` via `oceanicBase(age) =
max(oceanBase, ridgeDepth - seafloorSubsidence*sqrt(age))` (half-space cooling);
`oceanBase` is now the **deep abyssal floor** (-6000 m), `ridgeDepth` the shallow
young value (-2500 m), and `seedInitialRelief()` seeds an oceanic age spread
(`seafloorSeedAge`) so the starting seafloor already has ridge->abyss variety.
Headless: ~half of seeds produce >7000 m ranges that persist (the rest are
legitimately low-relief ocean worlds), <2% pinned to the clamp on all seeds,
older seafloor markedly deeper, deterministic. Tune the new knobs in `planet.cfg`.
- **Phase 3 increment 1 (hydrology: rivers, lakes, fluvial erosion):**
`Planet::hydrology(dtMy)` = `routeFlow()` then mass-conserving fluvial erosion, on
the fixed grid (Eulerian, raylib-free). `routeFlow()` does priority-flood
depression-filling (epsilon tilt so flats drain; ocean cells are outlets)
`sFill`/`sLakeDepth` (a cell with `lakeDepth>0` above sea level is a **lake**),
steepest-descent over the filled surface `sFlowTo`, and flow accumulation in
descending-fill order `sDischarge` (rivers = `discharge>riverThreshold`). The
erosion pass walks the network upstreamdownstream carrying a sediment load:
stream-power incision `K*Q^m*S^n*dt` where under capacity, deposition where over
(`cap=riverTransport*Q*S`) filling lakes, building deltas at mouths, depositing
the remainder at ocean sinks so **sum(elevation) is conserved**. Lakes/rivers are
**derived from elevation each tick** (no new saved per-cell field; only erosion
writes back to `elevation`). Orchestration (main.cpp): after `phase3AfterMy`
drift-My the sim **pauses and prompts** ("Continue Phase 2" / "Start Phase 3");
`H` toggles Phase 3 manually. In Phase 3 the drift loop keeps running
(advect/step/erode) but at a **finer dt** (`cflDtMy()*phase3DtScale`) plus
`hydrology(dt)` drift never stops, just resolves finer. Lakes shade inland-water
blue (`recolor`); rivers draw as a `centroid→downstream` line network (3D + 2D,
two widths, `J` toggles). Save bumped to **v3** (+ a `phase3` header flag).
Headless: discharge grows downstream and all land rainfall reaches the sinks,
mass conserved to ~1e-15, ~10 lake systems + rivers persist, deterministic.
(Note: the hydrology phase is now framed in the UI as **Phase 2.5**; the
internal `phase3*` names are unchanged.)
- **Phase 3 increment 1 (biomes classify + color):** `Planet::classifyBiomes()`
(src/sim/PlanetBiomes.cpp, raylib-free) writes a per-cell `Cell.biome` (enum
`Biome`, 13 entries: Ocean, **Ice**, Lake, Beach, Wetland, Grassland, Savanna,
Desert, Forest, Taiga, Tundra, Hills, Mountains). A first **rule-based** pass with
no real climate yet: temperature = warm-equator curve (super-linear in latitude so
cold concentrates at the poles) minus an elevation lapse; moisture = latitudinal
rainfall belts (wet equator/mid-lat, dry subtropicsdeserts) + river discharge +
coastal proximity; classified first-match (iceoceanlakebeachmountainshills
lowland-by-temp/moisture). **Polar ice caps** fall out of the temperature test (it
also snow-caps high peaks). All thresholds are **tunable in `planet.cfg`** (the
`biome*` PlanetConfig fields `biomeIceTemp`, `biomeMountainElev`, the moisture
cutoffs, etc.; only the latitudinal rainfall-belt curve shape stays a fixed helper).
The biome is **saved per cell** save bumped to **v4** (per-cell biome byte appended
after `invader`; `readState(is, hasBiome)` reads it for v4, reclassifies for v3, so v3
saves remain loadable). Rendered as color mode `5` (`biomeColor`, src/render/Colors.cpp);
re-run each `refreshView`. `lakeColor` changed to bright turquoise so lakes read clearly
vs ocean (the "blue speckle" near a clicked cell is just the subgrid detail overlay,
where ±250 m value-noise dips below sea level near coasts not lakes). Headless:
every cell valid, both poles Ice, deep equatorial water Ocean, 4 land biomes present,
deterministic, v4 round-trips biome, v3 loads + reclassifies.
- **Phase 3 polish (smaller caps + axial tilt + grid labels):** ice caps trimmed a few
points (~17%→~14% of cells) by lowering `ICE_TEMP` in PlanetBiomes.cpp. Added a
planetary **`axialTilt`** (obliquity, default 23.44°, in PlanetConfig/`planet.cfg`):
the 3D globe + a drawn **spin-axis rod** (through the poles, red/blue pole caps) lean
by it via an `rlRotatef` about world Z wrapping all 3D content in `renderGlobe3D`;
picking un-rotates the world hit dir by tilt (`rotateZ`, src/render/Picking.cpp) and
3D plate labels rotate by +tilt so everything stays consistent (the picking sphere is
rotation-invariant). Biomes/2D map are unchanged (tilt is visual + groundwork for
seasons). The graticule (`G`) now shows **lat/lon degree numbers on the 2D map** edges
(`drawGraticuleLabels2D`, plain "60N"/"120W" the default font has no `°`). `axialTilt`
was added to PlanetConfig (`planet.cfg`). Headless: ice ~14%, biome + `axialTilt`
round-trip, deterministic.
- **Phase 3 polish (biome knobs in config + future-proof save):** the 17 biome
classification thresholds moved from constants into PlanetConfig `biome*` fields
(tunable in `planet.cfg`, `F2`). To stop config additions from breaking saves each
time, the save now stores config as a **self-describing key=value text block** (save
**v6**) parsed like `planet.cfg` (`writeConfigFields`/`parseConfigStream` shared);
doubles written at `precision(17)` round-trip exactly. Adding/removing config fields no
longer breaks saves; v6 just can't load pre-v6 saves (one-time break). Headless: cfg
(incl. non-default `biome*`) round-trips exactly, unknown/missing keys handled.
- **Phase 3 increment 2 (climate model):** `Planet::computeClimate()`
(src/sim/PlanetClimate.cpp, raylib-free, derived/not saved) builds two continuous
per-cell fields. **Temperature** `sTemp` C) = the latitude curve (`biome*` temp
params) elevation lapse. **Precipitation** `sPrecip`: prevailing winds are zonal by
band (tropics/polar easterly, mid-lat westerly); ocean cells are a moisture source and
each land cell takes its **upwind** neighbour's moisture, **rains out** more on windward
upslopes (orographic) and loses a multiplicative fraction per cell (continentality), so
leeward + deep-interior cells dry out. The raw field is near-binary (saturated where the
wind hits the sea, ~0 elsewhere), so it's **diffused** `climateMoistureSmooth` passes to
create wetdry transition zones, then normalized to `sMoist` (0..1, **median land
0.5**, robust to orographic spikes). `classifyBiomes()` now reads `sTemp`/`sMoist`
(dropping the old latitude+discharge+coast hack) rain-shadow/interior **deserts** + a
varied, per-world biome spread; wetlands now require adjacency to water (ocean/lake).
Color modes `6` (temperature, bluered) / `7` (precipitation, drywet). `computeClimate()`
runs before `classifyBiomes()` in `generate()` and `refreshView()`. New `climate*` config
knobs (planet.cfg). Headless: equator warm/poles cold, lapse, coastal wetter than interior,
deserts present, deterministic.
- **UI polish (full cell info + view label + framing):** the cell-info panel
(`cellInfo`, src/render/Panels.cpp) now shows everything per cell crust type, **biome**
(`biomeName`), **temperature** + **precipitation %**, and **river/lake** when hydrology is
on in addition to the existing cell#, lat/lon, elevation, plate, geoAge. The active
color mode is shown **top-center** of the globe ("Biome view", etc., via `colorModeName`),
updating with `1``7`. The HUD title/status and the hydrology prompt were reworded to drop
the rigid "Phase N" labels (now "World Creation: forming / drift & erosion / hydrology");
internal `phase*` names are unchanged. Render/text only no sim/save/config change.
- Mouse hover (in either view) shows per-cell info. Clicking a tile opens a
right-side detail panel: tile info header + the tile's subgrid drawn as a
flat hoverable grid of subtiles (neighbor-owned subtiles dimmed). A high-res
subgrid patch is also overlaid on the globe for context; the hovered subtile
is marked on the globe. `C` closes the panel.
## Architecture
The code is split into a **raylib-free engine** (`src/sim`, testable headless)
and a **raylib viewer** (`src/render`); `src/main.cpp` is a ~10-line entry point.
`Planet` is one class implemented across several `.cpp` files (one per phase/
concern, all sharing `Planet.hpp`); the viewer is one `Viewer` struct whose state
+ methods are likewise spread across a few render files. CMake adds both folders
to the include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
```
src/
main.cpp entry point: build Viewer, init(argc,argv), run()
sim/ (raylib-free engine -- testable headless)
Vec3.hpp double-precision 3D vector math
IcoSphere.* geodesic icosphere: fixed vertices + neighbor adjacency
Projection.hpp Equal Earth equal-area projection (forward + Newton inverse),
dir<->lon/lat helpers. Header-only, raylib-free, testable.
PlanetTypes.hpp Cell / Plate / SubGrid / PlanetConfig data structures
Planet.hpp the Planet class declaration + config-file func decls
Planet.cpp generation, geometry, plate seeding, shared helpers, subgrid
PlanetTectonics.cpp step() (Phase 1/2 stress->uplift->relax)
PlanetDrift.cpp cflDtMy/advect + plate lifecycle (fission/kick/baby/fuse)
PlanetErosion.cpp erode() + adjustSeaLevel()
PlanetHydrology.cpp routeFlow/computeHydrology/hydrology (Phase 3)
PlanetIO.cpp config file (text) + binary save/load
render/ (raylib viewer)
Colors.* cell color modes (elevation/plate/age/crust/lake)
Map2D.* Equal Earth 2D map: positions + projection/draw helpers
Overlays.* borders, drift arrows, rivers, graticule, segments, subgrids
Picking.* mouse ray / sphere hit / nearest-cell / angle helpers
Panels.* right-column UI: detail panel, hover info, world stats
Viewer.{hpp,cpp} Viewer struct: all state + setup + sim orchestration
ViewerInput.cpp handleInput(): camera, hover picking, click, keys
ViewerRender.cpp renderGlobe3D / renderMap2D / renderPanels / renderHUD / renderPrompt
CMakeLists.txt fetches raylib 5.5 via FetchContent; lists src/sim + src/render
BUILD.md dependencies + build/run + controls
```
### Key data structures (src/sim/PlanetTypes.hpp)
- `Cell` `unit` (fixed sphere direction), `elevation` (continuous meters,
double, NOT quantized), `plateId`, `geoAge`, `neighbors`, and a
`std::shared_ptr<SubGrid> subgrid` HOOK (still null on the cell; the viewer
builds subgrids on demand via `Planet::makeSubGrid(cell,res)`).
- `SubGrid`/`SubCell` a res*res patch around one macro cell; elevation is an
inverse-distance blend of that cell + neighbors plus value-noise detail, and
each subcell records `nearestMacro`. Phase-4 preview, generated on click.
- `Plate` `type` (Oceanic/Continental), `driftAxis`, `driftSpeed`.
- `PlanetConfig` `radius` (default 6.371e6 m, Earth), `subdivisions`,
`seaLevel`, `plateCount`, `seed`.
### Resolution notes (important, was discussed in design)
- **Lateral** resolution = cell spacing = `sqrt(4*pi*R^2 / N)`. Coarse on
purpose (~223 km at level 5). Only mountain-range scale, not single hills.
- **Vertical** (elevation) resolution is effectively unlimited: it is a
`double` in meters. 100 m steps or finer are free. Mount-Everest /
Mariana-Trench range is exactly representable.
- Climate (phase 3) reads elevation as a smooth continuous function, so no
resolution is lost by coarse height bands.
- For dense detail later (population/culture), generate a per-cell **subgrid**
on demand and mark which edge sub-cells border which neighbor macro-cell,
so cross-boundary interaction (rain shadow, transition zones) works.
## Build & run
```bash
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/planetsim
```
Target OS is Nobara Linux (KDE/Wayland, Intel Arc A770). Dependency install
line is in BUILD.md (`dnf install cmake gcc-c++ mesa-libGL-devel ...`).
raylib 5.5 is fetched automatically do not vendor it.
### Quick headless logic test (no display needed)
```bash
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \
src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \
src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetIO.cpp \
-o /tmp/t && /tmp/t
```
Use this to verify tectonics after changing `Planet::step()` without launching
the window (the engine lives in `src/sim` and is raylib-free, so it links without
any render code). The `#pragma omp` lines in `step()` are ignored without
`-fopenmp`, so this serial build is correct; add `-fopenmp` to benchmark the
threaded path.
### Performance / parallelism
`Planet::step()` is data-parallel: each pass writes only its own cell index
(double-buffered where it reads a field it writes), so the OpenMP `parallel for`
loops are **bit-identical for any thread count** (determinism intact). It is
memory-bandwidth-bound, so the speedup tops out ~3x (sub-7: ~5.1 -> ~1.5 ms)
around 4-8 threads regardless of core count. `step()` also reuses persistent
scratch buffers (the `s*` members) so it allocates nothing per tick. Small grids
stay serial via `if(n > 20000)`. Control threads with `OMP_NUM_THREADS` (4-8 is the sweet spot;
the default uses all cores for no extra gain). OpenMP is optional and
auto-detected by CMake. For a bigger leap (or 1M+ cells) the next step is a GPU
compute-shader port -- a Phase-2 effort.
## Controls
LMB drag orbit · wheel zoom · hover for cell info (3D or map) ·
click a tile to open its detail panel (subtiles) · `C` close panel ·
drag the 2D map to pan it east/west · `1`..`7` color by
elevation/plate/age/crust-type/biome/temperature/precipitation (active mode shown
top-center of the globe) ·
`B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3,
all in 3D + 2D) · `SPACE` or on-screen button pause · `[`/`]` drift speed (My/sec) ·
`S` single tick · `F` fast-forward Phase-1 forming to settled ·
`H` toggle Phase 3 (hydrology) · `R` reseed ·
`+`/`-` subdivision level (1..7) · `F5` save (`planet.save`) · `F9` load ·
`F12` screenshot (`screenshot.png`) · `F2` reload `planet.cfg` + regenerate.
Phase 3: after `phase3AfterMy` simulated years a modal prompt asks **Continue
Phase 2** / **Start Phase 3**; `H` starts/stops it manually. In Phase 3 drift
keeps running at a finer timestep (`cflDtMy()*phase3DtScale`) while rivers, lakes
and fluvial erosion evolve.
CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate).
Two files in the working dir: `planet.cfg` (human-editable key=value of every
PlanetConfig param, auto-created on first run, reload with `F2`) and
`planet.save` (binary: seed + config + full planet state, written/read by
`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 **6**; 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); newer-than-supported is rejected.
**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 —
a one-time break; a length guard makes that fail gracefully.) `writeConfigFields`/
`parseConfigStream` in PlanetIO.cpp are shared by `planet.cfg` and the save.
Layout (1920x1080): left column 70% wide = 3D globe (top, 60% h, RenderTexture
1344x648) + 2D Equal Earth map (bottom, 40% h); right column 30% wide = cell info
(top 50% h) + subareas (bottom 50% h). 3D hover uses a custom camera ray with the
3D viewport (1344x648 at the origin -- GetScreenToWorldRay assumes the full
screen, wrong here); 2D hover uses EqualEarth::inverse (minus the `mapLon` pan).
Borders (`B`), drift vectors (`D`) and a lat/lon graticule (`G`) draw in BOTH the
3D globe and the 2D map; drag the 2D map left/right to pan longitude (`mapLon`).
The graticule (`G`) also draws **lat/lon degree numbers along the 2D map edges**. The
3D globe (and its spin-axis rod) lean by `cfg.axialTilt` (an `rlRotatef` about world Z
around all 3D content in `renderGlobe3D`); 3D picking un-rotates the world hit dir by
tilt and plate labels rotate by +tilt (`rotateZ`, src/render/Picking.cpp) to stay in sync.
Borders draw in two colors: real plate boundaries yellow, young spreading-ridge
(baby-plate) boundaries red — both via `buildBorders` filling two segment lists.
Drift arrows: one cyan arrow per plate at its centroid along the local drift
velocity `omega x r`; length scales with speed. Rebuilt per world-gen. Each plate
is also tagged with a `P<id>` label drawn just above its centroid — projected by
hand in 3D (the project's own `Vec3` camera-basis math, matching `BeginMode3D`'s
fovy/aspect, behind-camera points skipped) and via Equal Earth in the 2D map.
**Sim model (`Viewer::stepSim`, src/render/Viewer.cpp):** single-threaded. Each
frame (while not paused/settled)
it advances ~`formRate` (55) ticks/second of `planet.step()` (Phase 1 = pure
tectonic forming, no erosion — `planet.drifting` is false) and rebuilds the view
live, so you watch the terrain rise. `step()` returns the max per-tick
elevation change; after `settleNeed` (3) consecutive ticks below `settleThresh`
(2 m) it sets `settled` and stops stepping — no idle CPU. Re-evolve / reseed /
subdivision-change clear `settled` and restart the forming pass. Rendering reads
the live `cells` directly (safe: nothing else mutates them). Phase 2's
*continuous* sim will want a worker thread + a render snapshot (so the 60 fps
render never races the sim) — that's the natural place to reintroduce threading.
Plate borders are traced once per world-gen as a dual contour through boundary
triangles (plates are fixed in phase 1).
## Tuning knobs (expect to adjust these first)
- `elevExagg` (src/render/Viewer.hpp) — visual elevation exaggeration; without it the
sphere looks smooth.
- `axialTilt` (PlanetConfig, `planet.cfg`) — obliquity in degrees (default 23.44). Leans
the 3D globe + spin-axis rod; groundwork for seasons. Editable + `F2` to apply.
- Biome thresholds (`biome*` in PlanetConfig / `planet.cfg`) — `biomeIceTemp` (lower =
smaller polar/snow caps), `biomeMountainElev`/`biomeHillsElev`, the moisture cutoffs
(`biomeDesertMoist`/`biomeGrassMoist`/`biomeWetlandMoist`), and the temperature model
(`biomeEquatorTemp`/`biomePoleDrop`/`biomeLatExp`/`biomeElevLapse`, shared with climate).
Editable + `F2`.
- Climate (`climate*` in PlanetConfig / `planet.cfg`) — precipitation model:
`climateOrographic` (windward-rain strength), `climateRainEfficiency`,
`climateContinentality` (inland drying), `climateMoistureSmooth` (diffusion passes →
wet/dry transition zones; raise for smoother, more grassland/forest), `climateOceanMoisture`,
`climateOroRefHeight`, `climateWindPasses`. Temperature uses the `biome*` temp params.
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks
asymptote at `base + perTickUplift/relax`, so raising it lowers/flattens
the equilibrium; lowering it makes relief taller and slower to settle.
- `beltWidth` (PlanetConfig) — how many cell-rings a mountain belt spreads
inland (belt width / flank extent).
- `trenchFactor` (Planet::step) — depth multiplier for subduction trenches.
- `driftSpeed` (Planet::assignPlates) — how fast boundary stress builds.
- **Plate lifecycle (PlanetConfig, Phase 2 inc. 1.5):** `splitFraction` (0.20,
plate share that may rift), `splitProbBase`/`splitProbSlope` (fission prob
ramp), `splitCheckEvery` (10, iters between periodic checks), `stalemateEps` /
`stalemateBoost` (deadlock detection + kick), `stalemateWindows` (4, consecutive
stuck windows before a kick — raise to calm border jitter), `babyMinCells` (4,
baby blobs smaller than this dissolve as noise), `babyPromoteFrac` (0.7%,
ridge-strip size to become a real plate), `volcanicLandFrac` / `volcanicElev`
(land grown on promotion), `landBand` (0.10, soft land-conservation band),
`miniPlateCells` (50, below = "mini") + `fuseMinPlates` (3, cluster size to fuse
+ steal). Tune these to control how lively / fragmented the plate map stays.
- **Erosion + sea level (PlanetConfig, Phase 2 inc. 2):** `erosionLandRate` (0.08)
/ `erosionSeaRate` (0.02) — fraction/My worn off above / below sea level (raise
for faster, flatter terrain); `landFractionTarget` (0.30) — geographic land
goal; `seaLevelStep` (100 m) — fixed nudge per adjustment + `seaLevelTol` (0.02)
— deadband where sea level rests (raise step or shrink tol for a tighter 30/70,
but a big step can overshoot a flat "cliff"); `seaLevelEvery` (100) — iterations
between sea-level updates (higher = more gradual). All editable in `planet.cfg`.
- **Orogeny — taller mountains (PlanetConfig, Phase 2 inc. 4):** `collisionFactor`
(1.8, continent-continent uplift, Himalaya) and `arcFactor` (1.4, continental
subduction-arc uplift, Andes) — raise either to make ranges taller/more reliable
across seeds; `isostaticPersist` (0.85, how strongly high crust resists relax —
toward 1 = ranges barely erode and continents stay high; lower = more dynamic
rise/erode) saturating at `rootScale` (2500 m above `continentBase`). These three
boosts are **drift-only** (gated on `Planet::drifting`); in Phase-2 drift the
per-tick `erode()` is what limits their height, so they don't rail the clamp. The
hard clamp `[-11000, 9000]` in `step()` is the Everest-class cap; a few peak
cells may sit there during drift (<2% fine).
- **Seafloor aging->depth (PlanetConfig, Phase 2 inc. 4):** `oceanBase` is now the
**deep abyssal floor** (-6000 m, not a flat ocean base) and `ridgeDepth` the
shallow young value (-2500 m); `seafloorSubsidence` (280 m per sqrt(My)) sets how
fast oceanic crust deepens with `geoAge` (half-space cooling), and
`seafloorSeedAge` (80 My) is the initial oceanic age spread at generation so the
starting seafloor already has ridge->abyss variety.
- **Hydrology (PlanetConfig, Phase 3 inc. 1):** `phase3AfterMy` (300) — drift-My
before the Phase-3 prompt; `phase3DtScale` (0.2) — Phase-3 timestep =
`cflDtMy()*this` (smaller = finer carving, slower drift per step); `rainfall`
(1.0) — uniform precip per cell (drainage-area unit; orographic precip is a later
climate add-on); `riverThreshold` (50) — discharge above which a cell is a river
(also the river-render threshold); `riverIncision` K (0.02) + `riverDischargeExp`
m (0.5) + `riverSlopeExp` n (1.0) — stream-power incision `K*Q^m*S^n*dt` (raise K
for faster valley carving); `riverTransport` (0.1) — transport capacity
`cap=this*Q*S` and `depFrac` (0.25) — deposition rate of excess load (raise both
for more deltas / faster lake infill). All editable in `planet.cfg`.
## Conventions
- All code, identifiers, comments and filenames in **English**.
- Before generating code, summarize the approach and ask whether to proceed.
- Prefer **inline code in chat** over attachments; give exact file contents
rather than long explanations.
- Direct, concrete answers. Metric system throughout.
- C++17, multi-file CMake. Keep simulation logic free of raylib so it stays
testable headless: engine in `src/sim` (no raylib), all rendering in `src/render`.
- Geometry stays fixed — never make cells move; add new per-cell properties
and flow them over the existing grid + neighbor adjacency.