diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index a6022975..ff2f1993 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -16,9 +16,9 @@
|---|---|---|---|---|---|---|---|
| **P0** | 43 | 0 | 0 | 0 | 0 | 0 | 43 |
| **P1** | 36 | 1 | 14 | 0 | 14 | 1 | 66 |
-| **P2** | 33 | 0 | 5 | 1 | 6 | 6 | 51 |
+| **P2** | 33 | 0 | 5 | 1 | 7 | 6 | 52 |
| **P3 (oos)** | 3 | 0 | 0 | 0 | 1 | 19 | 23 |
-| **total** | **115** | **1** | **19** | **1** | **21** | **26** | **183** |
+| **total** | **115** | **1** | **19** | **1** | **22** | **26** | **184** |
@@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
-| [terraformer](../team-leads/terraformer.md) | 9 |
+| [terraformer](../team-leads/terraformer.md) | 10 |
| [warcouncil](../team-leads/warcouncil.md) | 7 |
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
| [shipwright](../team-leads/shipwright.md) | 5 |
@@ -204,6 +204,7 @@
| [p2-49](p2-49-climate-axes-latitude-continentality.md) | 🟡 partial | Climate axes refactor — latitude + continentality + zonal winds as first-class per-hex inputs | [terraformer](../team-leads/terraformer.md) | 2026-04-30 |
| [p2-50](p2-50-rng-determinism-pin.md) | 🟡 partial | Deterministic RNG + seed-derivation pin across mc-mapgen / mc-climate / mc-ecology | [terraformer](../team-leads/terraformer.md) | 2026-04-30 |
| [p2-51](p2-51-world-shape-knobs.md) | 🟡 partial | Player-facing world-shape parameters on new-game screen | [terraformer](../team-leads/terraformer.md) | 2026-04-30 |
+| [p2-52](p2-52-substrate-flora-cover-ontology-split.md) | ❌ missing | Split terrain enum into substrate × flora-cover layers (resolve biome ontology) | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
## Out of Scope (Game 2 / Game 3)
diff --git a/.project/objectives/p2-52-substrate-flora-cover-ontology-split.md b/.project/objectives/p2-52-substrate-flora-cover-ontology-split.md
new file mode 100644
index 00000000..82aabe4b
--- /dev/null
+++ b/.project/objectives/p2-52-substrate-flora-cover-ontology-split.md
@@ -0,0 +1,121 @@
+---
+id: p2-52
+title: Split terrain enum into substrate × flora-cover layers (resolve biome ontology)
+priority: p2
+status: missing
+scope: game1
+owner: terraformer
+updated_at: 2026-05-01
+canonical_doc: public/games/age-of-dwarves/docs/terrain/CLIMATE.md
+coordinates_with:
+ - p1-46
+ - p1-48
+ - p1-50
+ - p2-49
+ - p2-51
+---
+
+## Summary
+
+The current `terrain.json` enum (16 IDs) conflates three orthogonal
+ecological layers into a single field:
+
+| Layer | Examples currently in terrain enum | Should live where |
+|---|---|---|
+| **Substrate** (geological / hydrological) | `mountains`, `hills`, `ocean`, `coast`, `lake`, `inland_sea`, `volcano`, `ice`, `snow` | terrain (correct) |
+| **Flora-cover** (emergent from species) | `forest`, `jungle`, `boreal_forest`, `grassland`, `swamp` | derived from flora selector |
+| **Substrate × climate composite** | `desert`, `tundra`, `plains` | derived label, not authored |
+
+The `feature_type: "foliage"` field on `forest`/`jungle`/`boreal_forest`
+in `public/games/age-of-dwarves/data/terrain/land_forest.json` is the
+data layer admitting these are flora cover masquerading as terrain.
+
+This conflation propagates everywhere:
+- Flora species `biomes[]` arrays list flora-cover types as locking
+ conditions (a beech tree's `biomes = [temperate_forest, forest]` —
+ both are flora-cover labels, not substrates)
+- Climate classifier (`mc-climate::derive::classify_terrain_whittaker`)
+ emits flora-cover names from T/P bands directly, skipping the
+ substrate axis
+- Terrain blends (`terrain_blends.json`) mix substrate edges
+ (`coast+plains → shore`) with flora-cover edges
+ (`forest+plains → grass_fringe`) on equal footing
+
+This objective restructures the data model into three independent layers
+that the renderer composes into a final visual:
+
+```
+Substrate ← Tectonics + Hydrology output
+ × Climate ← (t_band, p_band, riparian_distance)
+ × Flora-cover ← Ecology selector output (canopy/understory/ground/bare)
+ = Biome label ← Display name only, derived not stored
+```
+
+## Why p2 (not p1) and why Game 1 not Game 2
+
+- Visually the lab works fine today because the conflation produces
+ plausible-looking output. Refactoring is **architectural cleanup**,
+ not a user-visible bug.
+- The Wave A–E pipeline already implicitly does substrate (tectonics)
+ → climate → flora (ecology selector) — the data model lags the
+ pipeline. This objective reconciles them.
+- Doing it pre-EA prevents lock-in of the wrong contract — every
+ subsequent Game-2 mechanic (leylines, soil g2-06, lifecycle g2-07,
+ population dynamics g2-08) inherits whatever shape ships with EA.
+
+## Acceptance
+
+- ◻ **New `substrate.json`** at `public/games/age-of-dwarves/data/terrain/`
+ authoring 8–10 substrate types: `bedrock`, `soil`, `sand`,
+ `permafrost`, `peat`, `water`, `seawater`, `ice`, `lava`. Each with
+ fields: `albedo`, `evapotranspiration_max`, `drainage`, `fertility_base`.
+- ◻ **TileState refactor** — `TileState::terrain_id` deprecated;
+ replaced by `TileState::{substrate_id, flora_cover_id, biome_label_id}`
+ where `biome_label_id` is *derived* (read-only, computed from the
+ three independent fields).
+- ◻ **Flora `biomes[]` rewrite** — every species's `biomes[]` array
+ becomes a list of `(substrate, climate_band)` pairs. Migration
+ script + visual diff to confirm equivalence.
+- ◻ **Whittaker classifier consumes substrate** —
+ `mc-climate::derive::classify_terrain_whittaker(t_band, p_band,
+ substrate)` returns a `(biome_label, flora_cover_id)` pair. The
+ current single-output signature is replaced.
+- ◻ **terrain_blends.json restructured** into two tables:
+ `substrate_blends.json` (substrate-edge ecotones like `coast+soil`)
+ and `flora_cover_blends.json` (e.g. `closed_canopy + open_grass →
+ forest_edge`). The renderer composes them.
+- ◻ **Renderer composition** — Lab.tsx + Godot tile renderer fill
+ substrate base → flora cover overlay → biome decorations, each
+ driven by the matching field.
+- ◻ **Backward-compat removed** — no aliasing of old `forest`/`jungle`
+ IDs to new substrate+cover combos. Per Zero Tech Debt, the old IDs
+ are gone, not shimmed.
+- ◻ **Migration test** — for one frozen seed, before/after
+ comparison: every tile's *visual* output (substrate + flora cover
+ rendered) matches the pre-refactor render to within tolerance.
+ Determinism preserved.
+- ◻ **Doc updates** — `CLIMATE.md` Whittaker section rewritten to
+ show 3-axis lookup; `ECOLOGY_BINDING.md` species index keyed on
+ `(substrate, t_band, p_band)`; new `SUBSTRATE.md` canonical doc
+ authored.
+
+## Dependencies / risks
+
+- p1-46 (Wave-E lab integration) should land FIRST, locking the
+ current visual baseline before the refactor.
+- Touches `mc-climate`, `mc-mapgen`, `mc-ecology`, `api-gdext`,
+ `api-wasm`, all 6 lab pages, all flora/fauna species JSON, and
+ Godot tile renderer. High blast radius — coordinate carefully.
+- Risk: post-EA save format must migrate (or refuse to load) old
+ worlds. Coordinate with `p2-50`'s save-format pin.
+
+## Non-goals
+
+- Adding new substrate types beyond the ~10 above (g2-05 / g2-06
+ expand into lithology + soil orders for Game 2).
+- Restructuring fauna `biomes[]` similarly — fauna mostly cluster
+ around flora cover anyway, lower priority. Possibly follow-up
+ objective.
+- A "Substrate" page in the design lab — covered by the
+ per-substrate inspector on the Tectonics page (already authored
+ in p1-53).
diff --git a/.project/team-leads/terraformer.md b/.project/team-leads/terraformer.md
index d7573f6f..99659f2d 100644
--- a/.project/team-leads/terraformer.md
+++ b/.project/team-leads/terraformer.md
@@ -15,6 +15,7 @@ objectives:
- p2-49
- p2-50
- p2-51
+ - p2-52
---
## Mandate
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index 9fe63775..378a0d25 100644
--- a/public/games/age-of-dwarves/data/objectives.json
+++ b/public/games/age-of-dwarves/data/objectives.json
@@ -1,13 +1,13 @@
{
- "generated_at": "2026-05-01T05:05:59Z",
+ "generated_at": "2026-05-01T05:07:45Z",
"totals": {
- "stub": 1,
- "done": 115,
"partial": 19,
+ "missing": 22,
"oos": 26,
"in_progress": 1,
- "missing": 21,
- "total": 183
+ "stub": 1,
+ "done": 115,
+ "total": 184
},
"objectives": [
{
@@ -1610,6 +1610,16 @@
"updated_at": "2026-04-30",
"summary": "The terraformer pipeline now exposes ~15 internal parameters\n(plate count, tectonic strength, fbm octaves, sea level, latitude\ngradient, continentality decay, rain-shadow factor, erosion\niterations, drainage threshold, etc.). Designers tune these in the\nforest lab; **players see none of them**. The new-game screen ships\n\"Map Size\" and not much else.\n\nIndustry baseline (Civ 6, Old World, Songs of Conquest) exposes 4–6\nhigh-level shape knobs. Each knob is a *preset* that derives several\ninternal parameters at once. This objective wires that surface from\nJSON presets through `mc-mapgen` parameters into the Godot game-setup\nscene."
},
+ {
+ "id": "p2-52",
+ "title": "Split terrain enum into substrate × flora-cover layers (resolve biome ontology)",
+ "priority": "p2",
+ "status": "missing",
+ "scope": "game1",
+ "owner": "terraformer",
+ "updated_at": "2026-05-01",
+ "summary": "The current `terrain.json` enum (16 IDs) conflates three orthogonal\necological layers into a single field:\n\n| Layer | Examples currently in terrain enum | Should live where |\n|---|---|---|\n| **Substrate** (geological / hydrological) | `mountains`, `hills`, `ocean`, `coast`, `lake`, `inland_sea`, `volcano`, `ice`, `snow` | terrain (correct) |\n| **Flora-cover** (emergent from species) | `forest`, `jungle`, `boreal_forest`, `grassland`, `swamp` | derived from flora selector |\n| **Substrate × climate composite** | `desert`, `tundra`, `plains` | derived label, not authored |\n\nThe `feature_type: \"foliage\"` field on `forest`/`jungle`/`boreal_forest`\nin `public/games/age-of-dwarves/data/terrain/land_forest.json` is the\ndata layer admitting these are flora cover masquerading as terrain.\n\nThis conflation propagates everywhere:\n- Flora species `biomes[]` arrays list flora-cover types as locking\n conditions (a beech tree's `biomes = [temperate_forest, forest]` —\n both are flora-cover labels, not substrates)\n- Climate classifier (`mc-climate::derive::classify_terrain_whittaker`)\n emits flora-cover names from T/P bands directly, skipping the\n substrate axis\n- Terrain blends (`terrain_blends.json`) mix substrate edges\n (`coast+plains → shore`) with flora-cover edges\n (`forest+plains → grass_fringe`) on equal footing\n\nThis objective restructures the data model into three independent layers\nthat the renderer composes into a final visual:\n\n```\nSubstrate ← Tectonics + Hydrology output\n × Climate ← (t_band, p_band, riparian_distance)\n × Flora-cover ← Ecology selector output (canopy/understory/ground/bare)\n = Biome label ← Display name only, derived not stored\n```"
+ },
{
"id": "g2-01",
"title": "Ley lines — Game 2 (Age of Kzzykt)",
|