Files
UnrealPrototyping/Tools/Terrain/internal/manifest/manifest.go
T
2026-09-25 17:02:24 +03:00

1112 lines
55 KiB
Go

// Package manifest reads RawContent/World/World.json, the one place that says how big L_World is, what a
// heightmap value means in metres, and where the height comes from. It is the Go half of a contract whose
// other half is Scripts/Authoring/world_manifest.py: create_world.py still reads the same file to place the
// landscape, so the two must derive the same Z scale and the same Z offset from the same keys. Any change to
// the height contract here is a change there.
package manifest
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"salty/terrain/internal/plates"
)
// The engine maps heightmap value v to local height (v - 32768) / 128 * ZScale cm, so ZScale 100 spans 512 m.
const EngineSpanMAtScale100 = 512.0
// Range is the [low, high] pair the pipeline block uses for anything a seed picks between.
type Range [2]float64
func (r Range) Lo() float64 { return r[0] }
func (r Range) Hi() float64 { return r[1] }
// Pick returns a value in the range from a unit random.
func (r Range) Pick(u float64) float64 { return r[0] + (r[1]-r[0])*u }
type Elevation struct {
Min float64 `json:"min"`
Max float64 `json:"max"`
}
type Source struct {
Kind string `json:"kind"`
Seed int64 `json:"seed"`
Path string `json:"path"`
Elevation *Elevation `json:"elevation_m"`
Width int `json:"width"`
FlipY bool `json:"flip_y"`
SmoothPasses int `json:"smooth_passes"`
}
// Layers is the paint-layer rule block, unchanged in meaning from the numpy pipeline.
type Layers struct {
RockSlopeStart float64 `json:"rock_slope_start"`
RockSlopeFull float64 `json:"rock_slope_full"`
HighAltitudeStartM float64 `json:"high_altitude_start_m"`
HighAltitudeFullM float64 `json:"high_altitude_full_m"`
BreakupM float64 `json:"breakup_m"`
WearRockStart float64 `json:"wear_rock_start"`
RidgeRock float64 `json:"ridge_rock"`
DepositSoftens float64 `json:"deposit_softens"`
}
type Plates struct {
Count int `json:"count"`
VelocityCmYr Range `json:"velocity_cm_yr"`
ConvergentMmYr Range `json:"convergent_mm_yr"`
BandKm Range `json:"band_km"`
DivergentMmYr Range `json:"divergent_mm_yr"`
RiftKm Range `json:"rift_km"`
// IntraplateMmYr and IntraplateSwellMmYr are the two ends of the regional swell: the interior warps
// between them over tens of kilometres. A single uniform intraplate rate is what produced a table-flat
// plain with no divides on it, and therefore no drainage for the router to find. See package uplift.
IntraplateMmYr float64 `json:"intraplate_mm_yr"`
IntraplateSwellMmYr float64 `json:"intraplate_swell_mm_yr"`
LowUpliftFraction Range `json:"low_uplift_fraction"`
}
type Faults struct {
Major Range `json:"major"`
Minor Range `json:"minor"`
LengthKm Range `json:"length_km"`
SpacingKm Range `json:"spacing_km"`
ThrowMajorM Range `json:"throw_major_m"`
ThrowMinorM Range `json:"throw_minor_m"`
StrikeSlipM Range `json:"strike_slip_m"`
}
type Lithology struct {
Types int `json:"types"`
KMultipliers []float64 `json:"k_multipliers"`
}
type Relief struct {
Octaves int `json:"octaves"`
Gain float64 `json:"gain"`
BaseFrequencyM float64 `json:"base_frequency_m"`
AmplitudeM Range `json:"amplitude_m"`
CrestWeight float64 `json:"crest_weight"`
}
// Fluvial is the stream-power block: dh/dt = U - K * A^m * S^n, solved implicitly up the drainage stack.
type Fluvial struct {
K float64 `json:"k"`
M float64 `json:"m"`
N float64 `json:"n"`
DtYr float64 `json:"dt_yr"`
Steps int `json:"steps"`
DiffusionM2Yr float64 `json:"diffusion_m2_yr"`
// FillEvery is the one number that decides whether a full run is five minutes or half an hour: the
// priority-flood is the only part of a step that is not O(n). See Docs/Terrain.md, the time budget.
FillEvery int `json:"fill_every"`
// CriticalAreaM2 is where channels begin; below it a cell is a hillslope. See package fluvial.
CriticalAreaM2 float64 `json:"critical_area_m2"`
ChannelTaper float64 `json:"channel_taper"`
// The nonlinear hillslope law, q = D*S/(1-(S/Sc)^2). CriticalSlopeDeg is Sc as an angle; 0 falls back to
// linear diffusion with the repose clamp inside the step loop. See internal/fluvial/hillslope.go.
CriticalSlopeDeg float64 `json:"critical_slope_deg"`
SlopeCap float64 `json:"slope_cap"`
MaxHillslopeSub int `json:"max_hillslope_substeps"`
// MFDExponent is the exponent on the multiple-flow-direction partition of drainage area. 0 goes back to
// D8's single receiver, which is what every bake before this one ran. See internal/fluvial/mfd.go.
MFDExponent float64 `json:"mfd_exponent"`
}
// Smooth is the edge-preserving pass that runs once after the solve. It is a filter, not a process, and it
// is off by default: 0 passes. See internal/field/smooth.go for why it cannot go inside the step loop, and
// Docs/Terrain-Next.md for the statistics a run with it on has to match a run with it off.
type Smooth struct {
Passes int `json:"passes"`
SlopeRef float64 `json:"slope_ref"` // rise over run; ground steeper than this is preserved
}
type Thermal struct {
CoarsePasses int `json:"coarse_passes"`
Every int `json:"every"`
FinePasses int `json:"fine_passes"`
TalusDeg float64 `json:"talus_deg"`
}
type Strata struct {
PeriodM float64 `json:"period_m"`
Contrast float64 `json:"contrast"`
}
type Detail struct {
Octaves int `json:"octaves"`
AmplitudeM Range `json:"amplitude_m"`
// ClassBlendM is how far a painted class's detail numbers fade into its neighbour's.
//
// The class *index* is never interpolated - a class is a name - but the numbers it stands for are
// quantities, and a boundary somebody drew with a mouse should not be a step in the ground. At 0 it is a
// step, which is what it was before: seven metres of dune amplitude to two in the width of one cell.
ClassBlendM float64 `json:"class_blend_m"`
// SeabedM is how deep the detail texture reaches below the waterline, and the reason it is not zero is
// that a coast where the land is rough and the water is glass reads as a cut-out rather than as a shore.
// The amplitude fades in from nothing at the waterline - a few metres of noise there turns the shallows
// into a scatter of one-cell islands - and back out to nothing at this depth.
SeabedM float64 `json:"seabed_m"`
// TilePx is the interior side of a detail tile, in detail cells. It must divide the planet's width in
// geology cells once divided by geology_factor, because X wraps and a tile grid that did not come out
// whole would leave the last tile overlapping the first by an arbitrary amount.
TilePx int `json:"tile_px"`
}
// Particle is the droplet block, demoted by D-47 from "carves the valleys" to detail only. Every brake in it
// was learned the hard way; see Docs/Terrain.md.
type Particle struct {
Droplets int `json:"droplets"`
// DropletsPerCell is what the tiled detail pass uses instead of Droplets, because a tile does not know
// how big the world is and must not: a cell has to spawn the same droplets whichever tile it falls in.
// 0.18 is the density Droplets 9e6 at 7141 squared comes to, which is the density the numpy was tuned at.
DropletsPerCell float64 `json:"droplets_per_cell"`
// Rounds is how many passes the droplets are split into. Within a round they read the height as it was
// when it began, so this is what lets a channel deepen as more water follows it; the numpy got the same
// effect from its batch size, and this is that number expressed so it does not depend on how big a piece
// of the world is being worked on. A tile and the whole map must agree about which round a droplet is in
// or the seams would not close.
Rounds int `json:"rounds"`
Lifetime int `json:"lifetime"`
Scale float64 `json:"scale"`
MinErodeSlope float64 `json:"min_erode_slope"`
MaxChange float64 `json:"max_change"`
MaxSpeed float64 `json:"max_speed"`
MaxLoad float64 `json:"max_load"`
Inertia float64 `json:"inertia"`
Capacity float64 `json:"capacity"`
MinSlope float64 `json:"min_slope"`
ErodeRate float64 `json:"erode_rate"`
DepositRate float64 `json:"deposit_rate"`
Evaporation float64 `json:"evaporation"`
Gravity float64 `json:"gravity"`
Batch int `json:"batch"`
}
// Continent is the coast and the sea floor: not in the incoming spec at all, kept by D-48 because sea level
// is a better-posed base level for the fluvial solve than one outlet edge.
type Continent struct {
Enabled bool `json:"enabled"`
Radius float64 `json:"radius"`
CoastWarp float64 `json:"coast_warp"`
SeaFloorM Range `json:"sea_floor_m"`
// LandFraction is met exactly, by thresholding the continent field at the percentile that yields it, so
// the land area does not wander with the seed.
LandFraction float64 `json:"land_fraction"`
// RadialBias pulls the land towards the middle. It only biases: at 0 the continent is wherever the noise
// puts it, and high values return the disc with a wobbly edge that the first version produced.
RadialBias float64 `json:"radial_bias"`
// ShoreWidthPct is how many percentiles the shore transition spans. Small is a cliff coast, large is a
// wide tidal shelf.
ShoreWidthPct float64 `json:"shore_width_pct"`
// OutlineOctaves and OutlineGain are how much detail the coastline itself has. A real coastline is
// fractal — that is the whole point of the Richardson coastline paradox — and five octaves over a 14 km
// map puts the finest feature at about 450 m, which is a smooth blob with no inlets, no headlands and no
// islands. Measured on seed 7 with five octaves: the fetch called the median stretch of coast fully open,
// because there was nothing at the fetch scale to shelter anything from anything.
OutlineOctaves int `json:"outline_octaves"`
OutlineGain float64 `json:"outline_gain"`
}
// Coast is what happens where the land meets the sea: the shape of the sea floor, and the two processes
// that work on the shoreline itself.
//
// It is a separate block from Continent because the two answer different questions. Continent decides *where*
// the coastline runs — it is part of the tectonics, it is what the fluvial solve takes as its base level, and
// it is fixed before a single step of erosion. Coast decides what the shoreline *is*, and it runs after the
// solve, on the terrain the solve produced: the sea floor cannot be laid until the land behind it has its
// relief, and the surf cannot cut a cliff into a mountain that has not been built yet.
type Coast struct {
Enabled bool `json:"enabled"`
// The sea floor. A real margin is a shelf at a very gentle grade out to a shelf break, and then a much
// steeper continental slope down to the abyssal floor; the flat plane at the bottom of the elevation
// range that this replaces was neither. ShelfKm is a range because the shelf width is not a constant:
// it is wide off a low coastal plain and narrow off a mountain range that comes down to the water, so it
// is interpolated per stretch of shore by the relief standing behind that stretch.
ShelfKm Range `json:"shelf_km"`
// BreakM is the depth at the shelf break, in positive metres: how deep the water is where the gentle
// shelf ends and the continental slope begins. It is the one number that decides how deep the near-shore
// sea *is*, and until D-64 it was not a key at all - it was read off `continent.sea_floor_m.hi()`, whose
// default is -30 because the square canvas is 14.28 km a side and a real margin does not fit on it.
// Applied unchanged to a 100 km painted planet that reads as no ocean at all: the derived margin is up to
// 3 km of shelf and 1.6 km of slope, 1069 km of shoreline carries 4900 km2 of it against a 3100 km2 sea,
// so the margin covers the whole ocean and pins it between 0 and 30 m whatever the author painted. Zero
// keeps the old behaviour, which is what the square canvas wants; a planet gets 130 m from
// fillPlanetDefaults, and the first template's own `shelf` class is 120 m, which is the same number by
// the other route. The break can never be deeper than the water it is a break in - see layShelf.
BreakM float64 `json:"break_m"`
SteepCoastM float64 `json:"steep_coast_m"`
SlopeKm float64 `json:"slope_km"`
ShelfExponent float64 `json:"shelf_exponent"`
RoughnessM float64 `json:"roughness_m"`
RoughWaveM float64 `json:"rough_wavelength_m"`
// Shelter. Fetch is cast from every waterline cell in FetchDirections directions out to FetchRangeM, and
// how far the rays get before they hit land is what separates an exposed headland from the back of a bay.
// It is the one field both coastal processes are driven by: the surf reaches furthest inland where the
// water is open, and sediment settles where it is not.
FetchDirections int `json:"fetch_directions"`
FetchRangeM float64 `json:"fetch_range_m"`
// The surf. Within a reach of the waterline the land is planed towards a shore platform at
// PlatformGrade; the step at the back of the planed strip is the cliff, and it is a consequence of the
// reach ending rather than something drawn. CutFraction below 1 leaves the platform rough.
SurfReachM float64 `json:"surf_reach_m"`
PlatformGrade float64 `json:"platform_grade"`
CutFraction float64 `json:"cut_fraction"`
// Deposition. What the surf cuts does not vanish: it is carried DriftM along the shore and laid in
// sheltered water shallower than DepositDepthM and within DepositReachM of the shore, up to BermM above
// sea level. Rivers deliver their own load at their mouths, which is what makes a delta.
DepositReachM float64 `json:"deposit_reach_m"`
DepositDepthM float64 `json:"deposit_depth_m"`
ShelterBias float64 `json:"shelter_bias"`
BermM float64 `json:"berm_m"`
DriftM float64 `json:"drift_m"`
RiverM3PerKm2 float64 `json:"river_m3_per_km2"`
RiverExponent float64 `json:"river_exponent"`
RiverChannelKm2 float64 `json:"river_channel_km2"`
}
// CoastDetail is the shore at the detail cell: pass 11b, and the one landform the geology grid cannot hold.
//
// Every length in it is in metres and none of them scales with the canvas, which is the argument for it being
// a block of its own rather than more knobs on Coast. The geology pass decides where the shore is, how far the
// surf reaches and how sheltered each stretch is, and those are *its* numbers, read from Coast; this decides
// what the shore looks like once there are cells small enough to draw it.
type CoastDetail struct {
Enabled bool `json:"enabled"`
// Crenulation moves the whole profile in and out along the shore, which is what a crenulate coastline is.
// It is added to the distance rather than to the height and it is drawn at the nearest waterline cell, so
// it varies along the shore and not across it. This is the fine end of the same idea as the template's
// coast_jitter_px, three orders of magnitude down: that one decides which pixels are land, this one wiggles
// a waterline that is already decided.
CrenulationM float64 `json:"crenulation_m"`
CrenulationWaveM float64 `json:"crenulation_wavelength_m"`
// ShoreSmoothM is how far the signed distance to the waterline is smoothed before the profile is measured
// from it, and it is not cosmetic. On a coastal plain the ground crosses sea level at a grade of about
// one in a hundred, so the land mask there is not a line but a forty-metre band of speckle, and a profile
// measured from it builds a separate two-metre berm on every isolated cell in it. Measured on the first
// run of the pass: a string of beads down the whole coast at a spacing of twenty to thirty metres.
// Smoothing the distance rather than the mask is what keeps the profile a profile - the shoreline moves,
// the shape crossing it does not.
ShoreSmoothM float64 `json:"shore_smooth_m"`
// The beach. DeanA is the A of the equilibrium profile depth = A*x^(2/3), in metres to the one third: 0.1
// is fine sand and 0.2 is coarse. BermBackM is how far inland the berm crest is held before the profile
// hands back to whatever the droplets left.
DeanA float64 `json:"dean_a"`
BermBackM float64 `json:"berm_back_m"`
// BeachFillM is the most sediment a beach may lay on what is already there. The equilibrium profile is a
// target *depth*, so without a cap a shore with deep water close in - a drowned valley, which is an
// ordinary thing - gets tens of metres of sand invented to bring the floor up to the curve.
BeachFillM float64 `json:"beach_fill_m"`
// The cliff. A stretch of shore is a beach below CliffFromM of backshore and a cliff above CliffToM, and
// blended between. CliffGrade is the tangent of the angle the face stands at - 2.75 is 70 degrees, which
// is a sea cliff rather than a hillside. ScreeDeg is the angle its debris comes to rest at and ScreeReachM
// is how far out from the foot the apron reaches.
// CliffMaxM is how tall a face the surf is allowed to have cut. Past it the ground is a mountain coming
// down to the water rather than a wave-cut cliff, and its face is a hillslope that belongs to the solve.
// Without it a coastal range gets a seventy-degree wall carved four hundred metres inland, because the
// only thing stopping the face is the ground rising faster than it does.
CliffFromM float64 `json:"cliff_from_m"`
CliffToM float64 `json:"cliff_to_m"`
CliffMaxM float64 `json:"cliff_max_m"`
CliffGrade float64 `json:"cliff_grade"`
ScreeDeg float64 `json:"scree_repose_deg"`
ScreeReachM float64 `json:"scree_reach_m"`
// PlatformReliefM is how far the strata field is allowed to move the shore platform, which is how a
// platform gets its ledges and runnels instead of being planed flat.
PlatformReliefM float64 `json:"platform_relief_m"`
// SmoothReachM is how far past the profile the shore damps the ground's *roughness* - not its shape.
//
// The profile itself is only a few tens of metres wide, so without this the ground goes from a drawn
// beach to full dune amplitude and droplet rills in the width of the taper, and the beach reads as a
// ribbon laid on top of the terrain rather than as part of it. What this does is blend the surface
// towards a smoothed copy of itself over a wider band: the relief is untouched, the metre-scale texture
// fades, and the backshore of a beach comes out smoother than the hillside behind it - which is what a
// backshore is. 0 turns it off.
SmoothReachM float64 `json:"smooth_reach_m"`
}
type Pipeline struct {
GeologyFactor int `json:"geology_factor"`
Continent Continent `json:"continent"`
Coast Coast `json:"coast"`
CoastDetail CoastDetail `json:"coast_detail"`
Plates Plates `json:"plates"`
Faults Faults `json:"faults"`
Lithology Lithology `json:"lithology"`
Relief Relief `json:"relief"`
Fluvial Fluvial `json:"fluvial"`
Thermal Thermal `json:"thermal"`
Smooth Smooth `json:"smooth"`
Strata Strata `json:"strata"`
Detail Detail `json:"detail"`
Particle Particle `json:"particle"`
}
// Planet turns a manifest into a planet-scale bake driven by a painted template instead of a seed.
//
// Its presence is what switches the generator from the square canvas to the cylinder; a manifest without it
// is the world the `generate` command has always built, unchanged. Paths are relative to the manifest file.
type Planet struct {
Template string `json:"template"` // the painted map
Legend string `json:"legend"` // what its colours mean
// Palette is how the preview is *drawn* - the hypsometric ramp, the water, the rivers, the ice and the
// light. Optional, and deliberately a file of its own rather than part of the legend: the legend says
// what the colours in the input mean and is about the world, while this is purely a matter of taste
// about the picture, and taste is the thing most likely to want swapping. Empty means the generator's
// own, which internal/field.DefaultPalette holds.
Palette string `json:"palette"`
// CircumferenceKm is how far it is all the way round. With the geology cell fixed at 8 m by D-48 this
// is the one number that sets how big the world is, and it must be a whole number of cells or the seam
// would fall between two columns.
CircumferenceKm float64 `json:"circumference_km"`
// OceanMarginKm is how much water each region carries around its landmass.
//
// The solve needs only one cell of it - a grid edge is an outlet, and the edge has to be water - because
// the coastal pass runs once on the whole cylinder rather than per region. What the margin actually
// decides is clustering: two landmasses within twice this distance are solved in one box, which is the
// right call when they are close enough to be one drainage problem and a waste of memory when they are
// not.
OceanMarginKm float64 `json:"ocean_margin_km"`
// MinLandCells drops specks. A stray paint pixel classified as land would otherwise cost a whole region
// for a rock; below this many cells a landmass goes back to the sea and the run says how many did.
MinLandCells int `json:"min_land_cells"`
// PadClass is the sea class filling the synthetic rows above and below the painted map, which exist so
// that a polar cap has a shore to drain to. Empty means the legend's first sea class.
PadClass string `json:"pad_class"`
// NoisePeriodKm is how far a world-coordinate noise lattice runs before repeating. It must divide the
// circumference exactly or every noise field breaks at the seam. Zero means one turn.
NoisePeriodKm float64 `json:"noise_period_km"`
// DetailNoisePeriodKm is the same thing for the detail passes, and it is short because it has to be: a
// noise lattice holds (period/wavelength)^2 floats, so an eight-metre octave on a hundred-kilometre
// period is a gigabyte and a half. What repeats at a kilometre is a few metres of surface roughness with
// no shape to it; everything with a shape comes from the solve and the paint, which do not repeat.
// It must divide the circumference too.
DetailNoisePeriodKm float64 `json:"detail_noise_period_km"`
// UpliftVariation is how much the painted uplift rate is modulated by sub-pixel noise, as a fraction.
//
// It is not decoration. D-49: uniform uplift over a wide area produces no divides, and with no divides
// the router falls back on the priority-flood's epsilon and draws its traversal order as rivers. A
// painted lowland holds one rate over tens of kilometres, so without this it would come out table-flat
// with the flood's geometry scratched across it.
UpliftVariation float64 `json:"uplift_variation"`
// MassifWavelengthKm is how big the planet's upland fabric is: the size of the blocks a class with a
// massif breaks into. One fabric for the whole world rather than one per class, deliberately, so that a
// highland belt and the hills in the lowland beside it are high and low parts of a single structure - a
// foreland and its outliers - instead of two unrelated noises that happen to meet at a painted edge.
//
// It is rounded to a whole number of lattice cells in the noise period, because noise.Lattice.Sample
// wraps modulo its cell count and anything else breaks at the seam. `terrain plan` prints what it was
// rounded to.
MassifWavelengthKm float64 `json:"massif_wavelength_km"`
// LithologyWavelengthKm is how big the planet's rock provinces are. Zero means no lithology at all, which
// is what every painted planet had before D-58: one flat erodibility inside each painted class, so
// map_erodibility.png was a recolour of map_class.png and there was nothing to make one flank of a range
// read differently from the next.
//
// The types and their multipliers are `pipeline.lithology`, shared with the procedural path. What is new
// here is the wavelength, because a province on a 100 km planet is a different size from one on a 14 km
// canvas, and the cut is a quantile of the planet rather than a percentile of whatever grid is in front
// of it - see internal/uplift's painted_rock.go for why that distinction is not optional.
LithologyWavelengthKm float64 `json:"lithology_wavelength_km"`
// FaultGrainKm is the wavelength of the fault set's orientation field: faults within one of its cells
// come out sub-parallel, and the strike swings gradually across the world.
//
// It is a field rather than one global angle because a single strike is what the procedural path has and
// it reads as corduroy across a whole map. Which classes are faulted at all, and how hard, is the
// legend's `faults` block; this is only how they are aimed.
FaultGrainKm float64 `json:"fault_grain_km"`
// CoastJitterPx perturbs the painted waterline by this many template pixels of world-coordinate noise.
//
// An upsampled painted outline is a smooth polygon, and a coastline is fractal - which is the whole
// content of the Richardson paradox and, measured, the difference between a shore with bays the shelter
// model can work with and one the fetch reports as fully open everywhere.
CoastJitterPx float64 `json:"coast_jitter_px"`
// CoastJitterWavelengthPx is the coarsest octave: the size of the biggest bay it can cut, in template
// pixels. Octaves halve from there, so the finest detail is this over 2^(octaves-1).
CoastJitterWavelengthPx float64 `json:"coast_jitter_wavelength_px"`
// CoastJitterOctaves and CoastJitterGain are the fractal structure. A gain near 0.5 makes each scale as
// prominent as the last, which is the property a real coastline has and a single wobble does not - it is
// the whole content of the Richardson paradox, and it is why one octave reads as a wobbly line rather
// than as a coast.
CoastJitterOctaves int `json:"coast_jitter_octaves"`
CoastJitterGain float64 `json:"coast_jitter_gain"`
// Overlay and OverlayLegend are the annotation layer: a second painting registered to the first, and a
// legend of marks saying what its colours stand for. Both empty means there is no overlay, which is what
// every planet had before D-57 and what one still has until an author paints one.
//
// It is a second *image* rather than more colours on the first because the two answer different
// questions. A class is geology - every colour on the template changes an uplift rate or an erodibility,
// and the solve answers for it - while a mark is a thing placed on the finished world: a forest, a
// village, a road, or a stretch of coast the author drew deliberately and does not want roughened. There
// is no uplift rate for a town, and a mark has to be able to sit on top of any class without changing it.
//
// See internal/overlay. Only one mark property is read by the generator at all (coast_jitter); the rest
// travel through to the engine as per-tile masks and as features in world metres in overlay.json.
Overlay string `json:"overlay"`
OverlayLegend string `json:"overlay_legend"`
// Plates is the tectonic model: how many rigid pieces the lithosphere is in and how fast they move.
//
// It is `planet.plates` rather than `pipeline.plates` deliberately. The two are different models of the
// same word: the procedural block below is a percentile range band over whatever grid it is handed, which
// D-53 forbids on a decomposed planet, while this one is drawn once for the whole cylinder in world
// metres and produces boundary *geometry* - the lines pass 3 was always specified to read.
//
// A count of zero switches it off, which is what every template painted before it had. It is off by
// default because nothing in the solve reads it yet: what it produces today is a diagnostic map and a
// set of lines in meta.json.
Plates plates.Config `json:"plates"`
}
type Manifest struct {
Path string `json:"-"`
Level string `json:"level"`
VerticesPerSide int `json:"vertices_per_side"`
QuadCm float64 `json:"quad_cm"`
ElevationM Elevation `json:"elevation_m"`
SeaLevelM float64 `json:"sea_level_m"`
SpawnPadM float64 `json:"spawn_pad_m"`
StreamingGridComponents int `json:"streaming_grid_components"`
Source Source `json:"source"`
Layers Layers `json:"layers"`
Pipeline Pipeline `json:"pipeline"`
// Planet is present only on a planet manifest. Its absence is what keeps `generate` exactly as it was.
Planet *Planet `json:"planet"`
// Erosion is the pre-D-47 block. Kept only so a manifest that still carries it can be reported rather
// than silently ignored.
Erosion map[string]any `json:"erosion"`
}
// Defaults are the generator's own numbers, so a manifest carries only what differs from them. This is the
// Go equivalent of heightmap_erosion.DEFAULTS and it plays the same role.
func Defaults() *Manifest {
return &Manifest{
Level: "/Game/Maps/L_World",
VerticesPerSide: 7141, // 255*28+1 (D-48): the importer's own rule then gives 28x28 components
QuadCm: 200,
ElevationM: Elevation{Min: -512, Max: 1536}, // span 2048 m is exactly Z scale 400
SeaLevelM: 0,
SpawnPadM: 150,
StreamingGridComponents: 2,
Source: Source{Kind: "noise", Seed: 7},
Layers: Layers{
RockSlopeStart: 0.55, RockSlopeFull: 1.05,
HighAltitudeStartM: 1100, HighAltitudeFullM: 1650, BreakupM: 18,
WearRockStart: 0.35, RidgeRock: 0.6, DepositSoftens: 0.7,
},
Pipeline: Pipeline{
GeologyFactor: 4,
Continent: Continent{
Enabled: true, Radius: 0.62, CoastWarp: 0.28,
SeaFloorM: Range{-180, -30}, LandFraction: 0.62,
RadialBias: 0.85, ShoreWidthPct: 3,
// Measured on seed 7 at 1400, sweeping the gain with everything else held: shoreline length
// 64 km at 0.50, 81 at 0.58, 96 at 0.62, 114 at 0.66, and the fetch's view of the coast went
// from "the median stretch is fully open" (1.00) to 0.98, 0.84 and 0.51. 0.62 is where the
// coast has islands, inlets and headlands that shelter each other without the outline
// breaking up into speckle. Octaves past 9 buy nothing: at gain 0.50 the sweep 5, 7, 8, 9, 10
// gave 59, 63, 64, 65, 66 km and it had flattened.
OutlineOctaves: 8, OutlineGain: 0.62,
},
Coast: Coast{
Enabled: true,
// The canvas is 14.28 km a side and the sea is a third of it, so a real shelf — 75 km out
// to a break at 130 m — does not fit and is not what these numbers are. They are the same
// *shape* scaled to the map: a gentle shelf a kilometre or two wide, a break at the
// SeaFloorM high end, and a slope to the SeaFloorM low end over another kilometre and a
// half. The two SeaFloorM numbers keep their meaning; what changes is that the depth
// between them is now a function of distance offshore rather than of the mask's ramp.
ShelfKm: Range{0.6, 3.0}, SteepCoastM: 300, SlopeKm: 1.6, ShelfExponent: 0.7,
RoughnessM: 10, RoughWaveM: 1200,
FetchDirections: 16, FetchRangeM: 1500,
// 110 m of reach is 14 cells at the 8 m geology cell, which is about the least that can
// carry a platform and a cliff at this resolution. The shore is the one landform whose
// scale is set by physics rather than by the map, so it does not grow with the canvas;
// when the detail passes exist this pass is where the beach itself gets built, at 2 m.
SurfReachM: 110, PlatformGrade: 0.02, CutFraction: 0.85,
DepositReachM: 350, DepositDepthM: 25, ShelterBias: 1.5, BermM: 2, DriftM: 300,
// Untuned, and deliberately reported rather than assumed: the summary prints the volume
// cut, the volume laid and the volume the rivers delivered, so the next round of tuning
// has a number to work from instead of an impression of a picture.
RiverM3PerKm2: 1.2e5, RiverExponent: 0.6, RiverChannelKm2: 0.5,
},
CoastDetail: CoastDetail{
Enabled: true,
// A bay a hundred and twenty metres across with six metres of wander in it. That is the
// scale the template's own coast_jitter cannot reach: its wavelength is 384 template px,
// which is five kilometres here, and its finest octave is still 150 m of paint.
CrenulationM: 6, CrenulationWaveM: 120, ShoreSmoothM: 12,
// Dean's A for medium sand. 0.12 puts the 2 m contour 65 m offshore and the 5 m contour
// 260 m, which is a beach you can wade out on and not a shelf.
DeanA: 0.12, BermBackM: 25, BeachFillM: 3,
// A coast with eight metres of land behind it is a beach; one with thirty is a cliff. Both
// are the backshore *mean* between one and two surf reaches inland, so a low headland in a
// bay does not turn the bay into a cliff coast.
CliffFromM: 8, CliffToM: 30, CliffMaxM: 60,
// tan 70 degrees. A heightfield cannot hold an overhang, so a wave-cut notch is the one
// piece of a cliff this pass cannot draw; what it can do is stop a thirty-metre cliff
// arriving as a four-cell ramp, which is what the upsample makes of it.
CliffGrade: 2.75, ScreeDeg: 34, ScreeReachM: 30,
PlatformReliefM: 0.6, SmoothReachM: 90,
},
Plates: Plates{
Count: 6, VelocityCmYr: Range{1, 5}, BandKm: Range{2, 4},
DivergentMmYr: Range{-2, -1}, RiftKm: Range{3, 6},
// The swell is the fix for the dead plains and it stays: what a lowland needs in order to
// have drainage is not a higher uplift rate but a *varying* one, because divides come from
// variation. What was wrong was the absolute rate, not the idea.
//
// Steady-state slope is S = U/(K*A^m), and at CriticalAreaM2 0 that law is applied down to
// a single cell, so every divide on the map sits at A = cell². At K 5e-5, m 0.5 and an 8 m
// cell that is S = U/4e-4: 0.25 mm/yr puts every divide at 32 degrees and 0.9 mm/yr puts it
// past the 35 degree repose clamp. Measured on the old numbers, 81 % of the land came out
// in the >0.5 mm/yr class and the plain class held 1 %, all of it sea cliff. The plains were
// not over-dissected; they were being uplifted at mountain rates, and U sets how *high* the
// summits get, not how steep the ground is — for n = 1 the hillslope angle is the same
// everywhere the same U is applied.
//
// So the rate drops an order of magnitude and the variation stays: 0.03 to 0.08 is still
// the ~2.5-fold warp that puts divides on a plain, and it gives 4 to 11 degree hillslopes
// and lowland channel gradients near 1 m/km. Against a convergent 1-2 mm/yr that is a
// 30-to-60-fold mountain-to-plain ratio, which is what real ones are; the three-fold ratio
// this replaces was not mountains and plains, it was mountains and slightly lower mountains.
// The percentile ramp in rangeMask keeps the foreland continuous, so nothing becomes bimodal.
ConvergentMmYr: Range{1.0, 2.0},
IntraplateMmYr: 0.03,
IntraplateSwellMmYr: 0.08,
LowUpliftFraction: Range{0.2, 0.4},
},
Faults: Faults{
Major: Range{3, 6}, Minor: Range{10, 30}, LengthKm: Range{2, 15}, SpacingKm: Range{1, 4},
ThrowMajorM: Range{100, 400}, ThrowMinorM: Range{20, 80}, StrikeSlipM: Range{200, 800},
},
Lithology: Lithology{Types: 3, KMultipliers: []float64{0.5, 1.0, 3.0}},
Relief: Relief{
// The low end is 15 m, not 50: amplitude scales with normalised uplift, so the lo end is
// what the plains start as, and steady-state plain relief at the rates above is about 10 m.
// Starting them as 50 m hills means the run spends itself eroding away relief it was handed
// rather than carving what the uplift field asks for.
Octaves: 7, Gain: 0.45, BaseFrequencyM: 4000, AmplitudeM: Range{15, 150}, CrestWeight: 0.12,
},
Fluvial: Fluvial{
// 1000 steps, not the incoming spec's 5000: at this K the trunk response time is about
// 45 000 yr, and the exponent stops moving after 500 steps at 512². FillEvery is 1 and is
// not a budget knob: at 50 the solve is simply wrong (see Docs/Terrain.md).
K: 5e-5, M: 0.5, N: 1.0, DtYr: 1500, Steps: 1000, DiffusionM2Yr: 0.02, FillEvery: 1,
// 0 disables it, and it is disabled on purpose. A channelization threshold is the textbook
// answer to stream power over-steepening hillslopes, but it only works paired with a
// hillslope transport law strong enough to carry the uplift into the channels, and at this
// timescale there isn't one: the diffusivity it would need (~0.3 m²/yr over a 220 m
// hillslope) has a diffusion length of sqrt(D*t) ≈ 470 m over 1.5 Myr, which smooths away
// every landform the generator exists to make. Measured: the map went to melted wax. With
// the threshold on and diffusion left low, hillslopes instead accumulate uplift unchecked
// and the map clipped 22% of the elevation range. Landsliding carries the hillslopes here.
// Measured again after the uplift field was fixed, and it still fails: at 1e4 the plains
// went from 0.8 to 7.0 degrees median, the rolling class from 7.4 to 32.8 with half of it
// pinned against the repose clamp, and the mountains to 79 % pinned. The reason is the same
// one as before — the hillslope the threshold creates has to shed its uplift by diffusion,
// and at D 0.02 it cannot, so the clamp takes the job instead. It stays at 0 until there is
// a transport law strong enough to pair it with.
CriticalAreaM2: 0,
ChannelTaper: 2,
// Sc is the repose angle, so the nonlinear law limits at the same place the clamp did; what
// changes is that it approaches it smoothly and isotropically instead of cutting to it along
// eight grid directions. See internal/fluvial/hillslope.go for what the cap and the sub-step
// budget buy and what they cost.
CriticalSlopeDeg: 35,
SlopeCap: 0.9,
MaxHillslopeSub: 24,
// One, and the choice is not a tuning decision. On a planar hillslope the correct specific
// catchment area is the same at every point along a contour, and D8 cannot say so: it gives
// one cell the whole flow tube and its neighbour a single cell for ever. Measured on a ramp
// at an aspect of 22.5 degrees, the most-drained cell in a contour band carried 769 times
// the median and 30 % of the grid drained nothing; at an exponent of one it is 1.34 and
// 0.4 %. Raising it past one narrows the spread again, so it is the knob to reach for if
// map_flow reads as broad smears rather than rivers - but a real valley has its cross-valley
// neighbours *above* it, which get zero weight whatever the exponent, so MFD is already D8
// wherever convergence is real.
MFDExponent: 1,
},
Thermal: Thermal{CoarsePasses: 2, Every: 4, FinePasses: 24, TalusDeg: 35},
// Off. Turning it on is a decision to hide something rather than to fix it, so it is a decision
// somebody makes in a file. 0.3 is about seventeen degrees: steeper than that is a landform and
// is left alone.
Smooth: Smooth{Passes: 0, SlopeRef: 0.3},
Strata: Strata{PeriodM: 160, Contrast: 0.6},
// ClassBlendM 120 is fifteen geology cells, which is exactly half the tile margin and therefore the
// most a tile can blend without reading past its own cut: two passes of a box blur reach twice the
// radius. SeabedM 24 is twice the shore taper, so the texture is fully in by the time the water is
// deep enough to hold it and gone again before the shelf.
Detail: Detail{Octaves: 4, AmplitudeM: Range{2, 8}, TilePx: 2500, ClassBlendM: 120, SeabedM: 24},
Particle: Particle{
Droplets: 9000000, DropletsPerCell: 0.18, Rounds: 16,
Lifetime: 40, Scale: 0.5, MinErodeSlope: 0.25, MaxChange: 0.2,
MaxSpeed: 5, MaxLoad: 2, Inertia: 0.1, Capacity: 2, MinSlope: 0.01,
ErodeRate: 0.2, DepositRate: 0.2, Evaporation: 0.02, Gravity: 4, Batch: 200000,
},
},
}
}
// Load reads the manifest over the defaults, so a key absent from the file keeps the generator's number.
// encoding/json only assigns fields that are present, which gives exactly the merge the numpy pipeline did
// with {**DEFAULTS, **settings}.
func Load(path string) (*Manifest, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
m := Defaults()
if err := json.Unmarshal(raw, m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
m.Path = path
m.fillPlanetDefaults()
return m, m.Validate()
}
// fillPlanetDefaults runs after the merge rather than in Defaults(), because the block is a pointer: a
// manifest without one is not a planet at all, and json.Unmarshal would allocate a zero struct over
// anything Defaults had put there.
func (m *Manifest) fillPlanetDefaults() {
p := m.Planet
if p == nil {
return
}
if p.CircumferenceKm == 0 {
p.CircumferenceKm = 100
}
if p.OceanMarginKm == 0 {
p.OceanMarginKm = 0.5
}
if p.MinLandCells == 0 {
p.MinLandCells = 16
}
if p.UpliftVariation == 0 {
p.UpliftVariation = 0.30
}
// 12 px is about 155 m on a 100 km planet drawn at 7738 px, and the octaves run from 2.5 km down to
// 155 m. The old default was 1.5 px, which is one template pixel of wobble and would have been invisible
// - it was never read by anything, so it was never a measured number.
if p.CoastJitterPx == 0 {
p.CoastJitterPx = 12
}
if p.CoastJitterWavelengthPx == 0 {
p.CoastJitterWavelengthPx = 192
}
if p.CoastJitterOctaves == 0 {
p.CoastJitterOctaves = 5
}
if p.CoastJitterGain == 0 {
p.CoastJitterGain = 0.55
}
if p.MassifWavelengthKm == 0 {
p.MassifWavelengthKm = 12
}
if p.NoisePeriodKm == 0 {
p.NoisePeriodKm = p.CircumferenceKm
}
if p.DetailNoisePeriodKm == 0 {
p.DetailNoisePeriodKm = 1
}
// A real shelf break, because on a planet a real margin fits. The square canvas's 30 m is not a shelf
// break at all, it is the shallow end of a 14 km canvas's sea floor range, and inheriting it here is
// what made a painted 512 m ocean come out as a 20 m pond (D-64).
if m.Pipeline.Coast.BreakM == 0 {
m.Pipeline.Coast.BreakM = 130
}
}
// ShelfBreakM is how deep the water is at the shelf break, in positive metres.
//
// The planet names it outright. The square canvas never did: its sea floor is a range and the shelf break is
// the shallow end of it, so the fallback keeps that reading rather than inventing a key for a manifest that
// was written without one.
func (m *Manifest) ShelfBreakM() float64 {
if m.Pipeline.Coast.BreakM > 0 {
return m.Pipeline.Coast.BreakM
}
return -m.Pipeline.Continent.SeaFloorM.Hi()
}
// IsPlanet reports whether this manifest describes a painted planet rather than the square canvas.
func (m *Manifest) IsPlanet() bool { return m.Planet != nil }
// TemplatePath and LegendPath resolve the planet's two inputs against the manifest's own directory.
func (m *Manifest) TemplatePath() string { return m.relative(m.Planet.Template) }
func (m *Manifest) LegendPath() string { return m.relative(m.Planet.Legend) }
// OverlayPath and OverlayLegendPath resolve the annotation layer, or "" when there is none. The image may
// be named by the manifest or, failing that, by the overlay legend itself; the manifest wins, which is what
// lets the studio's versioned saves repoint without rewriting a file the author wrote.
func (m *Manifest) OverlayLegendPath() string {
if m.Planet == nil || m.Planet.OverlayLegend == "" {
return ""
}
return m.relative(m.Planet.OverlayLegend)
}
// OverlayPath is the painted overlay named by the manifest, or "" when it names none.
func (m *Manifest) OverlayPath() string {
if m.Planet == nil || m.Planet.Overlay == "" {
return ""
}
return m.relative(m.Planet.Overlay)
}
// HasOverlay reports whether an annotation layer is configured at all.
func (m *Manifest) HasOverlay() bool { return m.OverlayLegendPath() != "" }
// PlatesLayerPath and PlatesLegendPath resolve the painted tectonic layer, or "" when there is none. Same
// shape as the overlay's pair above, and for the same reason: the manifest names the image so that a
// versioned save can be repointed without rewriting the legend an author wrote.
func (m *Manifest) PlatesLayerPath() string {
if m.Planet == nil || m.Planet.Plates.Layer == "" {
return ""
}
return m.relative(m.Planet.Plates.Layer)
}
func (m *Manifest) PlatesLegendPath() string {
if m.Planet == nil || m.Planet.Plates.Legend == "" {
return ""
}
return m.relative(m.Planet.Plates.Legend)
}
// HasPaintedPlates reports whether the tectonics are drawn rather than generated.
func (m *Manifest) HasPaintedPlates() bool {
return m.PlatesLayerPath() != "" && m.PlatesLegendPath() != ""
}
// PalettePath is the preview palette, or "" when the manifest names none.
func (m *Manifest) PalettePath() string {
if m.Planet == nil || m.Planet.Palette == "" {
return ""
}
return m.relative(m.Planet.Palette)
}
func (m *Manifest) relative(p string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
return filepath.Join(filepath.Dir(m.Path), p)
}
// validatePlanet checks the numbers that would otherwise fail deep inside a bake, or - worse - not fail.
func (m *Manifest) validatePlanet() error {
p := m.Planet
if p.Template == "" {
return fmt.Errorf("%s: planet.template is empty", m.Path)
}
if p.Legend == "" {
return fmt.Errorf("%s: planet.legend is empty", m.Path)
}
if p.CircumferenceKm <= 0 {
return fmt.Errorf("%s: planet.circumference_km is %v", m.Path, p.CircumferenceKm)
}
cell := m.GeologyCellM()
cols := p.CircumferenceKm * 1000 / cell
if d := cols - math.Round(cols); d > 1e-9 || d < -1e-9 {
return fmt.Errorf("%s: a %.3f km circumference is %.4f cells of %.1f m. It must be a whole number, "+
"or the seam falls between two columns; the nearest that works is %.3f km",
m.Path, p.CircumferenceKm, cols, cell, math.Round(cols)*cell/1000)
}
if p.OceanMarginKm <= 0 {
return fmt.Errorf("%s: planet.ocean_margin_km is %v; a region needs a ring of water", m.Path, p.OceanMarginKm)
}
if p.CoastJitterPx < 0 {
return fmt.Errorf("%s: planet.coast_jitter_px is %v", m.Path, p.CoastJitterPx)
}
if p.CoastJitterPx > 0 {
if p.CoastJitterWavelengthPx <= 0 {
return fmt.Errorf("%s: planet.coast_jitter_wavelength_px is %v", m.Path, p.CoastJitterWavelengthPx)
}
if p.CoastJitterOctaves < 1 || p.CoastJitterOctaves > 12 {
return fmt.Errorf("%s: planet.coast_jitter_octaves is %d, outside 1..12",
m.Path, p.CoastJitterOctaves)
}
if p.CoastJitterGain <= 0 || p.CoastJitterGain >= 1 {
return fmt.Errorf("%s: planet.coast_jitter_gain is %v, outside 0..1 exclusive",
m.Path, p.CoastJitterGain)
}
}
if p.MassifWavelengthKm <= 0 {
return fmt.Errorf("%s: planet.massif_wavelength_km is %v", m.Path, p.MassifWavelengthKm)
}
for _, w := range []struct {
key string
km float64
}{{"lithology_wavelength_km", p.LithologyWavelengthKm}, {"fault_grain_km", p.FaultGrainKm}} {
if w.km < 0 {
return fmt.Errorf("%s: planet.%s is %v; it is a wavelength in kilometres", m.Path, w.key, w.km)
}
if w.km > p.NoisePeriodKm {
return fmt.Errorf("%s: planet.%s is %v km, longer than the noise period of %v km, so the field "+
"would be one lattice cell and flat over the whole world",
m.Path, w.key, w.km, p.NoisePeriodKm)
}
}
if p.MassifWavelengthKm > p.NoisePeriodKm {
return fmt.Errorf("%s: planet.massif_wavelength_km is %v against a noise period of %v. The fabric "+
"would be a single lattice cell, so every massif on the planet would be the same one",
m.Path, p.MassifWavelengthKm, p.NoisePeriodKm)
}
for _, np := range []struct {
key string
period float64
}{{"noise_period_km", p.NoisePeriodKm}, {"detail_noise_period_km", p.DetailNoisePeriodKm}} {
if np.period <= 0 {
return fmt.Errorf("%s: planet.%s is %v", m.Path, np.key, np.period)
}
if k := p.CircumferenceKm / np.period; math.Abs(k-math.Round(k)) > 1e-9 || k < 1 {
return fmt.Errorf("%s: planet.%s %v does not divide the circumference %v (%.4f times); every "+
"noise field built on it would break at the seam", m.Path, np.key, np.period,
p.CircumferenceKm, k)
}
}
return nil
}
// LithologyCells is the rock field's wavelength in lattice cells of the noise period, or 0 when the planet
// asks for no lithology. Rounded the same way MassifCells is and for the same reason: noise.Lattice.Sample
// wraps modulo its cell count, so anything else breaks at the seam.
func (p *Planet) LithologyCells() int {
if p.LithologyWavelengthKm <= 0 {
return 0
}
n := int(p.NoisePeriodKm/p.LithologyWavelengthKm + 0.5)
if n < 1 {
n = 1
}
return n
}
// MassifCells is the upland fabric's wavelength counted in lattice cells of the noise period, which is what
// noise.Params.BaseCells takes. It has to be a whole number: noise.Lattice.Sample wraps modulo its cell count,
// so a fraction of a cell at the seam is a discontinuity down one meridian.
func (p *Planet) MassifCells() int {
n := int(p.NoisePeriodKm/p.MassifWavelengthKm + 0.5)
if n < 1 {
n = 1
}
return n
}
// MassifWavelengthRoundedKm is the wavelength MassifCells actually delivers, which is what a run should
// report rather than what was asked for.
func (p *Planet) MassifWavelengthRoundedKm() float64 {
return p.NoisePeriodKm / float64(p.MassifCells())
}
// MarginCells is the ocean margin in geology cells, at least one.
func (p *Planet) MarginCells(cellM float64) int {
n := int(p.OceanMarginKm*1000/cellM + 0.5)
if n < 1 {
n = 1
}
return n
}
func (m *Manifest) Validate() error {
if m.VerticesPerSide < 2 {
return fmt.Errorf("%s: vertices_per_side must be at least 2", m.Path)
}
if m.QuadCm <= 0 {
return fmt.Errorf("%s: quad_cm must be positive", m.Path)
}
if m.ElevationM.Max <= m.ElevationM.Min {
return fmt.Errorf("%s: elevation_m.max must be above .min", m.Path)
}
// D-45: the importer picks the largest section size that divides the quad count, preferring one section
// per component, so a resolution off the ladder of 255*N+1 or 127*N+1 silently multiplies the component
// count. 4033 gave 4096 components and a forty-minute import. Refuse rather than let it happen again.
q := m.QuadsPerSide()
section := 0
for _, s := range []int{255, 127, 63, 31, 15, 7} {
if q%s == 0 {
section = s
break
}
}
if section == 0 {
return fmt.Errorf("%s: vertices_per_side %d gives %d quads, which no section size divides; use 255*N+1 or 127*N+1",
m.Path, m.VerticesPerSide, q)
}
if components := (q / section) * (q / section); components > 1024 {
return fmt.Errorf("%s: vertices_per_side %d gives %d components of %d quads; that import takes tens of minutes (D-45)",
m.Path, m.VerticesPerSide, components, section)
}
if f := m.Pipeline.GeologyFactor; f < 1 || q%f != 0 {
return fmt.Errorf("%s: geology_factor %d must divide the quad count %d exactly", m.Path, f, q)
}
if m.IsPlanet() {
return m.validatePlanet()
}
return nil
}
// Derived geometry, all of it mirroring world_manifest.py.
func (m *Manifest) QuadsPerSide() int { return m.VerticesPerSide - 1 }
func (m *Manifest) QuadM() float64 { return m.QuadCm / 100 }
func (m *Manifest) SideM() float64 { return float64(m.QuadsPerSide()) * m.QuadM() }
func (m *Manifest) AreaKm2() float64 { s := m.SideM() / 1000; return s * s }
func (m *Manifest) ElevationSpanM() float64 { return m.ElevationM.Max - m.ElevationM.Min }
func (m *Manifest) ElevationMidM() float64 { return (m.ElevationM.Max + m.ElevationM.Min) / 2 }
// ZScale is the landscape actor's Z scale so the 16-bit range spans exactly the manifest's elevation range.
func (m *Manifest) ZScale() float64 { return m.ElevationSpanM() / EngineSpanMAtScale100 * 100 }
// LandscapeZCm places value 32768 at the middle of the range, so elevation 0 m lands on world Z 0.
func (m *Manifest) LandscapeZCm() float64 { return m.ElevationMidM() * 100 }
func (m *Manifest) MetresToValue(metres float64) float64 {
return (metres - m.ElevationM.Min) / m.ElevationSpanM() * 65535
}
func (m *Manifest) ValueToMetres(v float64) float64 {
return m.ElevationM.Min + v/65535*m.ElevationSpanM()
}
// GeologySize is the coarse grid the tectonics and the fluvial solve run on: an exact integer factor of the
// quad count, so the upsample back to full resolution lands every sample on a sample.
func (m *Manifest) GeologySize() int {
return m.QuadsPerSide()/m.Pipeline.GeologyFactor + 1
}
func (m *Manifest) GeologyCellM() float64 {
return m.QuadM() * float64(m.Pipeline.GeologyFactor)
}
// SectionLayout reports what the engine's importer will choose, so a run can print it and a person can see
// the component count before the editor spends minutes on it.
func (m *Manifest) SectionLayout() (section, componentsPerSide int) {
q := m.QuadsPerSide()
for _, s := range []int{255, 127, 63, 31, 15, 7} {
if q%s == 0 {
return s, q / s
}
}
return 0, 0
}
// Resolve reads a manifest path as relative to the project root.
func (m *Manifest) Resolve(rel string) string {
if filepath.IsAbs(rel) {
return rel
}
return filepath.Join(ProjectRoot(m.Path), rel)
}
// ProjectRoot walks up from the manifest (RawContent/World/World.json) to the repository root.
func ProjectRoot(manifestPath string) string {
abs, err := filepath.Abs(manifestPath)
if err != nil {
return "."
}
return filepath.Dir(filepath.Dir(filepath.Dir(abs)))
}
func (m *Manifest) Describe() string {
section, perSide := m.SectionLayout()
return fmt.Sprintf(
"%d vertices a side at %g cm: %.2f km, %.0f km2; elevation %g..%g m (Z scale %g, actor Z %g cm, %.2f cm a step); "+
"%dx%d components of %d quads; geology %d at %.1f m; source %s seed %d",
m.VerticesPerSide, m.QuadCm, m.SideM()/1000, m.AreaKm2(),
m.ElevationM.Min, m.ElevationM.Max, m.ZScale(), m.LandscapeZCm(), m.ElevationSpanM()/65535*100,
perSide, perSide, section, m.GeologySize(), m.GeologyCellM(), m.Source.Kind, m.Source.Seed)
}
// ClipFraction is the check D-48 made a pass/fail: U/K is the one relief knob and the elevation ceiling is a
// hard clip in the 16-bit encoding, so a run that clips is a failed run, not a rounded one.
func (m *Manifest) ClipFraction(metres []float32) float64 {
if len(metres) == 0 {
return 0
}
return float64(m.ClipCells(metres)) / float64(len(metres))
}
// ClipCells is the same count before it is turned into a fraction.
//
// A fraction of one region is not a fraction of a planet and cannot be made into one without carrying the
// region's size beside it, so anything that pools across regions counts cells and divides at the end. See
// internal/stats.
func (m *Manifest) ClipCells(metres []float32) int64 {
var n int64
for _, v := range metres {
if float64(v) < m.ElevationM.Min || float64(v) > m.ElevationM.Max {
n++
}
}
return n
}
// Encode turns metres into the 16-bit values the PNG carries, clamping to the range.
// Decode is Encode's inverse: 16-bit samples back to metres. It is what lets the detail bake read a geology
// bake's heightmap off disk instead of holding it, which is what makes the two commands separable.
func (m *Manifest) Decode(values []uint16) []float32 {
span := m.ElevationM.Max - m.ElevationM.Min
out := make([]float32, len(values))
for i, v := range values {
out[i] = float32(m.ElevationM.Min + float64(v)/65535*span)
}
return out
}
func (m *Manifest) Encode(metres []float32) []uint16 {
out := make([]uint16, len(metres))
for i, v := range metres {
x := m.MetresToValue(float64(v))
if x < 0 {
x = 0
} else if x > 65535 {
x = 65535
}
out[i] = uint16(math.Round(x))
}
return out
}