package uplift import ( "math" "testing" "salty/terrain/internal/world" ) // oneClassCandidates is a planet where every painted cell belongs to class 0, sampled at a stride. func oneClassCandidates(p world.Planet, stride int) ([][]int32, []int) { var cells []int32 for y := 0; y < p.H; y += stride { for x := 0; x < p.W; x += stride { cells = append(cells, int32(y*p.W+x)) } } return [][]int32{cells}, []int{p.W * p.H} } func testSpec() []FaultSpec { return []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{4, 8}}} } // A seed names a fault set, and the same seed names the same one. Everything else here rests on that. func TestTheSameSeedDrawsTheSameFaults(t *testing.T) { const w, h, cellM = 1024, 512, 64.0 p := testPlanet(t, w, h, cellM) cand, area := oneClassCandidates(p, 8) a := BuildFaults(p, 7, 32, testSpec(), cand, area) b := BuildFaults(p, 7, 32, testSpec(), cand, area) if len(a) == 0 { t.Fatal("no faults were drawn; this test measured nothing") } if len(a) != len(b) { t.Fatalf("two runs of one seed drew %d and %d traces", len(a), len(b)) } for i := range a { if a[i].ThrowM != b[i].ThrowM || a[i].Reverse != b[i].Reverse || len(a[i].PointsM) != len(b[i].PointsM) || a[i].PointsM[0] != b[i].PointsM[0] { t.Fatalf("trace %d differs between two runs of one seed", i) } } // And a different seed is a different world. if c := BuildFaults(p, 9342, 32, testSpec(), cand, area); len(c) > 0 && c[0].PointsM[0] == a[0].PointsM[0] { t.Error("a different seed put the first trace in the same place") } } // The density means what it says: traces per thousand square kilometres of the class, not per map. func TestFaultDensityIsPerAreaOfTheClass(t *testing.T) { const cellM = 64.0 small := testPlanet(t, 512, 256, cellM) big := testPlanet(t, 1024, 512, cellM) count := func(p world.Planet) int { cand, area := oneClassCandidates(p, 8) return len(BuildFaults(p, 7, 32, testSpec(), cand, area)) } ns, nb := count(small), count(big) if ns == 0 { t.Fatal("the small planet drew nothing; this test measured nothing") } // Four times the area, so about four times the traces. Loose, because a long fault becomes two or three // en-echelon segments and the draw is stochastic - the assertion is that it scales, not that it is exact. if ratio := float64(nb) / float64(ns); ratio < 2.5 || ratio > 6 { t.Errorf("four times the area gave %d traces against %d, a ratio of %.1f", nb, ns, ratio) } } // The property the whole port exists for: a fault is the planet's, not a region's. Two frames overlapping the // same ground have to agree about the rate it contributes, and both have to agree with the whole planet. // // `uplift.Build` places a trace at two calls to Float() read as fractions of the grid it is filling, so the // obvious port of it fails this - the same fault would land somewhere different in every region. func TestTwoFramesAgreeAboutTheSameFaults(t *testing.T) { const w, h, cellM = 1024, 512, 64.0 p := testPlanet(t, w, h, cellM) cand, area := oneClassCandidates(p, 8) faults := BuildFaults(p, 7, 32, testSpec(), cand, area) if len(faults) == 0 { t.Fatal("no faults; this test measured nothing") } const runYears = 1.5e6 whole := FaultDelta(world.Whole(p), faults, runYears) a := world.Frame{P: p, X0: 200, Y0: 100, W: 300, H: 200} b := world.Frame{P: p, X0: 380, Y0: 160, W: 300, H: 200} da, db := FaultDelta(a, faults, runYears), FaultDelta(b, faults, runYears) if da == nil || db == nil { t.Fatal("neither frame was reached by any fault; move the windows") } checked, nonZero := 0, 0 for y := 0; y < a.H; y++ { for x := 0; x < a.W; x++ { px, py := a.PlanetXY(x, y) if px < b.X0 || px >= b.X0+b.W || py < b.Y0 || py >= b.Y0+b.H { continue } got := da[y*a.W+x] if want := db[(py-b.Y0)*b.W+(px-b.X0)]; got != want { t.Fatalf("at planet (%d,%d) frame A says %v and frame B says %v", px, py, got, want) } if wh := whole[py*p.W+px]; wh != got { t.Fatalf("at planet (%d,%d) a frame says %v and the whole planet says %v", px, py, got, wh) } checked++ if got != 0 { nonZero++ } } } if checked == 0 { t.Fatal("the two frames do not overlap") } if nonZero == 0 { t.Fatal("every cell of the overlap is zero; the agreement is vacuous") } } // X wraps, so a fault whose trace runs past the meridian has to reach the ground on the other side of it. // The trace is stored unwrapped and shifted once per fault; this is what says that shift works. func TestAFaultReachesAcrossTheSeam(t *testing.T) { const w, h, cellM = 512, 256, 64.0 p := testPlanet(t, w, h, cellM) circ := p.CircumferenceM() // A trace lying just east of the seam, running north-south, well inside the reach of the map's west edge. x := 300.0 trace := FaultTrace{ PointsM: [][2]float64{{x, 2000}, {x, 5000}, {x, 8000}}, ThrowM: 400, LengthM: 6000, Reverse: false, } west := world.Frame{P: p, X0: 0, Y0: 0, W: 40, H: h} if d := FaultDelta(west, []FaultTrace{trace}, 1.5e6); d == nil { t.Fatal("a trace 300 m east of the seam did not reach a frame at the seam") } // The same trace written with its X a whole world further east is the same fault, so a frame at the far // end of the map must see the identical field. shifted := FaultTrace{ PointsM: [][2]float64{{x + circ, 2000}, {x + circ, 5000}, {x + circ, 8000}}, ThrowM: 400, LengthM: 6000, } near := FaultDelta(west, []FaultTrace{trace}, 1.5e6) far := FaultDelta(west, []FaultTrace{shifted}, 1.5e6) if far == nil { t.Fatal("the shifted trace reached nothing; the wrap is not being applied") } for i := range near { if near[i] != far[i] { t.Fatalf("a trace and the same trace one circumference east differ at cell %d: %v vs %v", i, near[i], far[i]) } } } // The scarp is asymmetric, which is what makes a fault a tilted block rather than a ridge: it rises fast on // one side over a couple of hundred metres and falls away slowly on the other over a couple of kilometres. func TestAFaultIsATiltedBlockAndNotARidge(t *testing.T) { const w, h, cellM = 1024, 1024, 32.0 p := testPlanet(t, w, h, cellM) mid := float64(h) * cellM / 2 // A straight east-west trace across the middle. pts := make([][2]float64, 9) for i := range pts { pts[i] = [2]float64{float64(i) * float64(w) * cellM / 8, mid} } d := FaultDelta(world.Whole(p), []FaultTrace{{PointsM: pts, ThrowM: 400, LengthM: float64(w) * cellM}}, 1.5e6) if d == nil { t.Fatal("the trace reached nothing") } col := w / 2 at := func(yM float64) float64 { return float64(d[int(yM/cellM)*w+col]) } // One side is positive and the other negative: the block tilts. up, down := at(mid-1400), at(mid+1200) if up*down >= 0 { t.Fatalf("both sides of the trace have the same sign (%v, %v); that is a ridge, not a fault", up, down) } // And the footwall reaches further than the hanging wall. if math.Abs(at(mid-4800)) <= math.Abs(at(mid+4800)) { t.Errorf("at 4800 m the footwall is %v and the hanging wall %v; the asymmetry is the wrong way "+ "round or absent", math.Abs(at(mid-4800)), math.Abs(at(mid+4800))) } } // The defect this file's shape block is about (D-62), as two numbers rather than a hillshade. // // The profile used to put the whole throw on one side of the trace and the whole throw negated on the // other, one cell apart, inside a welt six hundred metres wide. That is unsolvable twice over: a step in // the rate field is a cliff the erosion can only clamp at the angle of repose, and a block narrower than // one hillslope has no drainage area on it for stream power to cut with, so the profile is printed onto // the surface instead of being eroded into a landform. // // So: continuous through the trace, and wide enough on the upthrown side for a drainage network to live // on. 1100 m is the hillslope length measured on Bake_013 (a drainage density of 0.45 channels per km), // and three of them is the least that can carry a valley and its two divides. func TestAFaultIsSolvableRatherThanPrinted(t *testing.T) { const w, h, cellM = 2048, 2048, 8.0 const throw, runYears = 400.0, 1.5e6 p := testPlanet(t, w, h, cellM) mid := float64(h) * cellM / 2 pts := make([][2]float64, 17) for i := range pts { pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, mid} } d := FaultDelta(world.Whole(p), []FaultTrace{{PointsM: pts, ThrowM: throw, LengthM: float64(w) * cellM}}, runYears) if d == nil { t.Fatal("the trace reached nothing") } col := w / 2 // Metres of displacement the rate builds over the whole run, which is what the surface has to carry. at := func(yM float64) float64 { return float64(d[int(yM/cellM)*w+col]) * runYears } var crest, trough, crestAt, troughAt, steepest, steepestAt float64 prev := at(mid - faultReachM) for dy := -faultReachM + cellM; dy <= faultReachM; dy += cellM { v := at(mid + dy) if v > crest { crest, crestAt = v, dy } if v < trough { trough, troughAt = v, dy } if g := math.Abs(v-prev) / cellM; g > steepest { steepest, steepestAt = g, dy } prev = v } // Continuous: no cell-to-cell step steeper than ground the solve can actually shape. The repose clamp // is at 35 degrees and the old profile measured 89. if deg := math.Atan(steepest) * 180 / math.Pi; deg > 25 { t.Errorf("the steepest cell-to-cell step in the rate field is %.1f degrees at %+.0f m; that is a "+ "cliff in the uplift, and the solve can only clamp it at the angle of repose", deg, steepestAt) } // Zero on the trace itself: a rate difference across a line averages to the regional rate at the line. if v := math.Abs(at(mid)); v > throw/50 { t.Errorf("the anomaly on the trace is %.1f m; it should be nothing", v) } // Wide enough to be dissected: the upthrown flank has to carry a drainage network. const hillslopeM = 1100 var above float64 for dy := 0.0; dy <= faultReachM; dy += cellM { if at(mid-dy) >= crest/2 { above = dy } } if above < 3*hillslopeM { t.Errorf("the footwall stands above half its crest for only %.0f m, under three hillslope lengths "+ "(%d m); nothing can cut a valley into it and the profile will print", above, 3*hillslopeM) } // And the step across the fault is the throw the author asked for, not two of them. if step := crest - trough; math.Abs(step-throw) > throw/20 { t.Errorf("the step across the fault is %.0f m against a throw of %.0f m", step, throw) } t.Logf("crest %+.0f m at %+.0f m, trough %+.0f m at %+.0f m, step %.0f m over %.0f m (%.1f deg mean), "+ "steepest cell %.1f deg, footwall above half-crest for %.0f m", crest, crestAt, trough, troughAt, crest-trough, crestAt-troughAt, math.Atan((crest-trough)/math.Abs(crestAt-troughAt))*180/math.Pi, math.Atan(steepest)*180/math.Pi, above) } // A fault dies out along strike instead of stopping dead, which is what left an abrupt cut across a summit on // the procedural path - and it is never flat along strike either, which is what left it extruded. func TestTheThrowTapersToNothingAtTheTips(t *testing.T) { if tipTaper(0) != 0 || tipTaper(1) != 0 { t.Errorf("the tips carry no throw: got %v and %v", tipTaper(0), tipTaper(1)) } if tipTaper(0.5) != 1 { t.Errorf("the middle carries all of it: got %v", tipTaper(0.5)) } // Monotone to the middle, so the ramp has no step in it. prev := 0.0 for a := 0.0; a <= 0.5; a += 0.01 { v := tipTaper(a) if v < prev-1e-12 { t.Fatalf("the taper goes backwards at %v: %v after %v", a, v, prev) } prev = v } // Symmetric about the middle. for _, a := range []float64{0.05, 0.2, 0.37} { if math.Abs(tipTaper(a)-tipTaper(1-a)) > 1e-12 { t.Errorf("the two ends differ at %v: %v against %v", a, tipTaper(a), tipTaper(1-a)) } } // Nowhere flat: the old taper held exactly 1 across the middle two thirds, which extrudes the // cross-section along most of every trace. Nothing between the tips and the centre may repeat. if tipTaper(0.2) >= tipTaper(0.35) || tipTaper(0.35) >= tipTaper(0.5) { t.Errorf("the throw is flat along strike: %v, %v, %v at a fifth, a third and the middle", tipTaper(0.2), tipTaper(0.35), tipTaper(0.5)) } // But it still has a body: most of a trace carries at least half its throw. above := 0 for i := 0; i <= 1000; i++ { if tipTaper(float64(i)/1000) >= 0.5 { above++ } } if above < 750 { t.Errorf("only %d parts in a thousand of the trace carry half the throw; the fault is a spike", above) } } // A long fault steps rather than running as one ruled line. func TestALongFaultBreaksIntoEnEchelonSegments(t *testing.T) { const w, h, cellM = 2048, 1024, 64.0 p := testPlanet(t, w, h, cellM) cand, area := oneClassCandidates(p, 8) short := []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{4, 6}}} long := []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{20, 26}}} ns := len(BuildFaults(p, 7, 32, short, cand, area)) nl := len(BuildFaults(p, 7, 32, long, cand, area)) if ns == 0 { t.Fatal("nothing was drawn; this test measured nothing") } if nl <= ns { t.Errorf("faults over the en-echelon length gave %d traces against %d for short ones; they are not "+ "stepping", nl, ns) } } // Nothing is drawn when nothing asks, and nothing is rasterised when no trace reaches a frame - which is what // keeps a planet with no faults paying nothing for the pass. func TestNoFaultsCostsNothing(t *testing.T) { const w, h, cellM = 256, 128, 64.0 p := testPlanet(t, w, h, cellM) cand, area := oneClassCandidates(p, 8) if got := BuildFaults(p, 7, 32, []FaultSpec{{}}, cand, area); got != nil { t.Errorf("an empty spec drew %d traces", len(got)) } if got := BuildFaults(p, 7, 0, testSpec(), cand, area); got != nil { t.Error("a zero grain wavelength should draw nothing") } far := FaultTrace{PointsM: [][2]float64{{0, 100000}, {1000, 100000}}, ThrowM: 400} if d := FaultDelta(world.Whole(p), []FaultTrace{far}, 1.5e6); d != nil { t.Error("a trace far off the frame should allocate no field at all") } }