1112 lines
55 KiB
Go
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
|
|
}
|