planetsim/docs/design-notes.md
Jonas Reith f748940004 Fix: step-back now reverses run-born storms (record undo history continuously)
The step-back undo history was cleared on every continuous-run frame, so weather systems
(storms/hurricanes) created during a normal run had no recorded past. Stepping back then only
rewound the deterministic sky and left the storm frozen at its current spot, resuming motion
only on a forward step.

liveAdvance() now records a snapshot of the pre-advance weather state at ~one-step cadence on
ANY forward advance (continuous run or manual '.'), not just manual steps -- the interval
scales with liveRate, so it's ~one snapshot per real second at any clock rate, in a bounded
ring. liveStepBack() searches the ring for the most recent snapshot before the current time and
restores it (clock + humidity/cloud/rain + storms + RNG), so storms reverse regardless of when
they were born. The clear-on-run was removed; entering Live World still resets the ring.

All five suites pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:01:07 +02:00

246 lines
18 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.

# Design notes (durable context)
These are the non-obvious decisions/conventions that were previously only in Claude's
auto-memory (which lives under `~/.claude/` and does **not** travel with the repo). Captured
here so the context survives a move to another machine/server. `CLAUDE.md` has the
authoritative current-state changelog; this is the "why / where things live" summary.
## Framing: World Creation → Live World
The roadmap is no longer rigid numbered "phases". **World Creation** is a set of
continuous, overlapping stages on a geological clock (My): tectonics → continental drift &
erosion → hydrology → climate → biomes → (fauna & flora, next). The long-term goal is a
separate **Live World** mode that runs the *finished* planet at a much slower real-time
clock (hours/days/weeks/months) with dynamic weather (clouds, rain, storms) and living
ecosystems/civilization. **Internal code still uses `phase*` names** (`Planet::drifting`,
the `phase3` flag, `phase3AfterMy`/`phase3DtScale` config keys) for save/config
compatibility — only display strings and docs use the new framing.
## Code module layout
Split into a raylib-free **engine** (`src/sim/`, headless-testable) and a raylib **viewer**
(`src/render/`); `src/main.cpp` is a ~10-line entry point. CMake adds both dirs to the
include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
`Planet` is **one class implemented across several .cpp files** (all share `Planet.hpp`):
- `PlanetTypes.hpp``Cell` / `Plate` / `SubGrid` / `Biome` enum / `PlanetConfig`.
- `Planet.cpp` — generation, geometry, plate seeding, RNG + shared helpers, subgrid, min/max.
- `PlanetTectonics.cpp``step()` (stress→uplift→relax; orogeny boosts gated on `drifting`).
- `PlanetDrift.cpp``cflDtMy`/`advect` + plate lifecycle (fission/kick/baby/fuse/enclosed).
- `PlanetErosion.cpp``erode` + `adjustSeaLevel`.
- `PlanetHydrology.cpp``routeFlow`/`computeHydrology`/`hydrology` (depression-fill→lakes,
steepest-descent→rivers, mass-conserving stream-power incision).
- `PlanetClimate.cpp``computeClimate()` (temperature + orographic precipitation).
- `PlanetLive.cpp``computeInsolation()`/`computeLiveSeason()` (Live World: day/night + live
seasonal temperature; derived, not saved).
- `PlanetOcean.cpp` — moons (`generateMoons`, `moonDirection`/`sunDirection`/`moonOrbitNormal`) +
`computeTides()` + `computeOceanCurrents()` (Live World sky, tides & currents). Moons saved
(v9); tides/currents derived.
- `PlanetWeather.cpp``initWeather`/`stepWeather` (Live World dynamic humidity/cloud/rain cycle;
saved v10).
- `PlanetBiomes.cpp``classifyBiomes()` (per-cell `Cell.biome` from elevation + climate).
- `PlanetBiota.{hpp,cpp}` — Biota types + archetype table + slot/point draw +
`computeBiotaDensity()`/`generateBiota()` (flora/fauna/funga).
- `PlanetFloraGen.cpp` / `PlanetFaunaGen.cpp` / `PlanetFungiGen.cpp` — per-kind density +
per-cell `fill*` (fauna gates carnivores on local prey; funga is moisture/organic-led).
- `PlanetIO.cpp` — text config + binary save/load.
The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration),
`ViewerInput.cpp` (camera/picking/keys), `ViewerRender.cpp` (globe/map/panels/HUD/prompt),
plus topical helpers `Colors` / `Map2D` / `Overlays` / `Picking` / `Panels`.
Per-tick order in `Viewer::refreshView()`: `computeHydrology()` (if hydrology on) →
`computeClimate()``classifyBiomes()``computeBiotaDensity()``recolor()`. The discrete
biota *population* (`generateBiota()`) is NOT in this per-tick path — it's on-demand (key `L`).
## Core principle (do not violate)
Geometry is **fixed** — cells (icosphere vertices) never move. Only per-cell *properties*
flow over the fixed grid + neighbor adjacency (Eulerian). New phenomena = new per-cell
fields flowed over the grid, never moving cells.
## Axial tilt render convention (non-obvious)
The 3D globe is rendered leaned by `cfg.axialTilt` via `rlRotatef(tilt,0,0,1)` wrapping all
3D content in `renderGlobe3D`. Because that rotation isn't in the data, anything mapping
between world and model space must compensate with `rotateZ(v, ±tilt)` (src/render/
Picking.cpp): 3D picking un-rotates the ray hit by `tilt` before `nearestCell`; 3D plate
labels rotate by `+tilt` before projecting. The 2D map + biome/climate are tilt-independent.
## Save format (v7) — self-describing config + biota population
`planet.save` stores `PlanetConfig` as a **self-describing key=value text block** (not a raw
POD dump), parsed like `planet.cfg` (`writeConfigFields`/`parseConfigStream` shared in
PlanetIO.cpp), written at `precision(17)` so doubles round-trip exactly. Consequence:
**adding/removing PlanetConfig fields no longer breaks saves** (unknown keys ignored, missing
keys keep defaults). v6 cannot load pre-v6 saves (one-time break; a length guard fails it
gracefully). Per-cell `Cell.biome` is saved (a byte appended after `invader`). **v7** appends
the **biota population** (`sBiota`): a flag byte, then three `Organism{uint16 archetype, uint8
biome}` lists per cell. Densities are derived (not saved). Older saves without the block load
fine with an empty population (`readState(is, hasBiome, hasBiota)`; `hasBiota = ver>=7`).
## Biota (flora / fauna / funga) — density + slot/point population
Two layers (`PlanetBiota.cpp` + the three `*Gen.cpp`): (1) derived per-cell **density** scalars
(0..1) recomputed each tick like climate — flora = Liebig-min(temp, moisture), fauna ∝ flora
(carnivores gated on neighbourhood prey ≥ `bioCarnPreyMin`), funga = moisture/organic-matter-led
+ cold-tolerant; 0 on water/Ice. (2) On-demand discrete **population** `generateBiota()`: each
land cell draws broad archetypes from the comprehensive append-only `biotaArchetypes()` table
into a per-kind slot cap + density-scaled point budget (size → cost Tiny=1…Huge=5), weighted by
biome/climate suitability and a **regional bonus** for archetypes already in same-biome
neighbours (single index-ordered pass → homogeneous regions, boundary variety). Organisms are
labelled by **taxonomy** — Family + Size + role (full `Class > Order > Family` in
`organismTaxonomy()`), never informal common names ("Felidae", not "cat"); generalist families
get a biome adjective ("Desert Muridae"). Generation uses a **separate RNG seeded from `cfg.seed`** (not
`Planet::rngState`) so populating biota never perturbs tectonic determinism — asserted in
`test_biota.cpp`. The archetype table is **append-only** (indices are serialized in v7 saves).
## Climate + biome model (derived, not saved)
`computeClimate()` builds two derived per-cell fields:
- **Temperature** (°C) = latitude curve (`biomeEquatorTemp/PoleDrop/LatExp`, super-linear so
cold concentrates at poles) `biomeElevLapse` × elevation. This is the **annual mean**; the
**Seasons** pass adds derived `sTempSummer`/`sTempWinter` = mean ± `A`, where the seasonal
half-amplitude `A = seasonAmpMax · sin(axialTilt)/sin(23.44°) · latShape · continentality`.
Continentality is a multi-source-BFS ring distance from ocean cells (oceans/coasts muted by
thermal inertia; interiors swing most). `classifyBiomes()` blends winter temp into the
Tundra/Taiga cold cutoffs via `biomeSeasonWeight` (0 = mean only → unchanged biomes), so
cold-winter continental interiors turn boreal/tundra. Seasonal fields are derived/not-saved.
**Ocean currents** add a bounded coastal warm/cold anomaly to this mean before the seasons pass
(`climateCurrentFactor`; see the Ocean section).
- **Precipitation**: zonal prevailing winds (easterly tropics/poles, westerly mid-lat); ocean
cells are a moisture source; each land cell takes its **upwind** neighbour's moisture, rains
out more on windward upslopes (orographic), loses a multiplicative fraction per cell
(continentality) → leeward/interior drying. The raw field is near-binary, so it's
**diffused** (`climateMoistureSmooth` passes) into transition zones, then normalized to
`sMoist∈[0,1]` by anchoring the **median land precip → 0.5** (robust to orographic spikes).
`classifyBiomes()` reads `sTemp` + `sMoist` (not a latitude hack) → rain-shadow/interior
deserts emerge; 13 biomes incl. polar Ice; wetlands require water adjacency. All biome &
climate thresholds are tunable `biome*` / `climate*` keys in `planet.cfg`.
## Live World (slow real-time clock) — day/night + live seasons (derived, not saved)
The arc after World Creation: the finished planet runs on a slow real-time clock instead of
the geological My clock. `PlanetLive.cpp` (raylib-free) builds two derived per-cell fields,
recomputed each frame like climate (never saved):
- `computeInsolation(dayOfYear01, timeOfDay01)``sInsolation` ∈ [0,1], the instantaneous
solar incidence `max(0, cell.unit · sunDir)`. `sunDir = lonLatToDir(λ, δ)` with declination
`δ = axialTilt·sin(2π·dayOfYear01)` (0 at equinox, ±tilt at solstice → polar day/night) and
sub-solar longitude `λ = π·(12·timeOfDay01)` sweeping once per day. **This is the hook the
future weather sim reads** (daytime heating). Computed in **model space** (the fixed cell
units) so it stays consistent with both the tilted 3D globe (the lit pattern rotates with the
globe; the seasonal lean is carried by `δ`, not the render tilt) and the model-space 2D map.
- `computeLiveSeason(dayOfYear01)``sLiveTemp`, the annual-mean `sTemp` swung toward the
static `summerTemp`/`winterTemp` by the seasonal phase `g = sin(2π·doy)·sign(lat)`
(`liveTemp = mean + A·g`, `A = (summerwinter)/2`), anti-phased across hemispheres.
Viewer (Eulerian, geometry fixed — all overlays are per-cell render passes): key `W` (settled
world) toggles `liveWorld`; drift freezes and `liveTime` (hours) advances at `liveRate` (sim
hours/real-second, ramped hour→month with `[`/`]`). `rebuildLiveOverlay()` builds `illum` (soft
day/night terminator over `sInsolation`, dim night floor) + `shadedColors` (base colour → snow
on cold land / sea-ice on cold ocean via `snowTemp`/`seaIceTemp` → day/night dim); both the 3D
globe and 2D map draw `displayColors()` (the overlay over **any** colour mode). `N` toggles the
terminator. Save **v8** appends a Live World flag + `liveTime` (header, version-gated). Knobs:
`dayLengthHours`/`yearLengthDays`/`snowTemp`/`seaIceTemp` in `planet.cfg`.
## Moons & tides (Live World sky/oceans)
`PlanetOcean.cpp` (raylib-free): `generateMoons()` seeds **13 `Moon`s** from a **separate RNG**
(`cfg.seed ^ 0x900D5EED`) so it never touches the tectonic `rngState` — moons are world objects
(not cells) and are **saved (v9)** via `writeState`/`readState(..., hasMoons)` (pre-v9 saves
synthesize them from the seed). Sky geometry is one source of truth: `sunDirection(doy,tod)` =
celestial dir leaned by declination then spun `-2π·tod` about +Y; `moonDirection(i,tod,days)` =
inclined orbit circle `Ω=2π·days/period+phase` then the same spin (so a fixed cell sees ≈one
lunar pass/day). `computeInsolation` now calls `sunDirection`. **Tides** (`computeTides`
`sTide`, derived/not saved): equilibrium two-bulge potential `Σ_body w·(cosθ²⅓)` over the moons
(weight `tideWeight`) + sun (`tideSunFactor`), scaled `tideAmplitude` — zero-mean, high under a
body and its antipode, low at 90°, sweeping ≈twice/day.
Render (Viewer): the coastline is traced once per terrain change (`buildCoastline`, dual-contour
on the land/ocean split, recording the adjacent ocean cell per segment) and coloured by
`tideColor(sTide[oceanCell])` (`T`; auto-scaled), in 3D + 2D. The 3D sun is small/distant with a
halo; moons render at a visible orbit band with a sun-lit **phase** (offset-dark-sphere trick),
faint **orbit rings** (great circle ⟂ `moonOrbitNormal`), and **eclipses** — solar darkens a spot
in `rebuildLiveOverlay`'s `illum` near the sub-solar point when a moon transits the sun; lunar
dims a moon reddish in the planet's shadow.
**Ocean currents** (`computeOceanCurrents`, also `PlanetOcean.cpp`): a per-ocean-cell tangent
velocity `sCurrent` from wind stress (`sWind`) rotated by a **Coriolis** deflection (right N /
left S about the cell normal), with the across-shore component removed at land neighbours so the
stream follows the coast (gyres), then smoothed and re-tangented (zero on land). `computeClimate`
calls it right after the wind pass and feeds **warm (poleward) / cold (equatorward)** currents
back into `sTemp` as a bounded coastal anomaly (`climateCurrentFactor`, smoothed onto coasts,
applied before seasons → biomes shift with it). Rendered as warm/cold arrows over the sea
(`buildCurrents`, key `O`). Currents/feedback are derived (not saved).
## Weather (Live World dynamic clouds & rain)
`PlanetWeather.cpp` advances a per-cell **humidity / cloud / rain** cycle on the live clock
(`stepWeather(dtHours)`), time-varying unlike the static climate. One step: **evaporate** over
warm sunlit ocean (relax humidity toward a marine target scaled by `sTemp` warmth + `sInsolation`
daytime), **advect** humidity & cloud downwind (upwind differencing along `sWind`/`sUpwind`, speed
`weatherWindKmh`), **condense** the supersaturated air into cloud (saturation
`weatherSatBase + weatherSatTempCoef·T`, plus windward orographic lift), **rain** out cloud above
`weatherRainThresh`, then **dissipate** (half returns to humidity). All rate terms use bounded
`1exp(rate·dt)` forms so it's stable at any timestep (the clock can run hours→months/sec).
`initWeather()` seeds it from the moisture climatology. Deterministic (no RNG). Driven each live
frame from `Viewer::stepSim` with dt = the sim-hours added to `liveTime` (0 when paused).
Render: a translucent **cloud shell** over the 3D globe (white → dark slate where it rains, alpha
= cover, a second triangle layer at `visBase+0.03`) and a matching `drawWeather2D` layer on the
2D map (shared `drawMapTris` rasterizer), toggled with `K`. Saved as **v10** (humidity/cloud/rain,
flag-gated; pre-v10 saves spin weather up on entering Live World).
**Moving weather systems** (same `stepWeather`): the base field above relaxes to a *static*
pattern under fixed forcing, so a population of drifting `WeatherSystem` **agents** (world objects,
not cells — like moons; transient/not saved; separate `sWeatherRng` seeded from `cfg.seed`)
provides the motion. Each step they **spawn** over warm tropical ocean (525°) or a mid-latitude
(3062°) ocean low, **move** along the steering wind (`sWind` at the nearest cell) + a poleward
recurve (`weatherSystemSpeed`), **intensify** over warm sea / **decay+cull** over land/cold, and
**stamp** a Gaussian cloud/rain shield onto the grid — so cloud clusters travel and dissipate
behind them. Tropical systems past `weatherHurricaneStr` are hurricanes/typhoons; rendered as
animated cyclonic spiral markers (eye for cyclones) spinning by hemisphere, in 3D + 2D, under `K`.
## Live World viewer controls (follow-cam, 2D zoom, clock stepper)
Three viewer-only controls over the Live World sim:
- **Storm follow-cam** (`Y`): the globe is at the origin and the camera orbits it, so to centre a
storm we point the camera **along the storm's world direction**`rotateZ(storm.pos, +axialTilt)`
(model→world; `Picking.hpp`), then `camPitch=asin(d.y)`, `camYaw=atan2(d.x,d.z)`. Tracked by a
stable `WeatherSystem.id` (assigned at spawn; transient, no RNG/determinism impact). Orbit-drag is
disabled while following; wheel-zoom still works; cycles by descending strength, auto-releases if
the storm dissipates.
- **2D map zoom** (`mapZoom`/`mapPanX`/`mapPanY`): implemented as a **virtual projection rect**,
`Viewer::mapViewRect()` = `mapRect` scaled about its centre + pan. Every map projection call
(`drawMap2D`/`drawWeather2D`/`drawSegments2D`/graticule/markers/`mapScreen` + the 2D hover-pick)
takes this `vr` instead of `mapRect`, while the **scissor + frame stay `mapRect`** so it clips to
the panel — no Map2D signature changes. `drawMapTris` was changed to derive the y-coordinate from
the rect (not the fixed-to-mapRect `m.pos`) so both axes zoom. Wheel zooms toward the cursor (18×);
drag pans when zoomed, else keeps the `mapLon` longitude rotation.
- **Clock stepper**: the `stepSim` Live-World body is factored into `Viewer::liveAdvance(dtClock,
dtWeather)` (clamps `liveTime≥0`, recomputes insolation/season/tides/moons, `stepWeather`,
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
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
restored snapshot includes the storm RNG, so re-stepping forward replays deterministically.
## Headless testing
Engine is raylib-free, so logic is tested without a display. Build/run:
```
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/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# test_biota.cpp uses the same source list (Biota suite).
```
(add new `src/sim/*.cpp` to that list as stages are added). `Planet::step()` passes are
data-parallel + double-buffered → bit-identical for any OpenMP thread count (determinism).