FieldLocalWetnessLayer = {}

FieldLocalWetnessLayer.VERSION = 2
FieldLocalWetnessLayer.NUM_CHANNELS = 8
FieldLocalWetnessLayer.MAX_VALUE = 255
FieldLocalWetnessLayer.SAVE_FILE = "mudSystemLocalWetness.grle"
FieldLocalWetnessLayer.META_FILE = "mudSystemLocalWetness.xml"
FieldLocalWetnessLayer.META_ROOT = "mudSystemLocalWetness"

-- The persisted GRLE covers the whole map at the same 1024x1024 resolution as
-- CropDiseases' cropThreatMask.grle. Hydrology runs on a separate, coarser grid
-- and expands each calculated cell into its corresponding GRLE rectangle.
FieldLocalWetnessLayer.STORAGE_MAP_SIZE = 1024
FieldLocalWetnessLayer.HYDRO_CELL_METERS = 4
FieldLocalWetnessLayer.MIN_GRID_SIZE = 64
FieldLocalWetnessLayer.MAX_GRID_SIZE = 256

FieldLocalWetnessLayer.SIMULATION_STEP_HOURS = 0.25
-- A hydrology pass touches every cell twice. Spread it across rendered frames
-- so accelerated time never puts a complete 65k-cell pass into one frame.
FieldLocalWetnessLayer.SIMULATION_CELLS_PER_FRAME = 2048
FieldLocalWetnessLayer.PDA_SIMULATION_CELLS_PER_FRAME = 4096
FieldLocalWetnessLayer.SCAN_CELLS_PER_FRAME = 256
FieldLocalWetnessLayer.FLUSH_ROWS_PER_FRAME = 1
FieldLocalWetnessLayer.FLUSH_CELLS_PER_FRAME = 64
FieldLocalWetnessLayer.PDA_FLUSH_CELLS_PER_FRAME = 128

FieldLocalWetnessLayer.map = nil
FieldLocalWetnessLayer.mapSize = 0
FieldLocalWetnessLayer.gridSize = 0
FieldLocalWetnessLayer.cellMeters = 16
FieldLocalWetnessLayer.terrainSize = 2048
FieldLocalWetnessLayer.minCoord = -1024
FieldLocalWetnessLayer.cells = {}
FieldLocalWetnessLayer.encoded = {}
FieldLocalWetnessLayer.pendingEncoded = {}
FieldLocalWetnessLayer.dirtyRows = {}
FieldLocalWetnessLayer._dirtyRowCursor = {}
FieldLocalWetnessLayer._flushRowCursor = 0
FieldLocalWetnessLayer.scan = nil
FieldLocalWetnessLayer.ready = false
FieldLocalWetnessLayer.isNew = false
FieldLocalWetnessLayer.simulationTimeMs = nil
FieldLocalWetnessLayer.lastSavedTimeMs = nil
FieldLocalWetnessLayer.forecast = {}
FieldLocalWetnessLayer._registeredWithSyncer = false
FieldLocalWetnessLayer._lastForecastRefreshMs = 0
FieldLocalWetnessLayer._lastAverage = 0
FieldLocalWetnessLayer._lastRunoff = 0
FieldLocalWetnessLayer._lastRain = 0
FieldLocalWetnessLayer.geometryReady = false
FieldLocalWetnessLayer._incomingBuffer = {}
FieldLocalWetnessLayer._nextMoistureBuffer = {}
FieldLocalWetnessLayer._finalMoistureBuffer = {}
FieldLocalWetnessLayer._encodedResultBuffer = {}
FieldLocalWetnessLayer._incomingGeneration = {}
FieldLocalWetnessLayer._simulationGeneration = 0
FieldLocalWetnessLayer._simulationJob = nil

local function flwlClamp(value, minimum, maximum)
    value = tonumber(value) or 0
    if value < minimum then return minimum end
    if value > maximum then return maximum end
    return value
end

local function flwlLerp(a, b, t)
    return a + (b - a) * flwlClamp(t, 0, 1)
end

local function flwlSmoothstep(edge0, edge1, value)
    local t = flwlClamp((value - edge0) / math.max(0.0001, edge1 - edge0), 0, 1)
    return t * t * (3 - 2 * t)
end

local function flwlTerrainSize()
    if tonumber(FieldLocalWetnessLayer._terrainSizeOverride) ~= nil then
        return tonumber(FieldLocalWetnessLayer._terrainSizeOverride)
    end
    if g_currentMission ~= nil and tonumber(g_currentMission.terrainSize) ~= nil then
        return tonumber(g_currentMission.terrainSize)
    end
    if tonumber(g_terrainSize) ~= nil then
        return tonumber(g_terrainSize)
    end
    return 2048
end

local function flwlNextPowerOfTwo(value)
    local result = 1
    while result < value do
        result = result * 2
    end
    return result
end

local function flwlAbsoluteGameTimeMs()
    local env = g_currentMission ~= nil and g_currentMission.environment or nil
    if env == nil then
        return g_time or 0
    end

    local day = tonumber(env.currentMonotonicDay) or tonumber(env.currentDay) or 0
    local dayTime = tonumber(env.dayTime) or 0
    return day * 86400000 + flwlClamp(dayTime, 0, 86400000)
end

local function flwlTerrainHeight(x, z)
    local terrainNode = g_terrainNode
    if (terrainNode == nil or terrainNode == 0) and g_currentMission ~= nil then
        terrainNode = g_currentMission.terrainRootNode
    end
    if terrainNode == nil or terrainNode == 0 or getTerrainHeightAtWorldPos == nil then
        return 0
    end
    return getTerrainHeightAtWorldPos(terrainNode, x, 0, z) or 0
end

local function flwlSaveDirectory()
    local missionInfo = g_currentMission ~= nil and g_currentMission.missionInfo or nil
    return missionInfo ~= nil and missionInfo.savegameDirectory or nil
end

local function flwlWeather()
    local environment = g_currentMission ~= nil and g_currentMission.environment or nil
    return environment ~= nil and environment.weather or nil
end

local function flwlActivePrecipitation(value)
    value = flwlClamp(value, 0, 1)
    if value <= 0.01 then
        return 0
    end
    return math.max(value, 0.28 + flwlSmoothstep(0.02, 0.55, value) * 0.62)
end

local function flwlCurrentWeatherState()
    local weather = flwlWeather()
    if weather == nil then
        return 0, 0, 0, 15, 0.25, 0
    end

    local rain = weather.getRainFallScale ~= nil and weather:getRainFallScale() or 0
    local snow = weather.getSnowFallScale ~= nil and weather:getSnowFallScale() or 0
    local hail = weather.getHailFallScale ~= nil and weather:getHailFallScale() or 0
    local temperature = weather.getCurrentTemperature ~= nil and weather:getCurrentTemperature() or 15
    local cloud = 0.25
    if weather.cloudUpdater ~= nil and weather.cloudUpdater.currentClouds ~= nil then
        local values = weather.cloudUpdater.currentClouds
        cloud = math.max(
            tonumber(values.precipitation) or 0,
            flwlClamp((tonumber(values.densityScale) or 0) * 35, 0, 1),
            flwlClamp((tonumber(values.cirrusCoverage) or 0) * 1.5, 0, 1)
        )
    end

    local wind = 0
    if weather.windUpdater ~= nil and weather.windUpdater.getCurrentValues ~= nil then
        local _, _, speed = weather.windUpdater:getCurrentValues()
        wind = tonumber(speed) or 0
    end

    return flwlActivePrecipitation(rain),
        flwlActivePrecipitation(snow),
        flwlActivePrecipitation(hail),
        tonumber(temperature) or 15,
        flwlClamp(cloud, 0, 1),
        math.max(0, wind)
end

local function flwlForecastStateAt(absoluteMs)
    local weather = flwlWeather()
    if weather == nil or weather.forecastItems == nil then
        return flwlCurrentWeatherState()
    end

    -- For the live half-hour use the real blended precipitation/cloud/wind
    -- values. Forecast presets describe the whole weather object and would
    -- otherwise make transition periods unrealistically abrupt.
    if math.abs(flwlAbsoluteGameTimeMs() - absoluteMs) <= 1800000 then
        return flwlCurrentWeatherState()
    end

    local selected = nil
    for _, item in ipairs(weather.forecastItems) do
        local startMs = (tonumber(item.startDay) or 0) * 86400000
            + (tonumber(item.startDayTime) or 0)
        local finishMs = startMs + (tonumber(item.duration) or 0)
        if absoluteMs >= startMs and absoluteMs < finishMs then
            selected = item
            break
        end
    end

    if selected == nil or weather.getForecastInstanceVariation == nil
        or weather.getWeatherObjectByIndex == nil then
        return flwlCurrentWeatherState()
    end

    local variation = weather:getForecastInstanceVariation(selected)
    local object = weather:getWeatherObjectByIndex(selected.season, selected.objectIndex)
    if variation == nil or object == nil then
        return flwlCurrentWeatherState()
    end

    local rain, snow, hail = 0, 0, 0
    if variation.rain ~= nil then
        rain = tonumber(variation.rain.rainfallScale) or 0
        snow = tonumber(variation.rain.snowfallScale) or 0
        hail = tonumber(variation.rain.hailfallScale) or 0
    end

    local dayTime = absoluteMs % 86400000
    local dayFraction = dayTime / 86400000
    local minTemperature = tonumber(variation.minTemperature) or 10
    local maxTemperature = tonumber(variation.maxTemperature) or minTemperature
    local halfRange = 0.5 * (maxTemperature - minTemperature)
    local temperature = halfRange * math.sin(6.283185307179586 * dayFraction - 2.5)
        + minTemperature + halfRange

    local cloud = 0.25
    if variation.clouds ~= nil then
        cloud = math.max(
            tonumber(variation.clouds.precipitation) or 0,
            flwlClamp((tonumber(variation.clouds.densityScale) or 0) * 35, 0, 1),
            flwlClamp((tonumber(variation.clouds.cirrusCoverage) or 0) * 1.5, 0, 1)
        )
    end

    local wind = variation.wind ~= nil and tonumber(variation.wind.windVelocity) or 0
    return flwlActivePrecipitation(rain),
        flwlActivePrecipitation(snow),
        flwlActivePrecipitation(hail),
        temperature,
        flwlClamp(cloud, 0, 1),
        math.max(0, wind or 0)
end

local function flwlIndex(ix, iz, size)
    return iz * size + ix + 1
end

local function flwlSetMapRect(map, startX, startZ, width, height, value)
    if map == nil or map == 0 or setBitVectorMapParallelogram == nil then
        return
    end
    setBitVectorMapParallelogram(
        map,
        startX,
        startZ,
        width,
        0,
        0,
        height,
        0,
        FieldLocalWetnessLayer.NUM_CHANNELS,
        value
    )
end

function FieldLocalWetnessLayer:getExpectedMapSize()
    return self.STORAGE_MAP_SIZE
end

function FieldLocalWetnessLayer:getExpectedGridSize(terrainSize)
    terrainSize = tonumber(terrainSize) or flwlTerrainSize()
    local requested = math.ceil(terrainSize / self.HYDRO_CELL_METERS)
    return flwlClamp(
        flwlNextPowerOfTwo(requested),
        self.MIN_GRID_SIZE,
        self.MAX_GRID_SIZE
    )
end

function FieldLocalWetnessLayer:refreshTerrainGeometry(candidateSize)
    local terrainSize = nil
    local missionSize = g_currentMission ~= nil
        and tonumber(g_currentMission.terrainSize) or nil
    if missionSize ~= nil and missionSize >= 32 then
        terrainSize = missionSize
    end

    local candidate = tonumber(candidateSize)
    if terrainSize == nil and candidate ~= nil and candidate >= 32 then
        terrainSize = candidate
    end

    local terrainNode = g_terrainNode
    if (terrainNode == nil or terrainNode == 0) and g_currentMission ~= nil then
        terrainNode = g_currentMission.terrainRootNode
    end
    if terrainSize == nil and terrainNode ~= nil and terrainNode ~= 0
        and getTerrainSize ~= nil then
        local ok, detectedSize = pcall(getTerrainSize, terrainNode)
        detectedSize = ok and tonumber(detectedSize) or nil
        if detectedSize ~= nil and detectedSize >= 32 then
            terrainSize = detectedSize
        end
    end

    local globalSize = tonumber(g_terrainSize)
    if terrainSize == nil and globalSize ~= nil and globalSize >= 32 then
        terrainSize = globalSize
    end
    if terrainSize == nil then
        return false, false
    end

    local gridSize = self:getExpectedGridSize(terrainSize)
    local changed = self.geometryReady ~= true
        or math.abs((tonumber(self.terrainSize) or 0) - terrainSize) > 0.01
        or tonumber(self.gridSize) ~= gridSize

    self._terrainSizeOverride = terrainSize
    self.terrainSize = terrainSize
    self.minCoord = -terrainSize * 0.5
    self.gridSize = gridSize
    self.cellMeters = terrainSize / gridSize
    self.geometryReady = true

    if changed then
        Logging.info(
            "[FieldLocalWetness] Terrain geometry ready: %.0fx%.0fm, hydrology %dx%d, cell %.2fm",
            terrainSize,
            terrainSize,
            gridSize,
            gridSize,
            self.cellMeters
        )
    end
    return true, changed
end

function FieldLocalWetnessLayer:getWorldIndex(x, z)
    local normalizedX = flwlClamp(
        ((tonumber(x) or 0) - self.minCoord) / self.terrainSize,
        0,
        0.999999
    )
    local normalizedZ = flwlClamp(
        ((tonumber(z) or 0) - self.minCoord) / self.terrainSize,
        0,
        0.999999
    )
    local gridX = flwlClamp(math.floor(normalizedX * self.gridSize), 0, self.gridSize - 1)
    local gridZ = flwlClamp(math.floor(normalizedZ * self.gridSize), 0, self.gridSize - 1)
    local mapX = flwlClamp(math.floor(normalizedX * self.mapSize), 0, self.mapSize - 1)
    local mapZ = flwlClamp(math.floor(normalizedZ * self.mapSize), 0, self.mapSize - 1)
    return gridX, gridZ, flwlIndex(gridX, gridZ, self.gridSize), mapX, mapZ
end

function FieldLocalWetnessLayer:getRawMapValue(ix, iz)
    if self.map == nil or self.map == 0 or getBitVectorMapPoint == nil then
        return 0
    end
    return getBitVectorMapPoint(self.map, ix, iz, 0, self.NUM_CHANNELS) or 0
end

function FieldLocalWetnessLayer:createEmptyMap(size)
    if self.map ~= nil and self.map ~= 0 then
        delete(self.map)
    end
    self.map = createBitVectorMap("mudSystemLocalWetness")
    loadBitVectorMapNew(self.map, size, size, self.NUM_CHANNELS, false)
    self.isNew = true
end

function FieldLocalWetnessLayer:loadMapFile()
    local expectedSize = self:getExpectedMapSize()
    local directory = flwlSaveDirectory()
    local filename = directory ~= nil and (directory .. "/" .. self.SAVE_FILE) or nil

    self.map = createBitVectorMap("mudSystemLocalWetness")
    self.isNew = true
    if g_server ~= nil and filename ~= nil and fileExists(filename) then
        if loadBitVectorMapFromFile(self.map, filename, self.NUM_CHANNELS) then
            local width, height = getBitVectorMapSize(self.map)
            local channels = getBitVectorMapNumChannels ~= nil
                and getBitVectorMapNumChannels(self.map) or self.NUM_CHANNELS
            if width == expectedSize and height == expectedSize
                and channels == self.NUM_CHANNELS then
                self.isNew = false
            else
                Logging.warning(
                    "[FieldLocalWetness] Stored GRLE resolution %sx%s is incompatible with %sx%s; creating a new moisture layer",
                    tostring(width),
                    tostring(height),
                    tostring(expectedSize),
                    tostring(expectedSize)
                )
                self:createEmptyMap(expectedSize)
            end
        else
            Logging.warning("[FieldLocalWetness] Could not load '%s'; creating a new moisture layer", filename)
            self:createEmptyMap(expectedSize)
        end
    else
        delete(self.map)
        self.map = nil
        self:createEmptyMap(expectedSize)
    end

    self.mapSize = expectedSize
    self:refreshTerrainGeometry()
end

function FieldLocalWetnessLayer:prepareForTerrain(terrainNode)
    if self.map ~= nil and self.map ~= 0 then
        return
    end
    local terrainSize = nil
    if terrainNode ~= nil and terrainNode ~= 0 and getTerrainSize ~= nil then
        local ok, detectedSize = pcall(getTerrainSize, terrainNode)
        if ok then
            terrainSize = detectedSize
        end
    end
    local geometryReady = self:refreshTerrainGeometry(terrainSize)
    self:loadMapFile()
    self:loadMetadata()
    if self.owner ~= nil and geometryReady then
        self:startTerrainScan()
    end
end

function FieldLocalWetnessLayer:registerWithDensityMapSyncer()
    if self._registeredWithSyncer or self.map == nil or self.map == 0 then
        return
    end
    local syncer = g_currentMission ~= nil and g_currentMission.densityMapSyncer or nil
    if syncer ~= nil and syncer.addDensityMap ~= nil then
        -- Use the mission's normal sync-cell size, just like Precision Farming
        -- value maps. The GRLE pixel size and network chunk size are unrelated.
        syncer:addDensityMap(self.map)
        self._registeredWithSyncer = true
    end
end

function FieldLocalWetnessLayer:loadMetadata()
    self.lastSavedTimeMs = nil
    if g_server == nil then
        return
    end
    local directory = flwlSaveDirectory()
    local filename = directory ~= nil and (directory .. "/" .. self.META_FILE) or nil
    if filename == nil or not fileExists(filename) then
        return
    end

    local xml = loadXMLFile("MudSystemLocalWetness", filename)
    if xml == nil or xml == 0 then
        return
    end
    local root = self.META_ROOT
    local version = getXMLInt(xml, root .. "#version") or 0
    local mapSize = getXMLInt(xml, root .. "#mapSize") or 0
    local gridSize = getXMLInt(xml, root .. "#gridSize") or 0
    if version == self.VERSION and mapSize == self.mapSize
        and gridSize == self.gridSize then
        self.lastSavedTimeMs = getXMLFloat(xml, root .. "#simulationTimeMs")
    end
    delete(xml)
end

function FieldLocalWetnessLayer:startTerrainScan()
    local geometryReady = self:refreshTerrainGeometry()
    if not geometryReady or self.gridSize <= 0 or self.cellMeters < 0.25 then
        self.scan = nil
        self.ready = false
        Logging.warning("[FieldLocalWetness] Terrain scan deferred: real terrain geometry is not ready")
        return false
    end
    self.cells = {}
    self.encoded = {}
    self.pendingEncoded = {}
    self.dirtyRows = {}
    self._dirtyRowCursor = {}
    self._flushRowCursor = 0
    self._simulationJob = nil
    self.ready = false
    self._scanMoistureTotal = 0
    self.scan = {
        stage = "height",
        cursor = 1,
        count = self.gridSize * self.gridSize,
        order = nil
    }
    return true
end

function FieldLocalWetnessLayer:buildHeightCell(index)
    local zeroIndex = index - 1
    local ix = zeroIndex % self.gridSize
    local iz = math.floor(zeroIndex / self.gridSize)
    local x = self.minCoord + (ix + 0.5) * self.cellMeters
    local z = self.minCoord + (iz + 0.5) * self.cellMeters
    local mapStartX = math.floor(ix * self.mapSize / self.gridSize)
    local mapEndX = math.floor((ix + 1) * self.mapSize / self.gridSize)
    local mapStartZ = math.floor(iz * self.mapSize / self.gridSize)
    local mapEndZ = math.floor((iz + 1) * self.mapSize / self.gridSize)
    local sampleWidth = math.max(1, mapEndX - mapStartX)
    local sampleHeight = math.max(1, mapEndZ - mapStartZ)
    local sampleHeights = {}
    local heightSum = 0
    local minimumHeight = math.huge
    local maximumHeight = -math.huge
    local sumXX, sumZZ = 0, 0
    local sumXHeight, sumZHeight = 0, 0
    local centerLocalX = (sampleWidth - 1) * 0.5
    local centerLocalZ = (sampleHeight - 1) * 0.5

    for localZ = 0, sampleHeight - 1 do
        for localX = 0, sampleWidth - 1 do
            local mapX = mapStartX + localX
            local mapZ = mapStartZ + localZ
            local sampleX = self.minCoord + (mapX + 0.5)
                * self.terrainSize / self.mapSize
            local sampleZ = self.minCoord + (mapZ + 0.5)
                * self.terrainSize / self.mapSize
            local height = flwlTerrainHeight(sampleX, sampleZ)
            sampleHeights[#sampleHeights + 1] = height
            heightSum = heightSum + height
            minimumHeight = math.min(minimumHeight, height)
            maximumHeight = math.max(maximumHeight, height)
            local planeX = localX - centerLocalX
            local planeZ = localZ - centerLocalZ
            sumXX = sumXX + planeX * planeX
            sumZZ = sumZZ + planeZ * planeZ
            sumXHeight = sumXHeight + planeX * height
            sumZHeight = sumZHeight + planeZ * height
        end
    end

    local sampleCount = math.max(1, #sampleHeights)
    local averageHeight = heightSum / sampleCount
    local localRelief = math.max(0, maximumHeight - minimumHeight)
    local planeSlopeX = sumXHeight / math.max(0.0001, sumXX)
    local planeSlopeZ = sumZHeight / math.max(0.0001, sumZZ)
    local residuals = {}
    local depressionDepth = 0
    local residualRelief = 0
    for sampleIndex, height in ipairs(sampleHeights) do
        local zeroIndex = sampleIndex - 1
        local localX = zeroIndex % sampleWidth
        local localZ = math.floor(zeroIndex / sampleWidth)
        local predictedHeight = averageHeight
            + planeSlopeX * (localX - centerLocalX)
            + planeSlopeZ * (localZ - centerLocalZ)
        local residual = predictedHeight - height
        residuals[sampleIndex] = residual
        depressionDepth = math.max(depressionDepth, residual)
        residualRelief = math.max(residualRelief, math.abs(residual))
    end
    local microBiasBytes = {}
    for sampleIndex, residual in ipairs(residuals) do
        local normalizedDelta = residual / math.max(0.035, residualRelief)
        local bias = flwlClamp(
            residual * 0.30 + normalizedDelta * 0.055,
            -0.14,
            0.24
        )
        -- Two-centimetre levels are enough for visible one-metre micro-relief while
        -- allowing adjacent pixels to be written as compact GRLE runs.
        local quantizedBias = flwlClamp(math.floor(bias / 0.02 + 0.5), -7, 12)
        local encodedBias = 128 + quantizedBias
        microBiasBytes[sampleIndex] = string.char(encodedBias)
    end
    local raw = self:getRawMapValue(
        math.floor((mapStartX + mapEndX - 1) * 0.5),
        math.floor((mapStartZ + mapEndZ - 1) * 0.5)
    )

    self.cells[index] = {
        ix = ix,
        iz = iz,
        x = x,
        z = z,
        surfaceHeight = averageHeight,
        -- A saved excavated pit must influence the drainage graph even when it
        -- occupies only one of the high-resolution heightmap pixels.
        height = averageHeight - math.min(0.75, depressionDepth * 0.58),
        localRelief = localRelief,
        microDepressionDepth = depressionDepth,
        microBias = table.concat(microBiasBytes),
        microWidth = sampleWidth,
        microHeight = sampleHeight,
        moisture = raw / self.MAX_VALUE,
        retention = 1,
        lowland = 0,
        pocket = 0,
        ridge = 0,
        slope = 0,
        downstream = nil,
        accumulation = 1
    }
    self.encoded[index] = raw
    self.pendingEncoded[index] = raw
end

function FieldLocalWetnessLayer:buildTopologyCell(index, owner)
    local cell = self.cells[index]
    if cell == nil then return end

    local sum, count = 0, 0
    local high = -math.huge
    local low = math.huge
    local bestSlope = 0
    local downstream = nil

    local requestedCellMeters = tonumber(owner ~= nil and owner.cellSize)
        or self.HYDRO_CELL_METERS
    local stride = flwlClamp(
        math.floor(requestedCellMeters / math.max(1, self.cellMeters) + 0.5),
        1,
        4
    )
    for dz = -1, 1 do
        for dx = -1, 1 do
            if dx ~= 0 or dz ~= 0 then
                local nx = cell.ix + dx * stride
                local nz = cell.iz + dz * stride
                if nx >= 0 and nx < self.gridSize and nz >= 0 and nz < self.gridSize then
                    local neighborIndex = flwlIndex(nx, nz, self.gridSize)
                    local neighbor = self.cells[neighborIndex]
                    if neighbor ~= nil then
                        sum = sum + neighbor.height
                        count = count + 1
                        high = math.max(high, neighbor.height)
                        low = math.min(low, neighbor.height)

                        local distance = self.cellMeters * stride
                        if dx ~= 0 and dz ~= 0 then
                            distance = distance * 1.41421356237
                        end
                        local dropSlope = (cell.height - neighbor.height) / math.max(0.1, distance)
                        if dropSlope > bestSlope then
                            bestSlope = dropSlope
                            downstream = neighborIndex
                        end
                    end
                end
            end
        end
    end

    -- D8 routing itself always uses an immediate neighbour. The wider stride
    -- above is only for landform/retention sampling; skipping cells in the
    -- drainage graph would split it into disconnected sub-grids.
    bestSlope = 0
    downstream = nil
    for dz = -1, 1 do
        for dx = -1, 1 do
            if dx ~= 0 or dz ~= 0 then
                local nx = cell.ix + dx
                local nz = cell.iz + dz
                if nx >= 0 and nx < self.gridSize and nz >= 0 and nz < self.gridSize then
                    local neighborIndex = flwlIndex(nx, nz, self.gridSize)
                    local neighbor = self.cells[neighborIndex]
                    if neighbor ~= nil then
                        local distance = self.cellMeters
                        if dx ~= 0 and dz ~= 0 then
                            distance = distance * 1.41421356237
                        end
                        local dropSlope = (cell.height - neighbor.height)
                            / math.max(0.1, distance)
                        if dropSlope > bestSlope then
                            bestSlope = dropSlope
                            downstream = neighborIndex
                        end
                    end
                end
            end
        end
    end

    local average = count > 0 and sum / count or cell.height
    local relief = math.max(0.08, high - low)
    local lowland = flwlClamp((average - cell.height) / relief, 0, 1)
    local ridge = flwlClamp((cell.height - average) / relief, 0, 1)
    local microPocket = flwlSmoothstep(
        0.025,
        0.55,
        tonumber(cell.microDepressionDepth) or 0
    )
    local pocket = downstream == nil and lowland > 0.03 and 1 or lowland * 0.35
    pocket = flwlClamp(math.max(pocket, microPocket * 0.82), 0, 1)
    local slope = flwlClamp(bestSlope / 0.18, 0, 1)

    cell.lowland = lowland
    cell.pocket = pocket
    cell.ridge = ridge
    cell.slope = slope
    cell.downstream = downstream
    cell.accumulation = 1
end

function FieldLocalWetnessLayer:finalizeCell(index, owner)
    local cell = self.cells[index]
    if cell == nil then return end

    local catchment = flwlClamp(
        math.log(1 + math.max(1, cell.accumulation)) / math.log(1 + self.gridSize * 0.8),
        0,
        1
    )
    cell.catchment = catchment

    local lowlandStrength = tonumber(owner ~= nil and owner.lowlandStrength) or 0.55
    local slopeDrainStrength = tonumber(owner ~= nil and owner.slopeDrainStrength) or 0.35
    cell.retention = flwlClamp(
        0.82
            + cell.lowland * (0.48 + lowlandStrength * 0.38)
            + cell.pocket * 0.32
            + catchment * 0.52
            - cell.ridge * 0.28
            - cell.slope * (0.16 + slopeDrainStrength * 0.20),
        0.42,
        2.25
    )

    if self.isNew and g_server ~= nil then
        local base = tonumber(owner ~= nil and owner.baseMoisture) or 0.10
        cell.moisture = flwlClamp(
            base * (0.72 + cell.retention * 0.30)
                + cell.lowland * 0.035
                + catchment * 0.025,
            tonumber(owner ~= nil and owner.minWetness) or 0.04,
            0.42
        )
        self.pendingEncoded[index] = math.floor(cell.moisture * self.MAX_VALUE + 0.5)
        if not self.dirtyRows[cell.iz] then
            self.dirtyRows[cell.iz] = true
            self._dirtyRowCursor[cell.iz] = 0
        end
    elseif g_server == nil then
        -- DensityMapSyncer may fill the layer after the terrain scan. Queries on
        -- clients read the GRLE directly, so this provisional value is harmless.
        cell.moisture = math.max(cell.moisture, tonumber(owner ~= nil and owner.minWetness) or 0.04)
    end
    self._scanMoistureTotal = (self._scanMoistureTotal or 0) + (cell.moisture or 0)
end

function FieldLocalWetnessLayer:updateTerrainScan(owner, maximum)
    local scan = self.scan
    if scan == nil or self.ready then
        return
    end

    local budget = math.max(1, math.floor(tonumber(maximum) or self.SCAN_CELLS_PER_FRAME))
    if scan.stage ~= "height" then
        budget = budget * 4
    end
    local processed = 0
    while processed < budget and not self.ready do
        if scan.stage == "height" then
            self:buildHeightCell(scan.cursor)
            scan.cursor = scan.cursor + 1
            processed = processed + 1
            if scan.cursor > scan.count then
                scan.stage = "topology"
                scan.cursor = 1
            end
        elseif scan.stage == "topology" then
            self:buildTopologyCell(scan.cursor, owner)
            scan.cursor = scan.cursor + 1
            processed = processed + 1
            if scan.cursor > scan.count then
                scan.order = {}
                for index = 1, scan.count do
                    scan.order[index] = index
                end
                table.sort(scan.order, function(a, b)
                    return self.cells[a].height > self.cells[b].height
                end)
                scan.stage = "accumulation"
                scan.cursor = 1
            end
        elseif scan.stage == "accumulation" then
            local index = scan.order[scan.cursor]
            local cell = self.cells[index]
            if cell ~= nil and cell.downstream ~= nil then
                local target = self.cells[cell.downstream]
                if target ~= nil then
                    target.accumulation = target.accumulation + cell.accumulation
                end
            end
            scan.cursor = scan.cursor + 1
            processed = processed + 1
            if scan.cursor > scan.count then
                scan.stage = "finalize"
                scan.cursor = 1
            end
        elseif scan.stage == "finalize" then
            self:finalizeCell(scan.cursor, owner)
            scan.cursor = scan.cursor + 1
            processed = processed + 1
            if scan.cursor > scan.count then
                scan.stage = "done"
                self.ready = true
                self._lastAverage = (self._scanMoistureTotal or 0)
                    / math.max(1, scan.count)
                local now = flwlAbsoluteGameTimeMs()
                self.simulationTimeMs = tonumber(self.lastSavedTimeMs) or now
                if self.simulationTimeMs > now then
                    self.simulationTimeMs = now
                end
                self:refreshForecast()
                if owner ~= nil and owner.debug then
                    print(string.format(
                        "[FieldLocalWetness] GRLE ready: storage=%dx%d hydrology=%dx%d, %.2f m/cell, loaded=%s",
                        self.mapSize,
                        self.mapSize,
                        self.gridSize,
                        self.gridSize,
                        self.cellMeters,
                        tostring(not self.isNew)
                    ))
                end
            end
        else
            self.ready = true
        end
    end
end

function FieldLocalWetnessLayer:refreshForecast()
    local weather = flwlWeather()
    local now = flwlAbsoluteGameTimeMs()
    local summary = {
        generatedAtMs = now,
        horizonHours = 0,
        rainHours = 0,
        snowHours = 0,
        hailHours = 0,
        nextRainHours = math.huge,
        items = 0
    }

    if weather ~= nil and weather.forecastItems ~= nil then
        local latest = now
        for _, item in ipairs(weather.forecastItems) do
            local startMs = (tonumber(item.startDay) or 0) * 86400000
                + (tonumber(item.startDayTime) or 0)
            local finishMs = startMs + (tonumber(item.duration) or 0)
            if finishMs > now then
                local overlapStart = math.max(now, startMs)
                local durationHours = math.max(0, finishMs - overlapStart) / 3600000
                local variation = weather.getForecastInstanceVariation ~= nil
                    and weather:getForecastInstanceVariation(item) or nil
                if variation ~= nil and variation.rain ~= nil then
                    local rain = tonumber(variation.rain.rainfallScale) or 0
                    local snow = tonumber(variation.rain.snowfallScale) or 0
                    local hail = tonumber(variation.rain.hailfallScale) or 0
                    if rain > 0.01 then
                        summary.rainHours = summary.rainHours + durationHours * rain
                        summary.nextRainHours = math.min(
                            summary.nextRainHours,
                            math.max(0, startMs - now) / 3600000
                        )
                    end
                    if snow > 0.01 then
                        summary.snowHours = summary.snowHours + durationHours * snow
                    end
                    if hail > 0.01 then
                        summary.hailHours = summary.hailHours + durationHours * hail
                    end
                end
                latest = math.max(latest, finishMs)
                summary.items = summary.items + 1
            end
        end
        summary.horizonHours = math.max(0, latest - now) / 3600000
    end
    self.forecast = summary
    self._lastForecastRefreshMs = g_time or 0
end

function FieldLocalWetnessLayer:simulateStep(owner, stepHours, sampleTimeMs)
    if not self.ready or g_server == nil then
        return
    end

    local rain, snow, hail, temperature, cloud, wind = flwlForecastStateAt(sampleTimeMs)
    local precipitationRate =
        rain * (tonumber(owner.rainGainPerHour) or 0.22)
        + snow * (tonumber(owner.snowGainPerHour) or 0.045)
        + hail * (tonumber(owner.hailGainPerHour) or 0.09)
    local precipitation = precipitationRate * stepHours

    local temperatureFactor
    if temperature <= 0 then
        temperatureFactor = 0.08
    elseif temperature >= 34 then
        temperatureFactor = 1.45
    else
        temperatureFactor = flwlLerp(0.28, 1.25, temperature / 34)
    end
    local radiationFactor = flwlLerp(1.08, 0.42, cloud)
    local windFactor = flwlClamp(0.92 + wind * 0.035, 0.92, 1.30)
    local rainSuppression = flwlLerp(1, 0.12, flwlClamp(rain + snow * 0.5 + hail * 0.7, 0, 1))
    local potentialDrying = (tonumber(owner.dryLossPerHour) or 0.018)
        * temperatureFactor * radiationFactor * windFactor * rainSuppression * stepHours

    -- Reuse the two 65k-element buffers. Allocating them for every accelerated
    -- time step caused large garbage-collector spikes while sleeping.
    local incoming = self._incomingBuffer or {}
    local nextMoisture = self._nextMoistureBuffer or {}
    self._incomingBuffer = incoming
    self._nextMoistureBuffer = nextMoisture
    local runoffTotal = 0
    local moistureTotal = 0
    local minimum = tonumber(owner.minWetness) or 0.04
    local maximum = tonumber(owner.maxWetness) or 1

    -- Initialize the complete routing buffer before moving any water. A cell may
    -- drain into a neighbour with a larger array index; initializing lazily in
    -- the simulation loop made that target nil and would also erase early inflow
    -- when the target cell was processed later.
    for index = 1, #self.cells do
        incoming[index] = 0
    end

    for index, cell in ipairs(self.cells) do
        local moisture = flwlClamp(cell.moisture, minimum, maximum)
        local retention = flwlClamp(cell.retention, 0.42, 2.25)
        local topographicStore = cell.lowland * 0.08 + cell.pocket * 0.12
            + (cell.catchment or 0) * 0.06
        local fieldCapacity = flwlClamp(0.38 + retention * 0.13 + topographicStore, 0.38, 0.82)
        local saturationStart = flwlClamp(fieldCapacity + 0.13 + topographicStore, 0.54, 0.96)

        local infiltrationFactor = flwlClamp(
            0.88 + retention * 0.15 - cell.slope * 0.16 + cell.lowland * 0.10,
            0.62,
            1.00
        )
        local saturationExcess = flwlSmoothstep(fieldCapacity, 1, moisture)
        local directRunoff = precipitation * saturationExcess
            * flwlClamp(0.20 + cell.slope * 0.48 + cell.ridge * 0.18, 0.08, 0.78)
        local remainingPrecipitation = math.max(0, precipitation - directRunoff)
        local infiltrated = remainingPrecipitation * infiltrationFactor
        local infiltrationExcess = remainingPrecipitation - infiltrated

        local evaporation = potentialDrying
            * flwlClamp(0.30 + moisture * 0.85, 0.25, 1.15)
            / flwlClamp(0.72 + retention * 0.38, 0.65, 1.55)
        local deepDrain = math.max(0, moisture - fieldCapacity)
            * flwlClamp(0.018 + cell.slope * 0.045 + cell.ridge * 0.018, 0.012, 0.085)
            / retention * stepHours

        local updated = moisture + infiltrated - evaporation - deepDrain
        local storedExcess = math.max(0, updated - saturationStart)
        local routeFraction = flwlClamp(
            (0.16 + cell.slope * 0.58 + cell.ridge * 0.18)
                / flwlClamp(retention, 0.55, 1.75),
            0.05,
            0.82
        )
        local routed = directRunoff + infiltrationExcess + storedExcess * routeFraction
        if cell.downstream == nil then
            -- Closed depressions retain most surface water and lose only a
            -- small overflow component to unresolved sub-grid drainage.
            routed = routed * flwlLerp(0.22, 0.05, cell.pocket)
        end

        updated = updated - routed
        nextMoisture[index] = flwlClamp(updated, minimum, maximum)
        if cell.downstream ~= nil and routed > 0 then
            local downstream = tonumber(cell.downstream)
            if downstream ~= nil and downstream >= 1 and downstream <= #self.cells then
                incoming[downstream] = incoming[downstream] + routed
                runoffTotal = runoffTotal + routed
            end
        end
    end

    for index, cell in ipairs(self.cells) do
        local inflow = incoming[index] or 0
        local inflowCapture = flwlClamp(
            0.70 + cell.lowland * 0.25 + cell.pocket * 0.18
                + (cell.catchment or 0) * 0.10,
            0.62,
            1.08
        )
        local moisture = flwlClamp(nextMoisture[index] + inflow * inflowCapture, minimum, maximum)
        cell.moisture = moisture
        moistureTotal = moistureTotal + moisture

        local encoded = math.floor(moisture * self.MAX_VALUE + 0.5)
        if encoded ~= self.pendingEncoded[index] then
            self.pendingEncoded[index] = encoded
            if not self.dirtyRows[cell.iz] then
                self.dirtyRows[cell.iz] = true
                self._dirtyRowCursor[cell.iz] = 0
            end
        end
    end

    self._lastAverage = moistureTotal / math.max(1, #self.cells)
    self._lastRunoff = runoffTotal / math.max(1, #self.cells)
    self._lastRain = precipitationRate
end

function FieldLocalWetnessLayer:startSimulationJob(owner)
    if self._simulationJob ~= nil or not self.ready or g_server == nil then
        return false
    end

    local now = flwlAbsoluteGameTimeMs()
    if owner.enabled ~= true then
        self.simulationTimeMs = now
        return false
    end

    local cursor = self.simulationTimeMs or now
    if now < cursor then
        cursor = now
        self.simulationTimeMs = now
    end
    local remainingHours = math.max(0, now - cursor) / 3600000
    if remainingHours < self.SIMULATION_STEP_HOURS * 0.98 then
        return false
    end

    local stepHours = self.SIMULATION_STEP_HOURS
    -- During a long sleep there is no benefit in replaying dozens of tiny
    -- quarter-hour passes. Three-hour weather samples preserve rain/drying
    -- changes while allowing the layer to catch up in a reasonable time.
    if remainingHours > 12 then
        stepHours = math.min(3, remainingHours)
    elseif remainingHours > 3 then
        stepHours = math.min(1, remainingHours)
    end
    stepHours = math.min(stepHours, remainingHours)

    local sampleTimeMs = cursor + stepHours * 1800000
    local rain, snow, hail, temperature, cloud, wind =
        flwlForecastStateAt(sampleTimeMs)
    local precipitationRate =
        rain * (tonumber(owner.rainGainPerHour) or 0.22)
        + snow * (tonumber(owner.snowGainPerHour) or 0.045)
        + hail * (tonumber(owner.hailGainPerHour) or 0.09)

    local temperatureFactor
    if temperature <= 0 then
        temperatureFactor = 0.08
    elseif temperature >= 34 then
        temperatureFactor = 1.45
    else
        temperatureFactor = flwlLerp(0.28, 1.25, temperature / 34)
    end
    local radiationFactor = flwlLerp(1.08, 0.42, cloud)
    local windFactor = flwlClamp(0.92 + wind * 0.035, 0.92, 1.30)
    local rainSuppression = flwlLerp(
        1,
        0.12,
        flwlClamp(rain + snow * 0.5 + hail * 0.7, 0, 1)
    )

    self._simulationGeneration = (tonumber(self._simulationGeneration) or 0) + 1
    if self._simulationGeneration > 1000000000 then
        self._simulationGeneration = 1
        self._incomingGeneration = {}
    end

    self._simulationJob = {
        stage = "route",
        cursor = 1,
        count = #self.cells,
        generation = self._simulationGeneration,
        stepHours = stepHours,
        targetTimeMs = cursor + stepHours * 3600000,
        precipitationRate = precipitationRate,
        precipitation = precipitationRate * stepHours,
        potentialDrying = (tonumber(owner.dryLossPerHour) or 0.018)
            * temperatureFactor * radiationFactor * windFactor
            * rainSuppression * stepHours,
        minimum = tonumber(owner.minWetness) or 0.04,
        maximum = tonumber(owner.maxWetness) or 1,
        runoffTotal = 0,
        moistureTotal = 0
    }
    return true
end

function FieldLocalWetnessLayer:cancelSimulationJob(resetToCurrentTime)
    self._simulationJob = nil
    if resetToCurrentTime then
        self.simulationTimeMs = flwlAbsoluteGameTimeMs()
    end
end

function FieldLocalWetnessLayer:stepSimulationJob(owner, maximumCells)
    if not self.ready or g_server == nil then
        return false
    end
    if owner.enabled ~= true then
        self:cancelSimulationJob(true)
        return false
    end
    if self._simulationJob == nil and not self:startSimulationJob(owner) then
        return false
    end

    local job = self._simulationJob
    local now = flwlAbsoluteGameTimeMs()
    if job.targetTimeMs > now + 1 then
        self:cancelSimulationJob(true)
        return false
    end

    local budget = math.max(
        1,
        math.floor(tonumber(maximumCells) or self.SIMULATION_CELLS_PER_FRAME)
    )
    local processed = 0
    local incoming = self._incomingBuffer
    local incomingGeneration = self._incomingGeneration
    local nextMoisture = self._nextMoistureBuffer
    local finalMoisture = self._finalMoistureBuffer
    local encodedResult = self._encodedResultBuffer

    while processed < budget and self._simulationJob ~= nil do
        if job.stage == "route" then
            local index = job.cursor
            local cell = self.cells[index]
            if cell ~= nil then
                local moisture = flwlClamp(
                    cell.moisture,
                    job.minimum,
                    job.maximum
                )
                local retention = flwlClamp(cell.retention, 0.42, 2.25)
                local topographicStore = cell.lowland * 0.08
                    + cell.pocket * 0.12
                    + (cell.catchment or 0) * 0.06
                local fieldCapacity = flwlClamp(
                    0.38 + retention * 0.13 + topographicStore,
                    0.38,
                    0.82
                )
                local saturationStart = flwlClamp(
                    fieldCapacity + 0.13 + topographicStore,
                    0.54,
                    0.96
                )
                local infiltrationFactor = flwlClamp(
                    0.88 + retention * 0.15 - cell.slope * 0.16
                        + cell.lowland * 0.10,
                    0.62,
                    1.00
                )
                local saturationExcess = flwlSmoothstep(
                    fieldCapacity,
                    1,
                    moisture
                )
                local directRunoff = job.precipitation * saturationExcess
                    * flwlClamp(
                        0.20 + cell.slope * 0.48 + cell.ridge * 0.18,
                        0.08,
                        0.78
                    )
                local remainingPrecipitation = math.max(
                    0,
                    job.precipitation - directRunoff
                )
                local infiltrated = remainingPrecipitation * infiltrationFactor
                local infiltrationExcess =
                    remainingPrecipitation - infiltrated
                local evaporation = job.potentialDrying
                    * flwlClamp(0.30 + moisture * 0.85, 0.25, 1.15)
                    / flwlClamp(0.72 + retention * 0.38, 0.65, 1.55)
                local deepDrain = math.max(0, moisture - fieldCapacity)
                    * flwlClamp(
                        0.018 + cell.slope * 0.045 + cell.ridge * 0.018,
                        0.012,
                        0.085
                    )
                    / retention * job.stepHours

                local updated =
                    moisture + infiltrated - evaporation - deepDrain
                local storedExcess = math.max(
                    0,
                    updated - saturationStart
                )
                local routeFraction = flwlClamp(
                    (0.16 + cell.slope * 0.58 + cell.ridge * 0.18)
                        / flwlClamp(retention, 0.55, 1.75),
                    0.05,
                    0.82
                )
                local routed = directRunoff + infiltrationExcess
                    + storedExcess * routeFraction
                if cell.downstream == nil then
                    routed = routed
                        * flwlLerp(0.22, 0.05, cell.pocket)
                end

                nextMoisture[index] = flwlClamp(
                    updated - routed,
                    job.minimum,
                    job.maximum
                )
                local downstream = tonumber(cell.downstream)
                if downstream ~= nil and routed > 0
                    and downstream >= 1 and downstream <= job.count then
                    if incomingGeneration[downstream] ~= job.generation then
                        incomingGeneration[downstream] = job.generation
                        incoming[downstream] = routed
                    else
                        incoming[downstream] =
                            (incoming[downstream] or 0) + routed
                    end
                    job.runoffTotal = job.runoffTotal + routed
                end
            end

            job.cursor = job.cursor + 1
            processed = processed + 1
            if job.cursor > job.count then
                job.stage = "apply"
                job.cursor = 1
            end
        elseif job.stage == "apply" then
            local index = job.cursor
            local cell = self.cells[index]
            if cell ~= nil then
                local inflow = incomingGeneration[index] == job.generation
                    and (incoming[index] or 0) or 0
                local inflowCapture = flwlClamp(
                    0.70 + cell.lowland * 0.25 + cell.pocket * 0.18
                        + (cell.catchment or 0) * 0.10,
                    0.62,
                    1.08
                )
                local moisture = flwlClamp(
                    (nextMoisture[index] or cell.moisture)
                        + inflow * inflowCapture,
                    job.minimum,
                    job.maximum
                )
                finalMoisture[index] = moisture
                encodedResult[index] =
                    math.floor(moisture * self.MAX_VALUE + 0.5)
                job.moistureTotal = job.moistureTotal + moisture
            end

            job.cursor = job.cursor + 1
            processed = processed + 1
            if job.cursor > job.count then
                -- The expensive physics was spread across frames. Commit the
                -- already calculated scalar results atomically so a save can
                -- never capture half of one hydrology step.
                for commitIndex, commitCell in ipairs(self.cells) do
                    local moisture =
                        finalMoisture[commitIndex] or commitCell.moisture
                    local encoded = encodedResult[commitIndex]
                        or math.floor(moisture * self.MAX_VALUE + 0.5)
                    commitCell.moisture = moisture
                    if encoded ~= self.pendingEncoded[commitIndex] then
                        self.pendingEncoded[commitIndex] = encoded
                        if not self.dirtyRows[commitCell.iz] then
                            self.dirtyRows[commitCell.iz] = true
                            self._dirtyRowCursor[commitCell.iz] = 0
                        end
                    end
                end

                self._lastAverage =
                    job.moistureTotal / math.max(1, job.count)
                self._lastRunoff =
                    job.runoffTotal / math.max(1, job.count)
                self._lastRain = job.precipitationRate
                self.simulationTimeMs = job.targetTimeMs
                self._simulationJob = nil
                return true
            end
        else
            self._simulationJob = nil
            return false
        end
    end
    return false
end

function FieldLocalWetnessLayer:flushMapCell(ix, iz)
    local index = flwlIndex(ix, iz, self.gridSize)
    local value = self.pendingEncoded[index] or 0
    local mapStartX = math.floor(ix * self.mapSize / self.gridSize)
    local mapEndX = math.floor((ix + 1) * self.mapSize / self.gridSize)
    local mapStartZ = math.floor(iz * self.mapSize / self.gridSize)
    local mapEndZ = math.floor((iz + 1) * self.mapSize / self.gridSize)
    flwlSetMapRect(
        self.map,
        mapStartX,
        mapStartZ,
        math.max(1, mapEndX - mapStartX),
        math.max(1, mapEndZ - mapStartZ),
        value
    )

    local cell = self.cells[index]
    local microBias = cell ~= nil and cell.microBias or nil
    local microWidth = cell ~= nil and tonumber(cell.microWidth) or 0
    local microHeight = cell ~= nil and tonumber(cell.microHeight) or 0
    if microBias ~= nil and microWidth > 0 and microHeight > 0 then
        local baseMoisture = value / self.MAX_VALUE
        local biasScale = 0.25 + baseMoisture * 0.90
        for localZ = 0, microHeight - 1 do
            local localX = 0
            while localX < microWidth do
                local byteIndex = localZ * microWidth + localX + 1
                local encodedBias = string.byte(microBias, byteIndex) or 128
                local bias = (encodedBias - 128) * 0.02
                if math.abs(bias) < 0.012 then
                    localX = localX + 1
                else
                    local adjusted = math.floor(
                        flwlClamp(baseMoisture + bias * biasScale, 0, 1)
                            * self.MAX_VALUE + 0.5
                    )
                    local runEnd = localX + 1
                    while runEnd < microWidth do
                        local nextByte = string.byte(
                            microBias,
                            localZ * microWidth + runEnd + 1
                        ) or 128
                        local nextBias = (nextByte - 128) * 0.02
                        local nextAdjusted = math.floor(
                            flwlClamp(
                                baseMoisture + nextBias * biasScale,
                                0,
                                1
                            ) * self.MAX_VALUE + 0.5
                        )
                        if math.abs(nextBias) < 0.012
                            or nextAdjusted ~= adjusted then
                            break
                        end
                        runEnd = runEnd + 1
                    end
                    flwlSetMapRect(
                        self.map,
                        mapStartX + localX,
                        mapStartZ + localZ,
                        runEnd - localX,
                        1,
                        adjusted
                    )
                    localX = runEnd
                end
            end
        end
    end
    self.encoded[index] = self.pendingEncoded[index]
end

function FieldLocalWetnessLayer:flushDirtyCells(maximumCells)
    if g_server == nil or self.map == nil or self.map == 0
        or self.gridSize <= 0 then
        return
    end

    local maximum = math.max(
        1,
        math.floor(tonumber(maximumCells) or self.FLUSH_CELLS_PER_FRAME)
    )
    local flushed = 0
    local checkedRows = 0
    local iz = flwlClamp(
        math.floor(tonumber(self._flushRowCursor) or 0),
        0,
        math.max(0, self.gridSize - 1)
    )
    while flushed < maximum and checkedRows < self.gridSize do
        if self.dirtyRows[iz] then
            local ix = flwlClamp(
                math.floor(tonumber(self._dirtyRowCursor[iz]) or 0),
                0,
                self.gridSize
            )
            while ix < self.gridSize and flushed < maximum do
                self:flushMapCell(ix, iz)
                ix = ix + 1
                flushed = flushed + 1
            end

            if ix >= self.gridSize then
                self.dirtyRows[iz] = nil
                self._dirtyRowCursor[iz] = nil
                iz = (iz + 1) % self.gridSize
                checkedRows = checkedRows + 1
                self._flushRowCursor = iz
            else
                self._dirtyRowCursor[iz] = ix
                self._flushRowCursor = iz
                break
            end
        else
            iz = (iz + 1) % self.gridSize
            checkedRows = checkedRows + 1
            self._flushRowCursor = iz
        end
    end
end

function FieldLocalWetnessLayer:flushRows(maximumRows)
    if g_server == nil or self.map == nil or self.map == 0 then
        return
    end

    local maximum = math.max(
        1,
        math.floor(tonumber(maximumRows) or self.FLUSH_ROWS_PER_FRAME)
    )
    local flushed = 0
    for iz = 0, self.gridSize - 1 do
        if self.dirtyRows[iz] then
            for ix = 0, self.gridSize - 1 do
                self:flushMapCell(ix, iz)
            end
            self.dirtyRows[iz] = nil
            self._dirtyRowCursor[iz] = nil
            flushed = flushed + 1
            if flushed >= maximum then
                break
            end
        end
    end
end

function FieldLocalWetnessLayer:update(owner, dt)
    if self.map == nil or self.map == 0 then
        return
    end

    if self.geometryReady ~= true then
        local geometryReady = self:refreshTerrainGeometry()
        if not geometryReady then
            return
        end
        self:loadMetadata()
        self:startTerrainScan()
    end

    self:updateTerrainScan(owner, owner.scanCellsPerUpdate or self.SCAN_CELLS_PER_FRAME)
    local pdaWetnessPageActive = FieldWetnessMapFrame ~= nil
        and FieldWetnessMapFrame.getIsPageActive ~= nil
        and FieldWetnessMapFrame:getIsPageActive()
    self:flushDirtyCells(
        pdaWetnessPageActive
            and self.PDA_FLUSH_CELLS_PER_FRAME
            or self.FLUSH_CELLS_PER_FRAME
    )
    if not self.ready then
        return
    end

    if (g_time or 0) - (self._lastForecastRefreshMs or 0) > 30000 then
        self:refreshForecast()
    end

    if g_server == nil then
        return
    end

    self:stepSimulationJob(
        owner,
        pdaWetnessPageActive
            and self.PDA_SIMULATION_CELLS_PER_FRAME
            or self.SIMULATION_CELLS_PER_FRAME
    )
end

function FieldLocalWetnessLayer:getCellAt(x, z)
    if self.gridSize <= 0 then
        return nil
    end
    local _, _, index = self:getWorldIndex(x, z)
    return self.cells[index]
end

function FieldLocalWetnessLayer:getWetnessAt(x, z, owner)
    if self.map == nil or self.map == 0 or self.mapSize <= 0 or self.gridSize <= 0 then
        return nil, nil
    end

    local _, _, index, mapX, mapZ = self:getWorldIndex(x, z)
    local cell = self.cells[index]
    local minimum = tonumber(owner ~= nil and owner.minWetness) or 0.04
    if g_server == nil then
        local raw = self:getRawMapValue(mapX, mapZ)
        local moisture = raw / self.MAX_VALUE
        if raw == 0 and not self.ready then
            moisture = tonumber(owner ~= nil and owner.baseMoisture) or 0.10
        end
        if cell ~= nil then
            cell.moisture = math.max(minimum, moisture)
        end
        return math.max(minimum, moisture), cell
    end

    if cell ~= nil then
        local moisture = flwlClamp(cell.moisture, minimum, 1)
        local microWidth = tonumber(cell.microWidth) or 0
        local microHeight = tonumber(cell.microHeight) or 0
        if cell.microBias ~= nil and microWidth > 0 and microHeight > 0 then
            local mapStartX = math.floor(cell.ix * self.mapSize / self.gridSize)
            local mapStartZ = math.floor(cell.iz * self.mapSize / self.gridSize)
            local localX = flwlClamp(mapX - mapStartX, 0, microWidth - 1)
            local localZ = flwlClamp(mapZ - mapStartZ, 0, microHeight - 1)
            local byteIndex = localZ * microWidth + localX + 1
            local encodedBias = string.byte(cell.microBias, byteIndex) or 128
            local bias = (encodedBias - 128) * 0.02
            moisture = flwlClamp(
                moisture + bias * (0.25 + moisture * 0.90),
                minimum,
                1
            )
        end
        return moisture, cell
    end
    return math.max(minimum, self:getRawMapValue(mapX, mapZ) / self.MAX_VALUE), nil
end

function FieldLocalWetnessLayer:resetMoisture(owner)
    if g_server == nil or not self.ready then
        return false
    end
    self.isNew = true
    for index = 1, #self.cells do
        self:finalizeCell(index, owner)
    end
    self.isNew = false
    self.simulationTimeMs = flwlAbsoluteGameTimeMs()
    return true
end

function FieldLocalWetnessLayer:save()
    if g_server == nil or self.map == nil or self.map == 0 then
        return
    end
    local directory = flwlSaveDirectory()
    if directory == nil then
        return
    end

    -- An unfinished chunked job has not touched the committed cells or GRLE.
    -- Save the last complete hydrology step and its cursor; processing resumes
    -- normally after saving without a large synchronous sleep-time catch-up.

    self:flushRows(self.gridSize)
    saveBitVectorMapToFile(self.map, directory .. "/" .. self.SAVE_FILE)

    local metadataPath = directory .. "/" .. self.META_FILE
    local xml = createXMLFile("MudSystemLocalWetness", metadataPath, self.META_ROOT)
    if xml ~= nil and xml ~= 0 then
        local root = self.META_ROOT
        setXMLInt(xml, root .. "#version", self.VERSION)
        setXMLInt(xml, root .. "#mapSize", self.mapSize)
        setXMLInt(xml, root .. "#gridSize", self.gridSize)
        setXMLFloat(xml, root .. "#cellMeters", self.cellMeters)
        setXMLFloat(xml, root .. "#simulationTimeMs", self.simulationTimeMs or flwlAbsoluteGameTimeMs())
        setXMLFloat(xml, root .. "#averageWetness", self._lastAverage or 0)
        saveXMLFile(xml)
        delete(xml)
    end
end

function FieldLocalWetnessLayer:load(owner)
    self.owner = owner
    self.ready = false
    self._simulationJob = nil
    self._scanMoistureTotal = 0
    self.forecast = {}
    local geometryReady = self:refreshTerrainGeometry()
    if self.map == nil or self.map == 0 then
        self._registeredWithSyncer = false
        self:loadMapFile()
        self:loadMetadata()
        geometryReady = self.geometryReady == true
    end
    if self.map ~= nil and self.map ~= 0 then
        self:registerWithDensityMapSyncer()
        if geometryReady then
            self:startTerrainScan()
        end
    end
end

function FieldLocalWetnessLayer:delete()
    if self.map ~= nil and self.map ~= 0 then
        delete(self.map)
    end
    self.map = nil
    self.mapSize = 0
    self.gridSize = 0
    self.cells = {}
    self.encoded = {}
    self.pendingEncoded = {}
    self.dirtyRows = {}
    self._dirtyRowCursor = {}
    self._flushRowCursor = 0
    self._incomingBuffer = {}
    self._nextMoistureBuffer = {}
    self._finalMoistureBuffer = {}
    self._encodedResultBuffer = {}
    self._incomingGeneration = {}
    self._simulationGeneration = 0
    self._simulationJob = nil
    self.scan = nil
    self.ready = false
    self.geometryReady = false
    self._registeredWithSyncer = false
    self.owner = nil
    self._terrainSizeOverride = nil
end

function FieldLocalWetnessLayer:getStatus()
    local scanText = self.ready and "ready" or "waitingTerrain"
    if not self.ready and self.scan ~= nil then
        scanText = string.format("%s:%d/%d", self.scan.stage, self.scan.cursor, self.scan.count)
    end
    local forecast = self.forecast or {}
    local nextRain = forecast.nextRainHours
    local nextRainText = (nextRain == nil or nextRain == math.huge)
        and "none" or string.format("%.1fh", nextRain)
    local simulationLagHours = self.simulationTimeMs ~= nil
        and math.max(0, flwlAbsoluteGameTimeMs() - self.simulationTimeMs) / 3600000
        or 0
    local simulationJobText = self._simulationJob ~= nil
        and string.format(
            "%s:%d/%d",
            tostring(self._simulationJob.stage),
            tonumber(self._simulationJob.cursor) or 0,
            tonumber(self._simulationJob.count) or 0
        )
        or "idle"
    local dirtyRowCount = 0
    for _ in pairs(self.dirtyRows or {}) do
        dirtyRowCount = dirtyRowCount + 1
    end
    return string.format(
        "grle=%s storage=%dx%d terrain=%.0fm hydro=%dx%d cell=%.2fm scan=%s avg=%.1f%% runoff=%.4f lag=%.2fh sim=%s dirtyRows=%d forecast=%.1fh rainEq=%.1fh nextRain=%s",
        tostring(self.map ~= nil and self.map ~= 0),
        self.mapSize,
        self.mapSize,
        self.geometryReady and self.terrainSize or 0,
        self.gridSize,
        self.gridSize,
        self.cellMeters,
        scanText,
        (self._lastAverage or 0) * 100,
        self._lastRunoff or 0,
        simulationLagHours,
        simulationJobText,
        dirtyRowCount,
        forecast.horizonHours or 0,
        forecast.rainHours or 0,
        nextRainText
    )
end

local function fieldLocalWetnessLayerSaveHook()
    if FieldLocalWetnessLayer ~= nil then
        FieldLocalWetnessLayer:save()
    end
end

local function fieldLocalWetnessTerrainPrepareHook(_, terrainNode)
    if FieldLocalWetnessLayer ~= nil
        and FieldLocalWetnessLayer.prepareForTerrain ~= nil then
        FieldLocalWetnessLayer:prepareForTerrain(terrainNode)
    end
end

local function fieldLocalWetnessDensityMapsAddedHook(syncer)
    if FieldLocalWetnessLayer == nil then
        return
    end

    local geometryReady, geometryChanged =
        FieldLocalWetnessLayer:refreshTerrainGeometry()
    if geometryReady and geometryChanged
        and FieldLocalWetnessLayer.map ~= nil
        and FieldLocalWetnessLayer.map ~= 0 then
        FieldLocalWetnessLayer:loadMetadata()
        if FieldLocalWetnessLayer.owner ~= nil then
            FieldLocalWetnessLayer:startTerrainScan()
        end
    end

    if FieldLocalWetnessLayer.map == nil
        or FieldLocalWetnessLayer.map == 0
        or FieldLocalWetnessLayer._registeredWithSyncer == true then
        return
    end
    if syncer ~= nil and syncer.addDensityMap ~= nil then
        syncer:addDensityMap(FieldLocalWetnessLayer.map)
        FieldLocalWetnessLayer._registeredWithSyncer = true
    end
end

if FSBaseMission ~= nil and FSBaseMission.initTerrain ~= nil
    and Utils ~= nil and Utils.prependedFunction ~= nil
    and _G.__MudSystemLocalWetnessTerrainHooked ~= true then
    FSBaseMission.initTerrain = Utils.prependedFunction(
        FSBaseMission.initTerrain,
        fieldLocalWetnessTerrainPrepareHook
    )
    _G.__MudSystemLocalWetnessTerrainHooked = true
end

if DensityMapSyncer ~= nil and DensityMapSyncer.onDensityMapsAdded ~= nil
    and Utils ~= nil and Utils.prependedFunction ~= nil
    and _G.__MudSystemLocalWetnessSyncHooked ~= true then
    DensityMapSyncer.onDensityMapsAdded = Utils.prependedFunction(
        DensityMapSyncer.onDensityMapsAdded,
        fieldLocalWetnessDensityMapsAddedHook
    )
    _G.__MudSystemLocalWetnessSyncHooked = true
end

if FSBaseMission ~= nil and FSBaseMission.saveSavegame ~= nil
    and Utils ~= nil and Utils.appendedFunction ~= nil
    and _G.__MudSystemLocalWetnessSaveHooked ~= true then
    FSBaseMission.saveSavegame = Utils.appendedFunction(
        FSBaseMission.saveSavegame,
        fieldLocalWetnessLayerSaveHook
    )
    _G.__MudSystemLocalWetnessSaveHooked = true
end
