Files
2026-09-25 17:02:24 +03:00

187 lines
6.6 KiB
Go

package uplift
import (
"math"
"testing"
"salty/terrain/internal/manifest"
"salty/terrain/internal/world"
)
// The defect D-62 caused and D-63 answers: a sub-parallel fault set stacked.
//
// Widening a fault's reach from 600 m to 6 km made overlap the ordinary case rather than a rarity, and a
// fault set is sub-parallel by construction - traces within one cell of the orientation grain share a
// strike. On Bake_018's region 11, 75 % of the faulted ground had two or more faults on it and the sum was
// a median 1.77 and up to 4.46 times the largest single contribution, which took the landmass from 75 m to
// 221 m and fired the repose clamp on 4.1 % of it.
//
// Five parallel traces two kilometres apart is that in miniature: at a 6 km footwall every one of them
// reaches every other, so an unbounded sum would be several throws deep.
func TestParallelFaultsDoNotStack(t *testing.T) {
const w, h, cellM = 512, 1600, 16.0
const throw, runYears = 300.0, 1.5e6
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
trace := func(yM float64) FaultTrace {
pts := make([][2]float64, 17)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, yM}
}
return FaultTrace{PointsM: pts, ThrowM: throw, LengthM: float64(w) * cellM}
}
midM := float64(h) * cellM / 2
var many []FaultTrace
for i := -2; i <= 2; i++ {
many = append(many, trace(midM+float64(i)*2000))
}
one := []FaultTrace{trace(midM)}
peakOf := func(faults []FaultTrace) (hi, lo float64) {
d := FaultDelta(f, faults, runYears)
if d == nil {
t.Fatal("the traces reached nothing")
}
col := w / 2
for y := 0; y < h; y++ {
v := float64(d[y*w+col]) * runYears
hi = math.Max(hi, v)
lo = math.Min(lo, v)
}
return hi, lo
}
hi1, lo1 := peakOf(one)
hiN, loN := peakOf(many)
// A belt still stands higher than one fault does, which is the point of a belt.
if hiN <= hi1 {
t.Errorf("five faults build %.0f m against one fault's %.0f m; the stack is suppressed entirely",
hiN, hi1)
}
// But not five times higher. The asymptote is 1+faultStackBonus times the largest single contribution
// at a cell, and the crest of the stack sits near the crest of its strongest member.
limit := (1 + faultStackBonus) * 1.05
if hiN > hi1*limit {
t.Errorf("five parallel faults build %.0f m against one fault's %.0f m, a factor of %.2f; the "+
"bound is %.2f", hiN, hi1, hiN/hi1, limit)
}
// The hanging walls are bounded on the same terms, or a fault set digs a hole instead of building one.
if loN < lo1*limit {
t.Errorf("five parallel faults drop %.0f m against one fault's %.0f m, a factor of %.2f",
loN, lo1, loN/lo1)
}
t.Logf("one fault %+.0f/%+.0f m, five at 2 km spacing %+.0f/%+.0f m (x%.2f)",
hi1, lo1, hiN, loN, hiN/hi1)
// And it is still solvable ground: bending the stack must not put a step back into the rate field.
d := FaultDelta(f, many, runYears)
var steepest float64
col := w / 2
for y := 1; y < h; y++ {
g := math.Abs(float64(d[y*w+col]-d[(y-1)*w+col])) * runYears / cellM
steepest = math.Max(steepest, g)
}
if deg := math.Atan(steepest) * 180 / math.Pi; deg > 25 {
t.Errorf("the steepest cell-to-cell step across the stack is %.1f degrees", deg)
}
}
// One fault is untouched by the bound, so D-62's calibration still holds: the step across a fault is the
// throw its author asked for. softStack is the identity below its knee and the knee is the largest single
// contribution, so this is the property that makes the two changes compose rather than fight.
func TestOneFaultIsNotBentByTheStackBound(t *testing.T) {
const w, h, cellM = 512, 1600, 16.0
const throw, runYears = 300.0, 1.5e6
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
midM := float64(h) * cellM / 2
pts := make([][2]float64, 17)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, midM}
}
d := FaultDelta(f, []FaultTrace{{PointsM: pts, ThrowM: throw, LengthM: float64(w) * cellM}}, runYears)
if d == nil {
t.Fatal("the trace reached nothing")
}
col := w / 2
var hi, lo float64
for y := 0; y < h; y++ {
v := float64(d[y*w+col]) * runYears
hi = math.Max(hi, v)
lo = math.Min(lo, v)
}
if step := hi - lo; math.Abs(step-throw) > throw/20 {
t.Errorf("the step across a lone fault is %.0f m against a throw of %.0f m; the stack bound is "+
"biting on a single fault", step, throw)
}
}
// The initial relief is scaled by the rate *before* the faults (D-63).
//
// It used to be scaled by the finished rate with the fault delta in it and no upper bound, so D-62's six
// kilometre footwalls raised the stamped noise from about 39 m to about 166 m on a landmass whose whole
// relief was 221 m - and a thousand steps cannot erase initial relief the size of the landscape, so the
// ridged fBm stopped breaking the symmetry and became the flank texture instead. The initial relief exists
// to give the solve something to bite on; how much noise sits on a hillside is not a fault's decision.
func TestTheInitialReliefIgnoresFaults(t *testing.T) {
const w, h, cellM = 256, 256, 64.0
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
class := make([]uint8, w*h)
land := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
if x >= 16 {
class[y*w+x], land[y*w+x] = 1, true
}
}
}
midM := float64(h) * cellM / 2
pts := make([][2]float64, 17)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, midM}
}
faults := []FaultTrace{{PointsM: pts, ThrowM: 400, LengthM: float64(w) * cellM}}
m := manifest.Defaults()
m.Source.Seed = 7
build := func(fs []FaultTrace) *Result {
return FromTemplate(Paint{
Frame: f, Class: class, Land: land,
Rates: []float32{0, 0.00025}, // 0.25 mm/yr, the shipped highland
Ks: []float32{1, 1},
Faults: fs,
RunYears: 1.5e6,
Variation: 0,
// The repose bound off, so the fault delta survives into the rate and the difference this
// test is looking for is actually there to find.
ClampCeilM: 0,
}, m)
}
with, without := build(faults), build(nil)
differs := 0
for i := range with.Rate.Data {
if with.Rate.Data[i] != without.Rate.Data[i] {
differs++
}
}
if differs == 0 {
t.Fatal("the fault changed no rate at all; this test measured nothing")
}
for i := range with.Height.Data {
if with.Height.Data[i] != without.Height.Data[i] {
t.Fatalf("the initial relief at cell %d is %.3f m with faults and %.3f m without, over %d "+
"cells whose rate the fault changed; the amplitude is still reading the finished rate",
i, with.Height.Data[i], without.Height.Data[i], differs)
}
}
t.Logf("the fault moved %d of %d rates and no initial relief at all", differs, len(with.Rate.Data))
}