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>
246 lines
18 KiB
Markdown
246 lines
18 KiB
Markdown
# 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 `λ = π·(1−2·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 = (summer−winter)/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 **1–3 `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
|
||
`1−exp(−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 (5–25°) or a mid-latitude
|
||
(30–62°) 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 (1–8×);
|
||
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).
|