17 KiB
/Tools/Scripts — generation logic
C# backend for the offline developer tools. Decoupled from the live server and client.
MapGenerator.cs
Generates the entire 2D blueprint. Roughly in order:
-
Config — reads
ServerConfig.json, which setsMapSize(8192 by default) and the crater radius. TheMapSize = 4096field initialiser is a fallback that is immediately overwritten. -
Topography — FastNoiseLite base height plus a mountain spine, minus a squircle distance falloff, giving a guaranteed island. Noise frequency is divided by
scaleFactor(MapSize / 1024f) so terrain features stay the same real-world size at any map profile. The island's proportions are theIslandAxisX/IslandAxisYdials (1.30/0.78; the pre-task-11 shape was1.15/0.90).IslandAxisXis a weak lever — the island is already Trench-clamped at ~90 % of the map width, so aspect responds almost entirely toIslandAxisY, which trades against land area. These move the coastline, so they move biomes. The mountain spine sharesIslandAxisXfor its width, and its crest is rounded (IslandFalloff.SmoothAbs) —1 - |x - centre|used to peak with a slope discontinuity that was, measured, the largest slope step on the map outside the Trench walls. The spine's AXIS is still a straight line down the map centre; that is known, deferred, and its own task. WithCoastProfile: "wide"(the default) the seabed leaving the shoreline is shelved (IslandFalloff.CoastShelf): the height curve is identity at and below sea level, so it never reached the water, and the shoreline used to shelve gently on land then drop 6.7× steeper the moment it went under. The shelf cannot move the waterline — it is strictly positive for positive depth — so biomes and water are bit-identical with it on or off.OffshoreIslandDensity(0.02,0disables) seeds sparse discoverable islets in the open ocean; they are held off the mainland by a depth moat and out of the Trench by a distance mask, both by construction. WhenTerrainCurve: "v5"(the default — the task-09 gate's BALANCED winner), the calibrated height-redistribution curve (HeightCurve.cs— the terraced ascent with spatially modulated shelves: flat farmable lowlands, a walkable foothill bench at 100±12 m, the white mountain-town plateau at 220±20 m — shelf heights and strength drift across the island so no two flanks wear the same ring — and a per-seed-normalized summit spike to the 420 m cap) reshapes above-sea terrain after noise/falloff/Trench and before the crater carve; biome classification reads a retained uncurved map, so biomes are identical either way. WithTerrainDetail: "v1"(the default) two detail passes ride along with the curve (TerrainDetailPass.cs): shelf micro-relief (ShelfReliefAmp, ±3 m rolling skin on the benches/plateaus) and shelf-edge variation (ShelfEdgeVariation, 12 m) — a per-column shift of the shelf/riser knot block that makes the shelf edge scallop into notches, coves and peninsulas instead of tracing a clean height contour. Both touch exported heights only, and neither can reach below the red ceiling or above the 420 m cap: the curve's K2 and K6 knots do not move, so a warped column is bit-identical to an unwarped one outside the shelf/riser stack. Drops the0_heighthillshade snapshot (hypsometric bands × NW hillshade) in both modes.Erosion (tasks 17–18, Phase C0) — with
Erosion: "v1"(default "off", opt-in until the developer's gate approves it) a droplet-based hydraulic erosion pass (HydraulicErosion.cs, standalone numeric, deterministic from the resolved seed) details the curved+detailed render map after the detail passes and before the crater carve: droplets walk downhill with inertia, eroding steep fast stretches and depositing where the ground flattens — both spread over the same cone brush, so neither carving nor dumping can spike a single cell. Four hard governors bound it (ErosionDropletCount/ErosionDropletLifetime/ErosionCarveCap/ErosionDepositCap; both caps are per-cell metres against one net-displacement ledger and are asserted on exit), a sea clamp forbids carving below sea + margin and leaves below-sea cells untouched in both directions (the rendered coastline cannot move — asserted every generation), and the crater is handled byCraterErosionMode(task 19): nothing inside the protected strike core (CraterErosionCore, 0.80 × CraterRadius — the carve's own extent) is modified, and outside it either"full"applies full strength at once or"feather"ramps 0→full out toCraterErosionFeather(1.05 ×). Task 17's hard 1.2 × cutoff was replaced because it held 620 811 land cells of ordinary terrain smooth for no geometric reason — measured, the carve's displacement is exactly 0 beyond 0.80 × — which read as an un-eroded disc with a hard edge. Keeping the core at the carve radius is what makes erosion and the carve touch disjoint cells; a narrower core lets the carve, which runs afterwards and scales height toward the sea target, amplify erosion's deltas across the waterline (measured at a 0.50 core: 79 rendered waterline crossings). The flooded bay and its sea connection never depend on the crater mode — below-sea cells are read-only in both directions, so the bay can be neither carved open nor silted shut. Output-height only: biome/water classification reads the retained pre-erosion map, so1_biomes/0_waterare bit-identical with erosion on or off. The task-18 defaults tune for a drainage hierarchy — fine rills everywhere feeding a set of clearly deeper convergent channels — by letting droplets live long enough (384 steps at inertia 0.35, low evaporation) that their paths overlap and deepen shared low lines, and by raising the carve cap to 15 m so trunks separate from rills instead of both piling against one ceiling. Measured on seed 1280587109: ~1.9 M cells carved past 0.5 m, 42 k past 5 m, 1.4 k past 10 m, and 177 connected channel systems of 200+ cells at the 3 m threshold. Erosion concentrates ~9× on the curve's shelf risers, because that is where sustained slope exists; the flat shelves and lowlands are barely touched, so the lowland continuation of a trunk is not erosion's to cut — that is river promotion's job. No rivers yet — the carved channels are the designated future river routes (Phase C0b promotes them by flow accumulation). The predecessor D8 drainage-incision pass was written and reverted in task 10 — per-cell steepest descent on a regular grid can only route along eight headings, and at map scale that reads as straight hatching, not drainage; the droplet model is the working replacement (its carve field measures isotropic to within 1.5 % across the folded 45° grid period). River plan (task 21, C0b part 1) —DrainageAnalysis.cs+ the headlessRiverPlanTool.tscnproduce a river PLAN from an erosion-ON blueprint: priority-flood with a one-ulp epsilon resolves the ~15k erosion pits for ROUTING ONLY (terrain untouched), deep+large depressions survive as terminal basins, D8 flow directions + Kahn accumulation build the drainage network, and the top ~3 TRUE-ocean outlets (the ocean body, WBID 1 — enclosed lagoons do not count as "the sea") become trunk candidates with lean tributaries, mountain-exit handoff points, and lean endorheic terminals. Task 21b extends the plan to the developer's MIXED promotion: the ocean trunks PLUS the top endorheic giants (RIVERPLAN_GIANT_N, default 3), each classified — a giant whose terminal basin holds a classify lake stays a lake-ender (rivers ending in lakes are real geography); a dry-pan giant, and always the SOUTHERN CANDIDATE (the giant pooling nearest the southernmost town), gets a PROVISIONAL route to the ocean: steepest descent on the full (no-terminal) fill, so the basin overtops at its spill and the walk follows the terrain's own drainage to the sea — drawn dashed for the gate, never carved. Output is a JSON plan sidecar + console report — deliberately NOT a blueprint section, so a plan can never masquerade as realized water. Part 2 (task 22) carves and waters the gated plan. Pure analysis: the source blueprint is never written.River carving (task 22, C0b part 2a) — with
Rivers: "v1"(default "off", pending the routing-style gate) the frozen task-21b plan is executed as real terrain (RiverCarvePass.cs): the drainage analysis reruns in-pipeline (deterministic), each routed giant gets a lowland route to the nearest ocean by deterministic Dijkstra —RiverRoutingStyle: "short"(direct, uphill-penalised) or"lowground"(cost ≈ elevation; follows the lowest ground and meanders; the provisional default) — and every promoted course is carved as a parabolic channel with a smoothstep shoulder, width/depth growing downstream with drainage (RiverWidthScale/RiverDepthScale), bed made monotone non-increasing toward the outlet and clamped to sea +RiverSeaMargineverywhere (the erosion flood-guard discipline: below-sea cells read-only, zero new below-sea cells, coastline provably fixed; asserted per generation). Slots AFTER towns (town placement reads the render map and must not shift) and BEFORE roads. Crater core excluded.River water (task 23, C0b finale) — the payoff. Each carved main river becomes a chain of stepped flat water-body REACHES: a new reach starts every
RiverStepDropM(2 m) of bed descent and sitsRiverWaterDepthM(1.2 m) above its bed, each reach strictly lower than the one upstream, stepping down to the sea (small vertical drops — riffles — smoothable purely by the step dial; tilted continuous-slope water is the deferred model B). Reaches are ordinary water bodies: WBID cells + a WBTB entry (type 2 = river, fresh) each; WSRF derives from body levels exactly as for lakes, and river water renders through the C1/task-13 path with the task-15 presence rule at its banks. Lake-enders route the last reach into their lake by the lowground Dijkstra (a pooling terminal IS a local minimum — blind descent dead-ends there); rivers meet lakes and the sea without repainting them (existing bodies are never overwritten). Only the lowland reaches are ROUTE-SMOOTHED (RDP+Chaikin, the road pipeline): the upland stems already thread carved valley floors, and smoothing them off-line cut valley walls (measured). The BIOME oracle holds (classify untouched;1_biomesmd5-identical);0_waterand the re-captured0_heightchange — that IS the river water and its beds (both snapshots are re-captured after the river pass so the exported PNGs show them). Max-cut is now reported CUMULATIVELY vs the pre-pass surface.Polish (task 24) — tributaries carry water too, gated by along-course flow (
RiverTribWaterMinFlow, 0 = water them end to end) and tapered at the wet→dry transition overRiverTribTaperPx: the strip narrows and its surface drops to the bed, so a stream head fades instead of ending in a wall of water. Lake-enders route to the nearest significant water body (RiverLakeMinTargetPx, 20k cells) — "nearest wet cell" was satisfied by a 322-px puddle beside a 197k-px lagoon. Steps are finer (RiverStepDropM0.6 m) and beds deeper (RiverDepthScale1.5,RiverWaterDepthM2.2 m) so the chain reads as a graded descent rather than a pond staircase: 1034 reaches, adjacent-reach level gaps median 0.12 m.Polish r2 (task 25) — tributaries carry water their full promoted length (
RiverTribWaterMinFlow0) while still fading at the tip (the taper zone is alwaysRiverTribTaperPxlong, starting wherever water begins). The water surface isRiverFillFraction(0.80) of the local bed depth rather than a fixed height, floored byRiverWaterDepthM— a fixed height was a trickle in a 13 m mouth and over the rim at a 1.5 m head. Bank shoulders flareRiverBankFlare(3.2) half-widths with a smootherstep so water meets land as a shore, and the shoulder may not lower a cell more thanRiverBankMaxCutM(3 m) measured against the pre-pass surface — gentle ground flares, a ridge crossing keeps steep walls, and max cut fell to 16.8 m with no cell past 20 m.FILE SAFETY —
ISLA_BLUEPRINT_PATH(load a specific blueprint file) andISLA_EXPORT_DIR(write all of a run's blueprints and snapshots into a task folder) exist so tooling never writes to, or deletes from, the runtime root — the developer stagesMapData_Seed_*.datthere to view worlds in 3D. Batch drivers set both and contain no delete of any kind. -
Sea level and water — sea level per the configured model (
SeaLevelModel:"flat"scalar — the default,SeaLevelValue0.15 — or the legacy"field"latitude Lerp); flood fill separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass. -
The crater — placed along the northern coast and carved to below sea level, but only out to 80 % of its radius, which guarantees a landbridge rather than severing the island.
-
Water bodies — promotes the classified water into explicit data: the ocean as body 1, each lake connected-component labeled (same 4-connectivity as the true-ocean fill), one transitional surface level per body. Classification comes from the shared
IsOceanPixel/IsLakePixelpredicates that the biome stage also uses, so the two can never disagree. A priority-flood pit-fill runs here as validated diagnostics (basin statistics to console; serializes nothing). Drops the0_watersnapshot. -
Biomes and towns — biome zoning by height and temperature; tiered town placement (Capitol, Hubs, Villages, Outposts, POIs) filtered by slope, water proximity and spacing.
-
Roads — see below. Skipped entirely when the
SkipRoadsconfig toggle is on (the ~25-min A* pass is the bottleneck; a skip run exports a road-less iteration blueprint in ~80 s with a loud console banner). -
Export — writes the
.datblueprint (dual-write: the v2 tagged container under the primary seed name, plus the legacy v1 format beside it as_v1.dat— seeCore/Scripts/BLUEPRINT_FORMAT.md), then renders the PNG snapshot. The v2 file embeds the resolved generation params (seed, MapSize, crater radius, density, impact centre, provenance).
The road network
AStarGrid2D over the whole map at one node per pixel. Mountains are made expensive rather than
impassable (1 + elevation³ × 400), water and the crater are marked solid.
- Continental loop — highway nodes sorted by radial angle and connected in a ring.
- Mountain branch — a spur from the loop to the snow hub.
- County roads — Prim's algorithm daisy-chains remaining towns onto the network. Villages become Rugged roads, everything else Trails.
- Abandon protocol — a town further than 8 % of the map from the network is skipped rather than pathed to, so one unreachable outpost cannot hang generation.
- Smoothing — every path is decimated with Ramer–Douglas–Peucker (tolerance 4.0) then smoothed with 4 Chaikin passes.
⚠ This is the project's dominant performance problem. At 8K the grid is 67 million nodes, and the
weight-setup pass touches every one before the first path is requested. The await yields between
stages cannot interrupt a single engine-side GetPointPath call, so a long path still blocks. Full
regenerations frequently get abandoned.
The PNG snapshot
SaveMapSnapshot() builds an offscreen SubViewport in code rather than relying on the scene's
layout, because Godot's UI layout engine will otherwise crush a 4096+ render down to the editor
window size. A MapDrawProxy control re-issues the _Draw() calls into that viewport — GetImage()
captures only the base texture and would otherwise miss the roads and towns entirely — and the code
awaits two RenderingServer.FramePostDraw signals so the GPU has actually painted before the image
is read back.
Road colours on the snapshot, useful for identifying a road: red = Highway, black = Branch, dark brown = Rugged, light brown = Trail.
Outputs
MapData_Seed_<seed>.dat (v2), MapData_Seed_<seed>_v1.dat (legacy dual-write), and the staged
snapshots Map_Seed_<seed>_{0_height,0_water,1_biomes,2_towns,3_roads}.png, all to user://.
(0_height is the hypsometric hillshade of the curved terrain; 0_water is painted from the
water stage's own outputs — ocean deep blue, lakes lighter blue, land neutral; 3_roads is
absent on a SkipRoads run.)
RoundTripHarness.cs
The blueprint format regression test (scene: Tools/Scenes/RoundTripHarness.tscn). Loads a
known-good blueprint through the real parser, re-writes it as v2 through the real writer, re-loads
it, and asserts semantic equality (heights bitwise, biomes, towns, every road point). Headless,
seconds per cycle, no generation. Exit code 0 = pass.
Rules
- No magic numbers. Distances, radii and thresholds derive from
MapSizeorscaleFactor, so the generator behaves identically at 4K and 10K. - Respect the GPU. Anything exporting visual data must
awaitthe appropriate frame signals before reading pixels back.