// 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 }