RoadGarbageSystem = {
    name = "MudSystemPhysics.RoadGarbageSystem",
    path = g_currentModDirectory or "",
    enabled = true,

    models = {
        "assets/garbage/garbage_1.i3d",
        "assets/garbage/garbage_2.i3d"
    },

    SAVE_FILE = "MudSystemRoadGarbage.xml",
    SAVE_ROOT = "mudSystemRoadGarbage",

    -- Intentionally internal: the menu only exposes the mode switch. These
    -- values keep the feature rare and prevent one pile from destroying a
    -- whole vehicle.
    pointCount = 20,
    minimumSpacingM = 45,
    terrainLayerMinWeight = 0.52,
    maximumSlopeDeg = 17,
    triggerRadiusM = 1.15,
    minimumImpactSpeedKph = 3,
    punctureChance = 0.90,
    maximumPuncturesPerObject = 2,
    impactWindowMs = 4000,
    removalDelayMs = 15000,
    respawnDelayMs = 180000,
    scatterRetryDelayMs = 60000,
    collisionUpdateMs = 100,
    scatterAttemptsPerFrame = 20,
    scatterAttemptsPerObject = 700
}

RoadGarbageSystem.objects = {}
RoadGarbageSystem.nextObjectId = 1
RoadGarbageSystem.allowedLayerIds = {}

local function roadGarbageNodeExists(node)
    if type(node) ~= "number" or node == 0 then
        return false
    end
    if entityExists == nil then
        return true
    end
    local ok, exists = pcall(entityExists, node)
    return ok and exists == true
end

local function roadGarbageTerrainNode()
    if g_currentMission ~= nil then
        return g_currentMission.terrainRootNode
            or g_currentMission.terrainRootNodeId
            or g_terrainNode
    end
    return g_terrainNode
end

local function roadGarbageTerrainY(x, z)
    local terrainNode = roadGarbageTerrainNode()
    if terrainNode == nil or getTerrainHeightAtWorldPos == nil then
        return 0
    end
    return getTerrainHeightAtWorldPos(terrainNode, x, 0, z) or 0
end

local function roadGarbageLocalPlayerPosition()
    local player = g_localPlayer
        or (g_currentMission ~= nil and g_currentMission.player or nil)
    if player == nil then
        return nil, nil, nil
    end
    if player.getPosition ~= nil then
        local ok, x, y, z = pcall(player.getPosition, player)
        if ok and x ~= nil and z ~= nil then
            return x, y, z
        end
    end
    if roadGarbageNodeExists(player.rootNode) and getWorldTranslation ~= nil then
        return getWorldTranslation(player.rootNode)
    end
    return nil, nil, nil
end

local function roadGarbageDrawTerrainCircle(object, cacheKey, radius, steps, r, g, b)
    if drawDebugLine == nil then
        return
    end
    steps = math.max(8, math.floor(tonumber(steps) or 16))
    object.debugRingCache = object.debugRingCache or {}
    local points = object.debugRingCache[cacheKey]
    if points == nil then
        points = {}
        for step = 0, steps do
            local angle = step / steps * math.pi * 2
            local pointX = object.x + math.cos(angle) * radius
            local pointZ = object.z + math.sin(angle) * radius
            points[#points + 1] = {
                pointX, roadGarbageTerrainY(pointX, pointZ) + 0.10, pointZ
            }
        end
        object.debugRingCache[cacheKey] = points
    end
    for pointIndex = 2, #points do
        local previous = points[pointIndex - 1]
        local nextPoint = points[pointIndex]
        drawDebugLine(
            previous[1], previous[2], previous[3], r, g, b,
            nextPoint[1], nextPoint[2], nextPoint[3], r, g, b
        )
    end
end

local function roadGarbageMissionVehicles()
    if g_currentMission == nil then
        return {}
    end
    local vehicleSystem = g_currentMission.vehicleSystem
    if vehicleSystem ~= nil and vehicleSystem.vehicles ~= nil then
        return vehicleSystem.vehicles
    end
    return g_currentMission.vehicles or {}
end

local function roadGarbageVehicleSpeedKph(vehicle)
    if vehicle == nil then
        return 0
    end
    local speed = tonumber(vehicle.lastSpeedReal) or 0
    if math.abs(speed) < 0.00001 and vehicle.getRootVehicle ~= nil then
        local ok, rootVehicle = pcall(vehicle.getRootVehicle, vehicle)
        if ok and rootVehicle ~= nil and rootVehicle ~= vehicle then
            speed = tonumber(rootVehicle.lastSpeedReal) or speed
        end
    end
    return math.abs(speed) * 3600
end

local function roadGarbageDisablePhysicsRecursive(node)
    if not roadGarbageNodeExists(node) then
        return
    end

    if setCollisionMask ~= nil then
        pcall(setCollisionMask, node, 0)
    end
    if setRigidBodyType ~= nil and RigidBodyType ~= nil then
        pcall(setRigidBodyType, node, RigidBodyType.NONE)
    end
    if getNumOfChildren ~= nil and getChildAt ~= nil then
        for childIndex = 0, (getNumOfChildren(node) or 0) - 1 do
            roadGarbageDisablePhysicsRecursive(getChildAt(node, childIndex))
        end
    end
end

function RoadGarbageSystem:getIsActive()
    return self.enabled == true
        and PunctureSystem ~= nil
        and PunctureSystem.enabled == true
end

function RoadGarbageSystem:getTargetCount()
    local count = math.floor((tonumber(self.pointCount) or 20) / 10 + 0.5) * 10
    return math.max(10, math.min(150, count))
end

function RoadGarbageSystem:getObjectCount()
    local count = 0
    for _ in pairs(self.objects or {}) do
        count = count + 1
    end
    return count
end

function RoadGarbageSystem:refreshTerrainLayerCache()
    self.allowedLayerIds = {}
    local terrainNode = roadGarbageTerrainNode()
    if terrainNode == nil or getTerrainNumOfLayers == nil
        or getTerrainLayerName == nil then
        return false
    end

    local layerCount = getTerrainNumOfLayers(terrainNode) or 0
    for layerId = 0, layerCount - 1 do
        local name = string.upper(tostring(getTerrainLayerName(terrainNode, layerId) or ""))
        local isDirt = string.find(name, "DIRT", 1, true) ~= nil
        local isMud = string.find(name, "MUD", 1, true) ~= nil
        local denied = string.find(name, "WATER", 1, true) ~= nil
            or string.find(name, "CONCRETE", 1, true) ~= nil
            or string.find(name, "ASPHALT", 1, true) ~= nil
            or string.find(name, "GRAVEL", 1, true) ~= nil
            or string.find(name, "STONE", 1, true) ~= nil

        if (isDirt or isMud) and not denied then
            self.allowedLayerIds[#self.allowedLayerIds + 1] = layerId
        end
    end

    print(string.format(
        "[RoadGarbageSystem] Terrain layers ready: %d DIRT/MUD layer(s)",
        #self.allowedLayerIds
    ))
    return #self.allowedLayerIds > 0
end

function RoadGarbageSystem:getAllowedLayerWeight(x, z)
    local terrainNode = roadGarbageTerrainNode()
    if terrainNode == nil or getTerrainLayerAtWorldPos == nil then
        return 0
    end

    local bestWeight = 0
    for _, layerId in ipairs(self.allowedLayerIds or {}) do
        local weight = getTerrainLayerAtWorldPos(terrainNode, layerId, x, 0, z) or 0
        if weight > bestWeight then
            bestWeight = weight
        end
    end
    return bestWeight
end

function RoadGarbageSystem:getIsOnField(x, z)
    if FSDensityMapUtil == nil
        or FSDensityMapUtil.getFieldDataAtWorldPosition == nil then
        return false
    end
    local ok, isOnField = pcall(
        FSDensityMapUtil.getFieldDataAtWorldPosition, x, 0, z
    )
    return ok and isOnField == true
end

function RoadGarbageSystem:getIsInWater(x, z, terrainY)
    local mission = g_currentMission
    local environmentAreaSystem = mission ~= nil and mission.environmentAreaSystem or nil
    if environmentAreaSystem == nil then
        return false
    end

    if environmentAreaSystem.getWaterYAtWorldPosition ~= nil then
        local ok, waterY = pcall(
            environmentAreaSystem.getWaterYAtWorldPosition,
            environmentAreaSystem, x, (terrainY or 0) + 0.5, z
        )
        if ok and waterY ~= nil and waterY > (terrainY or 0) + 0.15 then
            return true
        end
    end
    return false
end

function RoadGarbageSystem:getIsTooSteep(x, z)
    if getTerrainNormalAtWorldPos == nil then
        return false
    end
    local terrainNode = roadGarbageTerrainNode()
    if terrainNode == nil then
        return false
    end
    local _, normalY = getTerrainNormalAtWorldPos(terrainNode, x, 0, z)
    if normalY == nil then
        return false
    end
    normalY = math.max(-1, math.min(1, normalY))
    return math.deg(math.acos(normalY)) > self.maximumSlopeDeg
end

function RoadGarbageSystem:getHasMinimumSpacing(x, z)
    local minimumDistanceSq = self.minimumSpacingM * self.minimumSpacingM
    for _, object in pairs(self.objects or {}) do
        local dx = object.x - x
        local dz = object.z - z
        if dx * dx + dz * dz < minimumDistanceSq then
            return false
        end
    end
    return true
end

function RoadGarbageSystem:getIsAwayFromVehicles(x, z, minimumDistance)
    local distanceSq = minimumDistance * minimumDistance
    for _, vehicle in pairs(roadGarbageMissionVehicles()) do
        local node = vehicle ~= nil and (vehicle.rootNode or vehicle.components
            and vehicle.components[1] and vehicle.components[1].node) or nil
        if roadGarbageNodeExists(node) then
            local vehicleX, _, vehicleZ = getWorldTranslation(node)
            local dx = vehicleX - x
            local dz = vehicleZ - z
            if dx * dx + dz * dz < distanceSq then
                return false
            end
        end
    end
    return true
end

function RoadGarbageSystem:getIsValidSpawnPosition(x, z, ignoreVehicleDistance)
    local terrainSize = g_currentMission ~= nil
        and tonumber(g_currentMission.terrainSize) or nil
    if terrainSize ~= nil then
        local halfSize = terrainSize * 0.5 - 20
        if x <= -halfSize or x >= halfSize or z <= -halfSize or z >= halfSize then
            return false
        end
    end

    if self:getAllowedLayerWeight(x, z) < self.terrainLayerMinWeight then
        return false
    end
    if self:getIsOnField(x, z) or self:getIsTooSteep(x, z) then
        return false
    end
    local terrainY = roadGarbageTerrainY(x, z)
    if self:getIsInWater(x, z, terrainY) then
        return false
    end
    if not self:getHasMinimumSpacing(x, z) then
        return false
    end
    if ignoreVehicleDistance ~= true and not self:getIsAwayFromVehicles(x, z, 18) then
        return false
    end
    return true
end

function RoadGarbageSystem:allocateObjectId()
    for _ = 1, 65535 do
        local objectId = math.floor(tonumber(self.nextObjectId) or 1)
        if objectId < 1 or objectId > 65535 then
            objectId = 1
        end
        self.nextObjectId = objectId + 1
        if self.objects[objectId] == nil then
            return objectId
        end
    end
    return nil
end

function RoadGarbageSystem:spawnObject(x, z, rotY, modelIndex, broadcast)
    if g_server == nil or not self:getIsActive() then
        return nil
    end
    local objectId = self:allocateObjectId()
    if objectId == nil then
        return nil
    end

    self:applySpawnLocal(objectId, x, z, rotY, modelIndex)
    if broadcast ~= false and g_server ~= nil and RoadGarbageEvent ~= nil then
        g_server:broadcastEvent(RoadGarbageEvent.new(
            RoadGarbageEvent.ACTION_ADD,
            objectId, x, z, rotY, modelIndex
        ), false)
    end
    return objectId
end

function RoadGarbageSystem:applySpawnLocal(objectId, x, z, rotY, modelIndex)
    objectId = math.floor(tonumber(objectId) or 0)
    if objectId <= 0 then
        return
    end

    local existing = self.objects[objectId]
    if existing ~= nil then
        self:destroyVisual(existing)
    end

    modelIndex = math.max(1, math.min(#self.models, math.floor(tonumber(modelIndex) or 1)))
    local object = {
        id = objectId,
        x = tonumber(x) or 0,
        z = tonumber(z) or 0,
        rotY = tonumber(rotY) or 0,
        modelIndex = modelIndex,
        puncturesCaused = 0,
        checkedWheels = setmetatable({}, {__mode = "k"})
    }
    self.objects[objectId] = object
    self.nextObjectId = math.max(self.nextObjectId or 1, objectId + 1)

    if g_client ~= nil then
        self:createVisual(object)
    end
end

function RoadGarbageSystem:createVisual(object)
    if object == nil or object.rootNode ~= nil or g_i3DManager == nil then
        return
    end

    local filename = Utils.getFilename(self.models[object.modelIndex], self.path)
    if filename == nil or filename == "" or not fileExists(filename) then
        Logging.warning("[RoadGarbageSystem] Garbage i3d not found: %s", tostring(filename))
        return
    end

    local rootNode = createTransformGroup("mudSystemRoadGarbage")
    link(getRootNode(), rootNode)
    setWorldTranslation(rootNode, object.x, roadGarbageTerrainY(object.x, object.z) + 0.012, object.z)
    setWorldRotation(rootNode, 0, object.rotY, 0)
    object.rootNode = rootNode

    local loadData = {objectId = object.id}
    local requestId = g_i3DManager:loadI3DFileAsync(
        filename, false, false, self.onI3DLoaded, self, loadData
    )
    loadData.requestId = requestId
    object.loadRequestId = requestId
end

function RoadGarbageSystem:onI3DLoaded(i3dNode, failedReason, data)
    local object = data ~= nil and self.objects[data.objectId] or nil
    -- An object id can be reused after a multiplayer resync. Never attach a
    -- late callback from the old request to the newly created object.
    if object == nil or data == nil or object.loadRequestId ~= data.requestId then
        if roadGarbageNodeExists(i3dNode) then
            delete(i3dNode)
        end
        return
    end
    object.loadRequestId = nil

    if failedReason ~= LoadI3DFailedReason.NONE
        or not roadGarbageNodeExists(object.rootNode) then
        if roadGarbageNodeExists(i3dNode) then
            delete(i3dNode)
        end
        return
    end

    local visualNode = i3dNode
    if getNumOfChildren(i3dNode) > 0 then
        visualNode = getChildAt(i3dNode, 0)
        link(object.rootNode, visualNode)
        delete(i3dNode)
    else
        link(object.rootNode, visualNode)
    end

    roadGarbageDisablePhysicsRecursive(visualNode)
    setTranslation(visualNode, 0, 0, 0)
    setRotation(visualNode, 0, 0, 0)
    setVisibility(visualNode, true)
    object.visualNode = visualNode
end

function RoadGarbageSystem:destroyVisual(object)
    if object == nil then
        return
    end
    if object.loadRequestId ~= nil and g_i3DManager ~= nil then
        pcall(g_i3DManager.cancelStreamI3DFile, g_i3DManager, object.loadRequestId)
        object.loadRequestId = nil
    end
    if roadGarbageNodeExists(object.rootNode) then
        delete(object.rootNode)
    elseif roadGarbageNodeExists(object.visualNode) then
        delete(object.visualNode)
    end
    object.rootNode = nil
    object.visualNode = nil
end

function RoadGarbageSystem:removeLocal(objectId)
    local object = self.objects[tonumber(objectId) or 0]
    if object == nil then
        return
    end
    self:destroyVisual(object)
    self.objects[object.id] = nil
end

function RoadGarbageSystem:removeObject(objectId, broadcast, scheduleRespawn)
    if g_server == nil then
        return
    end
    if self.objects[tonumber(objectId) or 0] == nil then
        return
    end
    self:removeLocal(objectId)
    if broadcast ~= false and RoadGarbageEvent ~= nil then
        g_server:broadcastEvent(RoadGarbageEvent.new(
            RoadGarbageEvent.ACTION_REMOVE, objectId
        ), false)
    end
    if scheduleRespawn == true then
        self.nextRespawnTime = math.max(
            self.nextRespawnTime or 0,
            (tonumber(g_time) or 0) + self.respawnDelayMs
        )
    end
end

function RoadGarbageSystem:clearLocal()
    for _, object in pairs(self.objects or {}) do
        self:destroyVisual(object)
    end
    self.objects = {}
    self.nextObjectId = 1
end

function RoadGarbageSystem:clearServerObjects()
    if g_server == nil then
        self:clearLocal()
        return
    end
    self:clearLocal()
    if RoadGarbageEvent ~= nil then
        g_server:broadcastEvent(RoadGarbageEvent.new(
            RoadGarbageEvent.ACTION_CLEAR
        ), false)
    end
end

function RoadGarbageSystem:trimToTargetCount()
    if g_server == nil then
        return
    end
    local removeCount = self:getObjectCount() - self:getTargetCount()
    if removeCount <= 0 then
        return
    end

    -- Remove already-hit objects first, then arbitrary remaining points. Each
    -- removal is broadcast so every client keeps the exact same point set.
    local removeIds = {}
    for objectId, object in pairs(self.objects or {}) do
        if object.removeAt ~= nil then
            removeIds[#removeIds + 1] = objectId
            if #removeIds >= removeCount then
                break
            end
        end
    end
    if #removeIds < removeCount then
        local selected = {}
        for _, objectId in ipairs(removeIds) do
            selected[objectId] = true
        end
        for objectId in pairs(self.objects or {}) do
            if selected[objectId] ~= true then
                removeIds[#removeIds + 1] = objectId
                if #removeIds >= removeCount then
                    break
                end
            end
        end
    end

    for _, objectId in ipairs(removeIds) do
        self:removeObject(objectId, true, false)
    end
end

function RoadGarbageSystem:sendFullState(connection)
    if g_server == nil or connection == nil or RoadGarbageEvent == nil then
        return
    end
    connection:sendEvent(RoadGarbageEvent.new(RoadGarbageEvent.ACTION_CLEAR))
    if not self:getIsActive() then
        return
    end
    for _, object in pairs(self.objects or {}) do
        connection:sendEvent(RoadGarbageEvent.new(
            RoadGarbageEvent.ACTION_ADD,
            object.id, object.x, object.z, object.rotY, object.modelIndex
        ))
    end
end

function RoadGarbageSystem:startScatter(isInitialFill)
    if g_server == nil or not self:getIsActive() then
        return
    end
    if #self.allowedLayerIds == 0 and not self:refreshTerrainLayerCache() then
        self.nextRespawnTime = (tonumber(g_time) or 0) + self.scatterRetryDelayMs
        return
    end
    local missing = math.max(0, self:getTargetCount() - self:getObjectCount())
    if missing <= 0 then
        self.scatterState = nil
        return
    end
    self.scatterState = {
        remaining = missing,
        attemptsRemaining = missing * self.scatterAttemptsPerObject,
        initial = isInitialFill == true
    }
end

function RoadGarbageSystem:updateScatter()
    local state = self.scatterState
    if state == nil or g_server == nil then
        return
    end
    if not self:getIsActive() then
        self.scatterState = nil
        return
    end

    local terrainSize = g_currentMission ~= nil
        and tonumber(g_currentMission.terrainSize) or 2048
    local halfSize = math.max(50, terrainSize * 0.5 - 25)
    local attempts = math.min(self.scatterAttemptsPerFrame, state.attemptsRemaining)

    for _ = 1, attempts do
        state.attemptsRemaining = state.attemptsRemaining - 1
        local x = -halfSize + math.random() * halfSize * 2
        local z = -halfSize + math.random() * halfSize * 2
        if self:getIsValidSpawnPosition(x, z) then
            local modelIndex = math.random(1, #self.models)
            self:spawnObject(x, z, math.random() * math.pi * 2, modelIndex, true)
            state.remaining = state.remaining - 1
            if state.remaining <= 0 then
                break
            end
        end
    end

    if state.remaining <= 0 or state.attemptsRemaining <= 0 then
        if state.attemptsRemaining <= 0 and state.remaining > 0 then
            Logging.warning(
                "[RoadGarbageSystem] Could only place %d/%d garbage objects on DIRT/MUD layers",
                self:getObjectCount(), self:getTargetCount()
            )
            self.nextRespawnTime = (tonumber(g_time) or 0) + self.scatterRetryDelayMs
        end
        self.scatterState = nil
    end
end

function RoadGarbageSystem:getWheelContactCandidates(object, vehicle)
    local candidates = {}
    local wheels = vehicle ~= nil and vehicle.spec_wheels ~= nil
        and vehicle.spec_wheels.wheels or nil
    if wheels == nil then
        return candidates
    end

    local checkedForVehicle = object.checkedWheels[vehicle]
    if checkedForVehicle == nil then
        checkedForVehicle = {}
        object.checkedWheels[vehicle] = checkedForVehicle
    end
    local triggerRadiusSq = self.triggerRadiusM * self.triggerRadiusM

    for wheelIndex, wheel in ipairs(wheels) do
        if checkedForVehicle[wheelIndex] ~= true and wheel ~= nil then
            local node = wheel.repr or wheel.driveNode
            if roadGarbageNodeExists(node) then
                local wheelX, _, wheelZ = getWorldTranslation(node)
                local dx = wheelX - object.x
                local dz = wheelZ - object.z
                if dx * dx + dz * dz <= triggerRadiusSq then
                    checkedForVehicle[wheelIndex] = true
                    candidates[#candidates + 1] = wheelIndex
                end
            end
        end
    end
    return candidates
end

function RoadGarbageSystem:processVehicleContact(object, vehicle, now)
    if not self:getIsActive() or vehicle == nil or vehicle.spec_wheels == nil
        or roadGarbageVehicleSpeedKph(vehicle) < self.minimumImpactSpeedKph then
        return false
    end

    local rootNode = vehicle.rootNode or vehicle.components
        and vehicle.components[1] and vehicle.components[1].node
    if not roadGarbageNodeExists(rootNode) then
        return false
    end
    local vehicleX, _, vehicleZ = getWorldTranslation(rootNode)
    local dx = vehicleX - object.x
    local dz = vehicleZ - object.z
    if dx * dx + dz * dz > 144 then
        return false
    end

    local candidates = self:getWheelContactCandidates(object, vehicle)
    if #candidates == 0 then
        return false
    end

    if object.impactExpiresAt == nil then
        object.impactExpiresAt = now + self.impactWindowMs
        object.removeAt = now + self.removalDelayMs
    end

    local eligible = PunctureSystem ~= nil
        and PunctureSystem.getPunctureEligibility ~= nil
        and PunctureSystem:getPunctureEligibility(vehicle)

    if eligible then
        for _, wheelIndex in ipairs(candidates) do
            if object.puncturesCaused >= self.maximumPuncturesPerObject then
                break
            end
            if math.random() <= self.punctureChance
                and PunctureSystem:triggerPuncture(vehicle, wheelIndex) then
                object.puncturesCaused = object.puncturesCaused + 1
            end
        end
    end

    return true
end

function RoadGarbageSystem:updateCollisions(dt)
    if g_server == nil or not self:getIsActive() then
        return
    end
    self.collisionTimer = (self.collisionTimer or 0) - (dt or 0)
    if self.collisionTimer > 0 then
        return
    end
    self.collisionTimer = self.collisionUpdateMs

    local now = tonumber(g_time) or 0
    local removeIds = {}
    for objectId, object in pairs(self.objects or {}) do
        if object.removeAt ~= nil and now >= object.removeAt then
            removeIds[#removeIds + 1] = objectId
        elseif object.impactExpiresAt == nil
            or (now < object.impactExpiresAt
                and object.puncturesCaused < self.maximumPuncturesPerObject) then
            for _, vehicle in pairs(roadGarbageMissionVehicles()) do
                self:processVehicleContact(object, vehicle, now)
                if object.puncturesCaused >= self.maximumPuncturesPerObject then
                    break
                end
            end
        end
    end

    for _, objectId in ipairs(removeIds) do
        self:removeObject(objectId, true, true)
    end
end

function RoadGarbageSystem:getSavePath()
    local missionInfo = g_currentMission ~= nil and g_currentMission.missionInfo or nil
    local directory = missionInfo ~= nil and missionInfo.savegameDirectory or nil
    if directory == nil or directory == "" then
        return nil
    end
    return directory .. "/" .. self.SAVE_FILE
end

function RoadGarbageSystem:saveToXML()
    if g_server == nil then
        return
    end
    local path = self:getSavePath()
    if path == nil then
        return
    end
    local xml = createXMLFile("MudSystemRoadGarbage", path, self.SAVE_ROOT)
    if xml == nil or xml == 0 then
        return
    end

    local index = 0
    if self:getIsActive() then
        for _, object in pairs(self.objects or {}) do
            -- An already-hit object is waiting only so the player can inspect
            -- it. Do not resurrect it when the game is saved during that delay.
            if object.removeAt == nil then
                local key = string.format("%s.object(%d)", self.SAVE_ROOT, index)
                setXMLInt(xml, key .. "#id", object.id)
                setXMLFloat(xml, key .. "#x", object.x)
                setXMLFloat(xml, key .. "#z", object.z)
                setXMLFloat(xml, key .. "#rotY", object.rotY)
                setXMLInt(xml, key .. "#modelIndex", object.modelIndex)
                index = index + 1
            end
        end
    end
    setXMLInt(xml, self.SAVE_ROOT .. "#count", index)
    setXMLInt(xml, self.SAVE_ROOT .. "#nextObjectId", self.nextObjectId or 1)
    saveXMLFile(xml)
    delete(xml)
end

function RoadGarbageSystem:loadFromXML()
    if g_server == nil or not self:getIsActive() then
        return false
    end
    local path = self:getSavePath()
    if path == nil or not fileExists(path) then
        return false
    end
    local xml = loadXMLFile("MudSystemRoadGarbage", path)
    if xml == nil or xml == 0 then
        return false
    end

    local count = math.max(0, getXMLInt(xml, self.SAVE_ROOT .. "#count") or 0)
    self.nextObjectId = math.max(1, getXMLInt(
        xml, self.SAVE_ROOT .. "#nextObjectId"
    ) or 1)
    for index = 0, count - 1 do
        local key = string.format("%s.object(%d)", self.SAVE_ROOT, index)
        local objectId = getXMLInt(xml, key .. "#id")
        local x = getXMLFloat(xml, key .. "#x")
        local z = getXMLFloat(xml, key .. "#z")
        if objectId ~= nil and objectId > 0 and x ~= nil and z ~= nil
            and self:getIsValidSpawnPosition(x, z, true) then
            self:applySpawnLocal(
                objectId, x, z,
                getXMLFloat(xml, key .. "#rotY") or 0,
                getXMLInt(xml, key .. "#modelIndex") or 1
            )
        end
    end
    delete(xml)
    return true
end

function RoadGarbageSystem:onSettingsChanged()
    self.enabled = self.enabled == true
    self.pointCount = self:getTargetCount()
    if not self.mapLoaded then
        return
    end

    if not self:getIsActive() then
        if self:getObjectCount() > 0 then
            if g_server ~= nil then
                self:clearServerObjects()
            else
                self:clearLocal()
            end
        end
        self.scatterState = nil
        return
    end

    if g_server ~= nil and self.mapFinished == true then
        self:trimToTargetCount()
        if self:getObjectCount() < self:getTargetCount() then
            self.nextRespawnTime = 0
            self:startScatter(true)
        end
    end
end

function RoadGarbageSystem:loadMap()
    self.mapLoaded = true
    self.mapFinished = false
    self.objects = {}
    self.nextObjectId = 1
    self.allowedLayerIds = {}
    self.scatterState = nil
    self.nextRespawnTime = 0
    self.collisionTimer = 0
    self.debugEnabled = false
    self.syncPending = g_server == nil
    self.syncDelay = 1400

    if self:getIsActive() and g_server ~= nil then
        self:refreshTerrainLayerCache()
        self:loadFromXML()
    end

    if not self.consoleRegistered then
        addConsoleCommand(
            "gsRoadGarbage",
            "Road garbage: gsRoadGarbage <status|spawn|debug [on|off]|clear|rebuild>",
            "consoleCommandRoadGarbage", self
        )
        self.consoleRegistered = true
    end

    if g_currentMission ~= nil then
        FSBaseMission.registerToLoadOnMapFinished(g_currentMission, self)
    end
end

function RoadGarbageSystem:onLoadMapFinished()
    self.mapFinished = true
    if self:getIsActive() and g_server ~= nil then
        self:startScatter(true)
    end
end

function RoadGarbageSystem:update(dt)
    if not self.mapFinished then
        return
    end

    if not self:getIsActive() then
        if self:getObjectCount() > 0 then
            if g_server ~= nil then
                self:clearServerObjects()
            else
                self:clearLocal()
            end
        end
        self.scatterState = nil
        return
    end

    if self.syncPending == true then
        self.syncDelay = (self.syncDelay or 1400) - (dt or 0)
        if self.syncDelay <= 0 then
            RoadGarbageEvent.sendRequest()
            self.syncPending = false
        end
    end

    if g_server ~= nil then
        self:updateCollisions(dt)
        self:updateScatter()
        local now = tonumber(g_time) or 0
        if self.scatterState == nil
            and self:getObjectCount() < self:getTargetCount()
            and now >= (self.nextRespawnTime or 0) then
            self:startScatter(false)
        end
    end
end

function RoadGarbageSystem:draw()
    if self.debugEnabled ~= true or g_client == nil or drawDebugLine == nil then
        return
    end

    local playerX, _, playerZ = roadGarbageLocalPlayerPosition()
    for _, object in pairs(self.objects or {}) do
        local impacted = object.removeAt ~= nil
        local r = impacted and 1 or 0.15
        local g = impacted and 0.15 or 1
        local b = 0.05
        local terrainY = roadGarbageTerrainY(object.x, object.z) + 0.10

        -- Inner circle is the actual wheel-contact radius. The larger ring and
        -- vertical beacon make every stored point visible from farther away.
        roadGarbageDrawTerrainCircle(
            object, "contact", self.triggerRadiusM, 18, r, g, b
        )
        roadGarbageDrawTerrainCircle(
            object, "marker", self.triggerRadiusM * 2.4, 16, 1, 0.75, 0.05
        )
        drawDebugLine(
            object.x, terrainY, object.z, r, g, b,
            object.x, terrainY + 3.5, object.z, r, g, b
        )

        if playerX ~= nil and playerZ ~= nil
            and Utils ~= nil and Utils.renderTextAtWorldPosition ~= nil then
            local dx = object.x - playerX
            local dz = object.z - playerZ
            if dx * dx + dz * dz <= 22500 then
                Utils.renderTextAtWorldPosition(
                    object.x, terrainY + 3.65, object.z,
                    string.format("garbage #%d", object.id),
                    getCorrectTextSize ~= nil and getCorrectTextSize(0.012) or 0.012,
                    0
                )
            end
        end
    end
end

function RoadGarbageSystem:deleteMap()
    self:clearLocal()
    self.allowedLayerIds = {}
    self.scatterState = nil
    self.mapLoaded = false
    self.mapFinished = false
    self.syncPending = false
    self.debugEnabled = false
end

function RoadGarbageSystem:consoleCommandRoadGarbage(command, value)
    command = string.lower(tostring(command or "status"))
    if command == "status" then
        return string.format(
            "RoadGarbage enabled=%s active=%s objects=%d/%d layers=%d scatter=%s debug=%s",
            tostring(self.enabled), tostring(self:getIsActive()),
            self:getObjectCount(), self:getTargetCount(),
            #(self.allowedLayerIds or {}), tostring(self.scatterState ~= nil),
            tostring(self.debugEnabled == true)
        )
    end
    if command == "debug" then
        value = string.lower(tostring(value or "toggle"))
        if value == "on" or value == "1" or value == "true" then
            self.debugEnabled = true
        elseif value == "off" or value == "0" or value == "false" then
            self.debugEnabled = false
        else
            self.debugEnabled = self.debugEnabled ~= true
        end
        if self.debugEnabled == true then
            for _, object in pairs(self.objects or {}) do
                object.debugRingCache = nil
            end
        end
        return string.format(
            "[RoadGarbageSystem] Terrain point debug %s (%d object(s))",
            self.debugEnabled and "ON" or "OFF", self:getObjectCount()
        )
    end
    if g_server == nil then
        return "[RoadGarbageSystem] This command must run on the server"
    end
    if command == "clear" then
        self:clearServerObjects()
        self.nextRespawnTime = (tonumber(g_time) or 0) + self.respawnDelayMs
        return "[RoadGarbageSystem] All road garbage removed"
    elseif command == "rebuild" then
        if not self:getIsActive() then
            return "[RoadGarbageSystem] Enable punctures and road garbage first"
        end
        self:clearServerObjects()
        self:refreshTerrainLayerCache()
        self.nextRespawnTime = 0
        self:startScatter(true)
        return "[RoadGarbageSystem] Rebuilding road garbage on DIRT/MUD layers"
    elseif command == "spawn" then
        if not self:getIsActive() then
            return "[RoadGarbageSystem] Enable punctures and road garbage first"
        end
        local x, _, z = roadGarbageLocalPlayerPosition()
        if x == nil or z == nil then
            return "[RoadGarbageSystem] Local on-foot player position is unavailable"
        end
        self:spawnObject(x, z, math.random() * math.pi * 2, math.random(1, #self.models), true)
        return string.format("[RoadGarbageSystem] Spawned test garbage under player at %.1f %.1f", x, z)
    end
    return "Usage: gsRoadGarbage <status|spawn|debug [on|off]|clear|rebuild>"
end

local function roadGarbageSavegameHook()
    if RoadGarbageSystem ~= nil then
        RoadGarbageSystem:saveToXML()
    end
end

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

addModEventListener(RoadGarbageSystem)
