Added: Initial world generation tool
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
// Package fluvial is the stream-power erosion solve: dh/dt = U - K * A^m * S^n, integrated implicitly up the
|
||||
// drainage stack by the method of Braun & Willett (2013).
|
||||
//
|
||||
// This is the reason the generator exists (D-47). Particle erosion carves the path each droplet happens to
|
||||
// take: it makes wear, but never a network. Stream power solves for drainage area first and erodes in
|
||||
// proportion to it, which is what produces a branching hierarchy, valleys whose size matches the area they
|
||||
// drain, and divides that sit where the basins either side of them put them.
|
||||
//
|
||||
// Four things happen per step, in this order:
|
||||
//
|
||||
// 1. Depressions are filled (priority-flood), because a D8 receiver graph containing a pit has no path to
|
||||
// base level and the implicit solve has nothing to descend to. This is the only part of a step that is
|
||||
// not O(n), so it runs every FillEvery steps, not every step.
|
||||
// 2. Receivers and the stack are computed: steepest descent to one of eight neighbours, then a depth-first
|
||||
// ordering in which every node appears after its receiver.
|
||||
// 3. Drainage area is accumulated down the stack in reverse.
|
||||
// 4. The implicit update runs up the stack, so each node's receiver already holds its new height. This is
|
||||
// what makes the scheme unconditionally stable in dt, and it is why a naive explicit solver is not an
|
||||
// acceptable substitute at dt = 1500 yr.
|
||||
package fluvial
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/thermal"
|
||||
)
|
||||
|
||||
// Params are the stream-power constants. K is per year with A in m².
|
||||
type Params struct {
|
||||
K float64
|
||||
M float64
|
||||
N float64
|
||||
DtYr float64
|
||||
Steps int
|
||||
Diffusion float64 // hillslope diffusivity, m²/yr
|
||||
FillEvery int
|
||||
|
||||
// Landsliding. Stream power bounds nothing at small drainage area, so without this the hillslopes grow
|
||||
// as steep as the uplift rate asks them to and the map becomes needles. TalusSlope is rise over run; 0
|
||||
// disables. It runs inside the loop rather than after it, because a cap applied once at the end just
|
||||
// shaves the tops off, while a cap applied throughout changes where the sediment goes.
|
||||
TalusSlope float64
|
||||
ThermalEvery int
|
||||
ThermalPasses int
|
||||
|
||||
// CriticalAreaM2 is where channels begin. Below it the cell is a hillslope: it still rises, and
|
||||
// diffusion and landsliding still shape it, but stream power does not incise it.
|
||||
//
|
||||
// This is not a tuning knob, it is a correctness fix. Stream power is a law about channels, and applying
|
||||
// it at A = one cell says a cell that drains only itself should stand at U/(K*cellM^(2m)) — 63 degrees at
|
||||
// the rates here. That is why the map came out as needles at a 39 degree median. It is also why relief
|
||||
// was resolution-dependent (1020 m at 512², 2605 m at 1786² on one seed): halving the cell size halves
|
||||
// the smallest A and steepens every divide, for ever. A critical area is a physical length, so the same
|
||||
// landscape comes out at any resolution, which is the property the full-resolution run depends on.
|
||||
CriticalAreaM2 float64
|
||||
|
||||
// ChannelTaper is the exponent on (A/Ac) below the channel head. 0 is no taper, 2 is strong.
|
||||
ChannelTaper float64
|
||||
|
||||
// CriticalSlope is Sc in the nonlinear hillslope law, rise over run. Above zero it selects
|
||||
// DiffuseNonlinear over plain linear diffusion and takes the repose clamp out of the step loop; see
|
||||
// hillslope.go and Run. Zero keeps the old pairing of linear diffusion and an in-loop clamp.
|
||||
CriticalSlope float64
|
||||
SlopeCap float64 // where the flux stops stiffening, as a fraction of Sc
|
||||
MaxHillslopeSub int // the sub-step budget that bound buys
|
||||
}
|
||||
|
||||
// Grid holds the flow topology and the scratch it is built from. Allocated once and reused across every
|
||||
// step: at 3.2 M cells the allocations would otherwise dominate the solve.
|
||||
type Grid struct {
|
||||
W, H int
|
||||
CellM float64
|
||||
|
||||
// Base marks cells fixed at base level: the ocean. The map border is an outlet too, so what actually
|
||||
// counts as "fixed" is Base plus the border, and that union is `fixed`. Keeping only Base in mind here is
|
||||
// how the outlets themselves ended up being uplifted: on a map with no ocean, every border cell has
|
||||
// Receiver == itself and Base == false, so base level rose two metres a step and the whole solve chased
|
||||
// it. The steady-state test is what caught it.
|
||||
Base []bool
|
||||
fixed []bool
|
||||
|
||||
Receiver []int32 // index of the cell this one drains to; itself for a base cell
|
||||
Length []float32 // distance to that receiver, metres
|
||||
Stack []int32 // every node after its receiver
|
||||
Area []float32 // drainage area, m²
|
||||
|
||||
seed uint64 // the jitter's seed; see jitter.go and SetSeed
|
||||
donorOff []int32
|
||||
donorList []int32
|
||||
cursor []int32
|
||||
closed []bool
|
||||
pq *bucketPQ
|
||||
fifo []int32
|
||||
scratch []float32
|
||||
}
|
||||
|
||||
// SetElevationRange sizes the flood's bucket queue. Called once, with the manifest's elevation range plus a
|
||||
// margin, before the first step.
|
||||
func (g *Grid) SetElevationRange(loM, hiM float64) {
|
||||
g.pq = newBucketPQ(loM, hiM)
|
||||
}
|
||||
|
||||
var (
|
||||
// D8, in the order (-1,-1) .. (1,1) skipping the centre. The order is fixed so that a run is reproducible,
|
||||
// but it is no longer what decides a tie between two equally steep neighbours: a fixed order resolves
|
||||
// every tie the same way and prints its preferred axis across any near-flat ground. See jitter.go.
|
||||
dx8 = [8]int{-1, 0, 1, -1, 1, -1, 0, 1}
|
||||
dy8 = [8]int{-1, -1, -1, 0, 0, 1, 1, 1}
|
||||
)
|
||||
|
||||
func NewGrid(w, h int, cellM float64, base []bool) *Grid {
|
||||
n := w * h
|
||||
g := &Grid{
|
||||
W: w, H: h, CellM: cellM, Base: base,
|
||||
Receiver: make([]int32, n), Length: make([]float32, n), Stack: make([]int32, 0, n),
|
||||
Area: make([]float32, n), donorOff: make([]int32, n+1), donorList: make([]int32, n),
|
||||
closed: make([]bool, n), fifo: make([]int32, 0, n), scratch: make([]float32, n),
|
||||
}
|
||||
g.fixed = make([]bool, n)
|
||||
for i := range g.fixed {
|
||||
g.fixed[i] = g.isOutlet(i)
|
||||
}
|
||||
g.SetElevationRange(-2000, 4000)
|
||||
return g
|
||||
}
|
||||
|
||||
// FillDepressions raises closed depressions to their spill point, in place, using Barnes' improved
|
||||
// priority-flood with a plain FIFO beside the heap. The FIFO is the optimisation that matters: on real
|
||||
// terrain most cells are reached while descending into an already-flooded pit, and those never touch the
|
||||
// heap, which turns the cost from "half an hour over a run" into something affordable.
|
||||
//
|
||||
// The epsilon variant adds a millimetre of fall per cell across a flat, so filled lakes still route rather
|
||||
// than becoming a plateau the flow accumulator cannot leave. That millimetre is scattered per cell by a hash
|
||||
// of the index rather than applied uniformly: a uniform epsilon means the only gradient on a flat is the
|
||||
// flood's own traversal order, and the router then draws that order as rivers. See jitter.go.
|
||||
func (g *Grid) FillDepressions(h []float32, epsilon float32) {
|
||||
n := g.W * g.H
|
||||
for i := range g.closed {
|
||||
g.closed[i] = false
|
||||
}
|
||||
g.pq.reset()
|
||||
g.fifo = g.fifo[:0]
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
if g.isOutlet(i) {
|
||||
g.closed[i] = true
|
||||
g.pq.push(h[i], int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
head := 0
|
||||
for g.pq.len() > 0 || head < len(g.fifo) {
|
||||
var c int32
|
||||
var celev float32
|
||||
// Drain the FIFO while it cannot violate the queue's ordering.
|
||||
if head < len(g.fifo) && (g.pq.len() == 0 || h[g.fifo[head]] <= g.pq.frontElev()) {
|
||||
c = g.fifo[head]
|
||||
head++
|
||||
celev = h[c]
|
||||
} else {
|
||||
c = g.pq.pop()
|
||||
if c < 0 {
|
||||
break
|
||||
}
|
||||
celev = h[c]
|
||||
}
|
||||
cx := int(c) % g.W
|
||||
cy := int(c) / g.W
|
||||
for k := 0; k < 8; k++ {
|
||||
nx, ny := cx+dx8[k], cy+dy8[k]
|
||||
if nx < 0 || ny < 0 || nx >= g.W || ny >= g.H {
|
||||
continue
|
||||
}
|
||||
ni := int32(ny*g.W + nx)
|
||||
if g.closed[ni] {
|
||||
continue
|
||||
}
|
||||
g.closed[ni] = true
|
||||
if h[ni] <= celev {
|
||||
h[ni] = celev + epsilon*(0.5+hash01(g.seed, ni))
|
||||
g.fifo = append(g.fifo, ni)
|
||||
} else {
|
||||
g.pq.push(h[ni], ni)
|
||||
}
|
||||
}
|
||||
// Compact the FIFO occasionally so it does not grow without bound over a whole flood.
|
||||
if head > n/2 {
|
||||
g.fifo = append(g.fifo[:0], g.fifo[head:]...)
|
||||
head = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Grid) isOutlet(i int) bool {
|
||||
if g.Base != nil && g.Base[i] {
|
||||
return true
|
||||
}
|
||||
x, y := i%g.W, i/g.W
|
||||
return x == 0 || y == 0 || x == g.W-1 || y == g.H-1
|
||||
}
|
||||
|
||||
// ComputeReceivers picks the steepest downhill neighbour of each cell. A base cell, and any cell with no
|
||||
// lower neighbour, receives itself, which makes it a root of the stack.
|
||||
//
|
||||
// Two neighbours of equal steepness are separated by a hash of the cell and the direction rather than by the
|
||||
// fixed order of dx8/dy8. A fixed order always resolves a tie the same way, which on any near-flat surface
|
||||
// puts a systematic preference on one grid axis and shows up as rivers that run straight along it. The
|
||||
// perturbation is a tenth of a percent, so it decides near-ties and nothing else: a neighbour that is
|
||||
// genuinely steeper than another by more than that is still chosen.
|
||||
func (g *Grid) ComputeReceivers(h []float32) {
|
||||
diag := float32(g.CellM * math.Sqrt2)
|
||||
card := float32(g.CellM)
|
||||
field.Rows(g.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < g.W; x++ {
|
||||
i := int32(y*g.W + x)
|
||||
if g.isOutlet(int(i)) {
|
||||
g.Receiver[i] = i
|
||||
g.Length[i] = card
|
||||
continue
|
||||
}
|
||||
best := int32(-1)
|
||||
bestJitter := float32(0)
|
||||
bestLen := card
|
||||
for k := 0; k < 8; k++ {
|
||||
nx, ny := x+dx8[k], y+dy8[k]
|
||||
if nx < 0 || ny < 0 || nx >= g.W || ny >= g.H {
|
||||
continue
|
||||
}
|
||||
ni := int32(ny*g.W + nx)
|
||||
l := card
|
||||
if dx8[k] != 0 && dy8[k] != 0 {
|
||||
l = diag
|
||||
}
|
||||
s := (h[i] - h[ni]) / l
|
||||
if s <= 0 {
|
||||
continue
|
||||
}
|
||||
// The tie-break, not a change of gradient: the comparison is jittered, the slope that
|
||||
// is kept is not, so Length and the stream-power update see the true geometry.
|
||||
sj := s * (1 + 1e-3*(hash01(g.seed, i*8+int32(k))-0.5))
|
||||
if sj > bestJitter {
|
||||
bestJitter, best, bestLen = sj, ni, l
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
g.Receiver[i] = i
|
||||
g.Length[i] = card
|
||||
} else {
|
||||
g.Receiver[i] = best
|
||||
g.Length[i] = bestLen
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BuildStack orders every node after its receiver, by counting donors into a CSR list and then walking it
|
||||
// depth-first from the roots. O(n), no recursion, and the order is fully determined by the receiver array,
|
||||
// so it does not vary between runs.
|
||||
func (g *Grid) BuildStack() {
|
||||
n := g.W * g.H
|
||||
for i := 0; i <= n; i++ {
|
||||
g.donorOff[i] = 0
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
r := g.Receiver[i]
|
||||
if int(r) != i {
|
||||
g.donorOff[r+1]++
|
||||
}
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
g.donorOff[i+1] += g.donorOff[i]
|
||||
}
|
||||
cursor := g.scratchInt32()
|
||||
copy(cursor, g.donorOff[:n])
|
||||
for i := 0; i < n; i++ {
|
||||
r := g.Receiver[i]
|
||||
if int(r) != i {
|
||||
g.donorList[cursor[r]] = int32(i)
|
||||
cursor[r]++
|
||||
}
|
||||
}
|
||||
|
||||
g.Stack = g.Stack[:0]
|
||||
for i := 0; i < n; i++ {
|
||||
if int(g.Receiver[i]) == i {
|
||||
g.Stack = append(g.Stack, int32(i))
|
||||
}
|
||||
}
|
||||
// Depth-first: everything already in the stack expands its donors, which land after it.
|
||||
for read := 0; read < len(g.Stack); read++ {
|
||||
c := g.Stack[read]
|
||||
for d := g.donorOff[c]; d < g.donorOff[c+1]; d++ {
|
||||
g.Stack = append(g.Stack, g.donorList[d])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scratchInt32 reuses the float32 scratch as int32 storage; same width, and it saves a 12 MB allocation per
|
||||
// step at the geology grid.
|
||||
func (g *Grid) scratchInt32() []int32 {
|
||||
if cap(g.cursor) < g.W*g.H {
|
||||
g.cursor = make([]int32, g.W*g.H)
|
||||
}
|
||||
return g.cursor[:g.W*g.H]
|
||||
}
|
||||
|
||||
// Accumulate sums drainage area down the stack in reverse, so every node has collected its whole upstream
|
||||
// catchment before its receiver is reached.
|
||||
func (g *Grid) Accumulate() {
|
||||
cell := float32(g.CellM * g.CellM)
|
||||
for i := range g.Area {
|
||||
g.Area[i] = cell
|
||||
}
|
||||
for k := len(g.Stack) - 1; k >= 0; k-- {
|
||||
i := g.Stack[k]
|
||||
r := g.Receiver[i]
|
||||
if r != i {
|
||||
g.Area[r] += g.Area[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StreamPower is the implicit update, walked up the stack. uplift is in metres per year and k is the local
|
||||
// erodibility; either may be nil for a uniform value.
|
||||
func (g *Grid) StreamPower(h []float32, uplift, k []float32, p Params) {
|
||||
dt := p.DtYr
|
||||
linear := math.Abs(p.N-1) < 1e-9
|
||||
for _, i := range g.Stack {
|
||||
if g.fixed[i] {
|
||||
continue // base level is fixed: no uplift, no erosion
|
||||
}
|
||||
u := 0.0
|
||||
if uplift != nil {
|
||||
u = float64(uplift[i])
|
||||
}
|
||||
r := g.Receiver[i]
|
||||
if r == i {
|
||||
// A local minimum that the last flood has not reached yet. It still rises: skipping uplift here
|
||||
// freezes exactly the cells that differential uplift is busy pushing up, which quietly removes
|
||||
// the basins from the landscape between floods.
|
||||
h[i] += float32(dt * u)
|
||||
continue
|
||||
}
|
||||
kk := p.K
|
||||
if k != nil {
|
||||
kk *= float64(k[i])
|
||||
}
|
||||
hr := float64(h[r])
|
||||
hi := float64(h[i]) + dt*u
|
||||
area := float64(g.Area[i])
|
||||
if p.CriticalAreaM2 > 0 && area < p.CriticalAreaM2 {
|
||||
// Below the channel head, incision is tapered rather than switched off. Switching it off
|
||||
// entirely is what broke: the material had nowhere to go, because the only remaining transport
|
||||
// was landsliding, which caps slope but not height, and the map grew until 22 % of it clipped
|
||||
// the elevation range. A taper suppresses the fine dissection that makes lowlands look like
|
||||
// small mountains while still letting the hillslope shed its uplift into the network.
|
||||
kk *= math.Pow(area/p.CriticalAreaM2, p.ChannelTaper)
|
||||
}
|
||||
a := math.Pow(area, p.M)
|
||||
l := float64(g.Length[i])
|
||||
|
||||
var next float64
|
||||
if linear {
|
||||
f := kk * dt * a / l
|
||||
next = (hi + f*hr) / (1 + f)
|
||||
} else {
|
||||
next = newtonStreamPower(hi, hr, kk*dt*a, l, p.N)
|
||||
}
|
||||
// A node may never fall below what it drains into; the implicit form only guarantees that while
|
||||
// uplift has not raised the receiver past it.
|
||||
if next < hr {
|
||||
next = hr
|
||||
}
|
||||
h[i] = float32(next)
|
||||
}
|
||||
}
|
||||
|
||||
// newtonStreamPower solves h - hi + c*((h-hr)/l)^n = 0 for n != 1. Five iterations from the linear answer is
|
||||
// comfortably enough at the exponents anyone actually uses; it is here so the manifest's n is not a lie.
|
||||
func newtonStreamPower(hi, hr, c, l, n float64) float64 {
|
||||
f := c / l
|
||||
h := (hi + f*hr) / (1 + f) // the n = 1 answer, as a starting point
|
||||
for iter := 0; iter < 5; iter++ {
|
||||
d := h - hr
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
s := d / l
|
||||
fx := h - hi + c*math.Pow(s, n)/1
|
||||
dfx := 1 + c*n*math.Pow(s, n-1)/l
|
||||
if dfx == 0 {
|
||||
break
|
||||
}
|
||||
step := fx / dfx
|
||||
h -= step
|
||||
if math.Abs(step) < 1e-6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if h < hr {
|
||||
h = hr
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Diffuse is hillslope diffusion, which rounds the divides and stops every channel head from being a needle.
|
||||
//
|
||||
// Explicit five-point diffusion is stable only while D*dt/dx² <= 0.25, and the defaults sit above that
|
||||
// (0.02 m²/yr at dt 1500 on 8 m cells is 0.47), so it sub-steps rather than quietly going unstable. This is
|
||||
// the sort of thing that shows up as a checkerboard three thousand steps in.
|
||||
func (g *Grid) Diffuse(h []float32, d, dt float64) {
|
||||
if d <= 0 || dt <= 0 {
|
||||
return
|
||||
}
|
||||
dx2 := g.CellM * g.CellM
|
||||
total := d * dt / dx2
|
||||
sub := int(math.Ceil(total / 0.2))
|
||||
if sub < 1 {
|
||||
sub = 1
|
||||
}
|
||||
alpha := float32(total / float64(sub))
|
||||
src := h
|
||||
tmp := g.scratch[:len(h)]
|
||||
for s := 0; s < sub; s++ {
|
||||
field.Rows(g.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < g.W; x++ {
|
||||
i := y*g.W + x
|
||||
if g.fixed[i] {
|
||||
tmp[i] = src[i]
|
||||
continue
|
||||
}
|
||||
c := src[i]
|
||||
lap := clampAt(src, g.W, g.H, x-1, y) + clampAt(src, g.W, g.H, x+1, y) +
|
||||
clampAt(src, g.W, g.H, x, y-1) + clampAt(src, g.W, g.H, x, y+1) - 4*c
|
||||
tmp[i] = c + alpha*lap
|
||||
}
|
||||
}
|
||||
})
|
||||
copy(src, tmp)
|
||||
}
|
||||
}
|
||||
|
||||
func clampAt(a []float32, w, h, x, y int) float32 {
|
||||
if x < 0 {
|
||||
x = 0
|
||||
} else if x >= w {
|
||||
x = w - 1
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
} else if y >= h {
|
||||
y = h - 1
|
||||
}
|
||||
return a[y*w+x]
|
||||
}
|
||||
|
||||
// Run is the whole solve. Progress is reported through log, which is what a five-minute budget needs to be
|
||||
// steerable: a run that is going wrong should say so at step 500, not at the end.
|
||||
func (g *Grid) Run(h []float32, uplift, k []float32, p Params, log func(step int, total int, elapsedPct float64)) {
|
||||
fill := p.FillEvery
|
||||
if fill < 1 {
|
||||
fill = 1
|
||||
}
|
||||
for step := 0; step < p.Steps; step++ {
|
||||
if step%fill == 0 {
|
||||
g.FillDepressions(h, 1e-3)
|
||||
}
|
||||
g.ComputeReceivers(h)
|
||||
g.BuildStack()
|
||||
g.Accumulate()
|
||||
g.StreamPower(h, uplift, k, p)
|
||||
if p.CriticalSlope > 0 {
|
||||
// The clamp still runs, and it still has to: a belt rising at millimetres a year asks for slopes
|
||||
// no bounded-flux transport law can hold, which is a fact about the forcing and not about the
|
||||
// scheme. What changes is the order and who gets the last word. The clamp cuts along the eight
|
||||
// D8 directions and leaves grid-aligned pyramid faces; nonlinear diffusion then runs over the
|
||||
// result with a symmetric five-point stencil and rounds them off before the next step sees them.
|
||||
//
|
||||
// Running the clamp only once at the end was tried and is worse: a thousand steps of unclamped
|
||||
// growth arrive at it together, so it cuts deeply, and nothing runs afterwards to soften what it
|
||||
// cut. The facets came back in the summits. Little and often, with diffusion last, is what keeps
|
||||
// the constraint without printing the stencil.
|
||||
if p.TalusSlope > 0 && p.ThermalEvery > 0 && step%p.ThermalEvery == 0 {
|
||||
g.ClampToRepose(h, p.TalusSlope)
|
||||
}
|
||||
g.DiffuseNonlinear(h, p.Diffusion, p.CriticalSlope, p.SlopeCap, p.DtYr, p.MaxHillslopeSub)
|
||||
} else {
|
||||
g.Diffuse(h, p.Diffusion, p.DtYr)
|
||||
if p.TalusSlope > 0 && p.ThermalEvery > 0 && step%p.ThermalEvery == 0 {
|
||||
// The constraint first, which actually binds, then the transport, which puts scree at the
|
||||
// foot of what the constraint cut.
|
||||
g.ClampToRepose(h, p.TalusSlope)
|
||||
if p.ThermalPasses > 0 {
|
||||
thermal.Apply(h, g.W, g.H, g.CellM, p.TalusSlope, p.ThermalPasses, g.fixed, g.scratch)
|
||||
}
|
||||
}
|
||||
}
|
||||
if log != nil && p.Steps >= 10 && step%(p.Steps/10) == 0 {
|
||||
log(step, p.Steps, float64(step)/float64(p.Steps)*100)
|
||||
}
|
||||
}
|
||||
// One last fill so the result has no closed pits to hand to the detail passes, and one last routing so
|
||||
// Area and Receiver describe the surface that is actually returned.
|
||||
g.FillDepressions(h, 1e-3)
|
||||
g.ComputeReceivers(h)
|
||||
g.BuildStack()
|
||||
g.Accumulate()
|
||||
}
|
||||
Reference in New Issue
Block a user