PunctureSystem = {
    name = g_currentModName or "MudSystemPhysics",
    path = g_currentModDirectory or "",
    enabled = false,
    requireJack = true,
    showCarriedWheel = false,
    highlightPuncturedWheel = true,
    aiPuncturesEnabled = false,
    aiAutoRepairEnabled = true,
    aiAutoRepairCost = 10,
    aiAutoRepairDelayMs = 10000,
    chancePerKm = 0.02, -- legacy field name: chance over chanceDistanceKm
    chanceDistanceKm = 1,
    interactionDistance = 3.8,
    repairDurationMs = 5000,
    removalDurationMs = 5000,
    installationDurationMs = 5000,
    puncturedRadiusFactor = 0.82,
    removedRadiusFactor = 0.35,
    puncturedFrictionMultiplier = 0.34,
    removedFrictionMultiplier = 0.06,
    puncturedSpeedFactor = 0.40,
    puncturedMinimumSpeedKph = 6,
    removedSpeedLimitKph = 2,
    puncturedSteeringPull = 0.22,
    maximumSteeringPull = 0.32,
    steeringPullStartKph = 3,
    steeringPullFullKph = 25,
    drivePenaltyCacheMs = 100,
    jackModel = "assets/domkrat.i3d",
    jackReadyThreshold = 0.72,
    jackStandardExtension = 1.0,
    jackMaxExtension = 1.5,
    jackVisualStroke = 0.34,
    jackPlacementMaxDistance = 2.35,
    jackSyncIntervalMs = 90,
    jackExtensionRatePerSecond = 0.20,
    SAVE_FILE = "MudSystemPunctures.xml",
    SAVE_ROOT = "mudSystemPunctures",
    STAGE_NONE = 0,
    STAGE_PUNCTURED = 1,
    STAGE_HELD = 2,
    STAGE_PLACED = 3,
    SOUND_PUNCTURE = 1,
    SOUND_JACK_PLACE = 2,
    SOUND_WHEEL_REMOVE = 3,
    SOUND_WHEEL_REPAIR = 4,
    SOUND_WHEEL_INSTALL = 5,
    SOUND_JACK_START = 6,
    SOUND_JACK_STOP = 7
}

PunctureSystem.states = setmetatable({}, {__mode="k"})
PunctureSystem.travel = setmetatable({}, {__mode="k"})
PunctureSystem.drivePenaltyCache = setmetatable({}, {__mode="k"})
PunctureSystem.aiAutoRepairTimers = setmetatable({}, {__mode="k"})
PunctureSystem.pending = {}

local function nodeExists(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 clamp(value, minValue, maxValue)
    value = tonumber(value) or minValue
    return math.max(minValue, math.min(maxValue, value))
end

local function punctureVehicleDeleting(vehicle)
    return vehicle == nil
        or vehicle.__mudSystemDeleting == true
        or vehicle.isDeleting == true
        or vehicle.isDeleted == true
end

local function getText(key, fallback)
    if g_i18n ~= nil and g_i18n.hasText ~= nil and g_i18n:hasText(key) then
        return g_i18n:getText(key)
    end
    return fallback or key
end

local function getVehicleWheels(vehicle)
    return vehicle ~= nil and vehicle.spec_wheels ~= nil and vehicle.spec_wheels.wheels or nil
end

local function getMissionVehicles()
    if g_currentMission == nil then return {} end
    if g_currentMission.vehicleSystem ~= nil
        and g_currentMission.vehicleSystem.vehicles ~= nil then
        return g_currentMission.vehicleSystem.vehicles
    end
    -- Compatibility fallback for maps or older game builds exposing the list directly.
    return g_currentMission.vehicles or {}
end

local function isCrawlerVehicle(vehicle)
    if vehicle == nil then return false end

    -- The specialization is the authoritative signal for tracked machines.
    -- Requiring an actual crawler entry avoids false positives during early loading.
    local spec = vehicle.spec_crawlers
    if spec ~= nil and type(spec.crawlers) == "table" and next(spec.crawlers) ~= nil then
        return true
    end

    -- Compatibility fallback for vehicles that model tracks through wheel physics
    -- without exposing the standard Crawlers specialization.
    if WheelsUtil ~= nil and WheelsUtil.getTireTypeName ~= nil then
        for _, wheel in ipairs(getVehicleWheels(vehicle) or {}) do
            local tireType = wheel ~= nil and wheel.physics ~= nil and wheel.physics.tireType or nil
            if tireType ~= nil then
                local ok, name = pcall(WheelsUtil.getTireTypeName, tireType)
                if ok and string.lower(tostring(name or "")) == "crawler" then
                    return true
                end
            end
        end
    end
    return false
end

local function isVehicleAIActive(vehicle)
    if punctureVehicleDeleting(vehicle) then return false end
    local root = vehicle.getRootVehicle ~= nil and vehicle:getRootVehicle()
        or vehicle.rootVehicle or vehicle
    if root.getIsAIActive == nil then return false end
    local ok, active = pcall(root.getIsAIActive, root)
    return ok and active == true
end

local function getActiveAIVehicle(vehicle)
    if punctureVehicleDeleting(vehicle) then return nil end
    local root = vehicle.getRootVehicle ~= nil and vehicle:getRootVehicle()
        or vehicle.rootVehicle or vehicle
    return isVehicleAIActive(root) and root or nil
end

local function getVehicleFarmId(vehicle)
    local root = vehicle ~= nil and (vehicle.getRootVehicle ~= nil and vehicle:getRootVehicle()
        or vehicle.rootVehicle or vehicle) or nil
    if root == nil then return nil end
    local spec = root.spec_aiJobVehicle
    local startedFarmId = spec ~= nil and tonumber(spec.startedFarmId) or nil
    if startedFarmId ~= nil and startedFarmId ~= 0 then return startedFarmId end
    return (root.getOwnerFarmId ~= nil and root:getOwnerFarmId()) or root.ownerFarmId
end

local function getAIHelperName(vehicle)
    local root = getActiveAIVehicle(vehicle)
        or (vehicle ~= nil and (vehicle.getRootVehicle ~= nil and vehicle:getRootVehicle()
            or vehicle.rootVehicle or vehicle))
    local spec = root ~= nil and root.spec_aiJobVehicle or nil
    local helper = spec ~= nil and spec.currentHelper or nil
    if helper ~= nil then return helper.title or helper.name end
    local job = spec ~= nil and spec.job or nil
    if job ~= nil and job.getHelperName ~= nil then
        local ok, name = pcall(job.getHelperName, job)
        if ok and name ~= nil then return name end
    end
    return getText("mudsys_puncture_ai_worker", "AI worker")
end

local function getWheel(vehicle, wheelIndex)
    local wheels = getVehicleWheels(vehicle)
    return wheels ~= nil and wheels[tonumber(wheelIndex) or 0] or nil
end

-- FS25 also creates WheelPhysics objects for invisible support/drum wheels used
-- by implements. Only WheelVisualPartTire represents an actual puncturable tire.
local function wheelHasVisualTire(wheel)
    if wheel == nil or wheel.physics == nil then return false end

    for _, visualWheel in ipairs(wheel.visualWheels or {}) do
        if visualWheel ~= nil and visualWheel.getTireNode ~= nil then
            local ok, tireNode = pcall(visualWheel.getTireNode, visualWheel)
            if ok and nodeExists(tireNode) then return true end
        end

        -- Class/name fallback keeps custom wheel implementations compatible and
        -- also works on a dedicated server when no renderable node is available.
        for _, visualPart in ipairs(visualWheel ~= nil and visualWheel.visualParts or {}) do
            local isTire = string.lower(tostring(visualPart.name or "")) == "tire"
            if not isTire and WheelVisualPartTire ~= nil and visualPart.isa ~= nil then
                local ok, result = pcall(visualPart.isa, visualPart, WheelVisualPartTire)
                isTire = ok and result == true
            end
            if isTire then return true end
        end
    end

    return false
end

local function isPuncturableWheel(vehicle, wheelIndex)
    return wheelHasVisualTire(getWheel(vehicle, wheelIndex))
end

local function getPuncturableWheelIndices(vehicle)
    local indices = {}
    for wheelIndex, wheel in ipairs(getVehicleWheels(vehicle) or {}) do
        if wheelHasVisualTire(wheel) then
            indices[#indices+1] = wheelIndex
        end
    end
    return indices
end

local function hasPuncturableWheel(vehicle)
    for _, wheel in ipairs(getVehicleWheels(vehicle) or {}) do
        if wheelHasVisualTire(wheel) then return true end
    end
    return false
end

local function getVehicleId(vehicle)
    if vehicle == nil then return nil end
    if vehicle.getUniqueId ~= nil then
        local ok, value = pcall(vehicle.getUniqueId, vehicle)
        if ok and value ~= nil then return tostring(value) end
    end
    if vehicle.uniqueId ~= nil then return tostring(vehicle.uniqueId) end
    return nil
end

local function getWheelWorldPosition(vehicle, wheelIndex)
    local wheel = getWheel(vehicle, wheelIndex)
    if wheel == nil then return nil end
    local node = wheel.repr or wheel.driveNode
    if not nodeExists(node) then return nil end
    return getWorldTranslation(node)
end

local function getWheelSoundNode(vehicle, wheelIndex)
    local wheel = getWheel(vehicle, wheelIndex)
    if wheel == nil then return vehicle ~= nil and vehicle.rootNode or nil end
    return wheel.repr or wheel.driveNode or vehicle.rootNode
end

local function getWheelRadius(wheel)
    if wheel == nil then return 0.5 end
    local physics = wheel.physics
    return math.max(0.15, tonumber(physics and (physics.radiusOriginal or physics.radius)) or 0.5)
end

local function getWheelWidth(wheel)
    if wheel == nil then return 0.3 end
    local physics = wheel.physics
    if physics ~= nil and tonumber(physics.width) ~= nil then
        return math.max(0.12, physics.width)
    end
    for _, visualWheel in ipairs(wheel.visualWheels or {}) do
        if tonumber(visualWheel.width) ~= nil then
            return math.max(0.12, visualWheel.width)
        end
    end
    return 0.3
end

local function vehicleSpeedKph(vehicle)
    if vehicle == nil then return 0 end
    return math.abs(tonumber(vehicle.lastSpeedReal) or 0) * 3600
end

local function canAccessVehicle(vehicle, connection)
    if vehicle == nil or g_currentMission == nil then return false end
    local ownerFarmId = vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() or vehicle.ownerFarmId
    if ownerFarmId == nil or ownerFarmId == 0 then return true end

    if connection ~= nil and g_currentMission.userManager ~= nil then
        if g_currentMission.userManager:getIsConnectionMasterUser(connection) then return true end
        local user = g_currentMission.userManager:getUserByConnection(connection)
        local farm = user ~= nil and g_farmManager ~= nil and g_farmManager:getFarmByUserId(user:getId()) or nil
        return farm ~= nil and farm.farmId == ownerFarmId
    end

    local localFarmId = g_currentMission.getFarmId ~= nil and g_currentMission:getFarmId() or nil
    return localFarmId == nil or localFarmId == ownerFarmId
end

local function safeDelete(node)
    if nodeExists(node) then pcall(delete, node) end
end

local function findNodeByNameRecursive(root, wantedName)
    if not nodeExists(root) then return nil end
    if getName ~= nil then
        local ok, name = pcall(getName, root)
        if ok and name == wantedName then return root end
    end
    if getNumOfChildren == nil or getChildAt == nil then return nil end
    local ok, count = pcall(getNumOfChildren, root)
    if not ok then return nil end
    for index=0,(count or 0)-1 do
        local okChild, child = pcall(getChildAt, root, index)
        if okChild then
            local found = findNodeByNameRecursive(child, wantedName)
            if found ~= nil then return found end
        end
    end
    return nil
end

local function createJackModel(path, name, modelFilename)
    if loadI3DFile == nil then return nil end
    local filename = Utils.getFilename(modelFilename or "assets/domkrat.i3d", path or "")
    local root = loadI3DFile(filename, false, false, false)
    if not nodeExists(root) then return nil end
    if setName ~= nil then pcall(setName, root, name or "mudSystemJack") end
    pcall(removeFromPhysics, root)
    local piston = findNodeByNameRecursive(root, "cylinder")
    local collision = findNodeByNameRecursive(root, "cube")
    local _, pistonBaseY = 0, 0
    if nodeExists(piston) then
        local x, y = getTranslation(piston)
        pistonBaseY = y or 0
    end
    return root, piston, pistonBaseY, collision
end

local function setJackExtension(stateOrVisual, extension, stroke)
    if stateOrVisual == nil then return end
    extension = clamp(extension, 0, PunctureSystem.jackMaxExtension or 1.5)
    stateOrVisual.extension = extension
    local piston = stateOrVisual.jackPiston or stateOrVisual.piston
    if nodeExists(piston) then
        local x, _, z = getTranslation(piston)
        setTranslation(
            piston, x or 0,
            (stateOrVisual.jackPistonBaseY or stateOrVisual.pistonBaseY or 0)
                + extension*(stroke or 0.34),
            z or 0
        )
    end
    local collision = stateOrVisual.jackCollision or stateOrVisual.collision
    if stateOrVisual.jackCollisionActive == true and nodeExists(collision) then
        -- A kinematic rigid body follows its parent transform, but explicitly
        -- touching the world transform also updates the physics proxy this frame.
        local cx, cy, cz = getWorldTranslation(collision)
        setWorldTranslation(collision, cx, cy, cz)
    end
end

local function setWheelVisible(vehicle, wheelIndex, visible)
    local wheel = getWheel(vehicle, wheelIndex)
    if wheel == nil then return end
    for _, visualWheel in ipairs(wheel.visualWheels or {}) do
        if nodeExists(visualWheel.node) then
            setVisibility(visualWheel.node, visible == true)
        end
    end
end

local function collectShapes(root, out)
    if not nodeExists(root) then return end
    if getHasClassId ~= nil and ClassIds ~= nil and ClassIds.SHAPE ~= nil then
        local ok, isShape = pcall(getHasClassId, root, ClassIds.SHAPE)
        if ok and isShape then out[#out+1] = root end
    end
    if getNumOfChildren == nil or getChildAt == nil then return end
    local ok, count = pcall(getNumOfChildren, root)
    if not ok or count == nil then return end
    for i=0,count-1 do
        local okChild, child = pcall(getChildAt, root, i)
        if okChild then collectShapes(child, out) end
    end
end

local function setPreviewGreen(root)
    local shapes = {}
    collectShapes(root, shapes)
    for _, shape in ipairs(shapes) do
        if getHasShaderParameter ~= nil and getHasShaderParameter(shape, "placeableColorScale") then
            setShaderParameter(shape, "placeableColorScale", 0.12, 1.0, 0.08, 0.62, false)
        end
    end
end

local function setCloneTireInflated(root)
    local shapes = {}
    collectShapes(root, shapes)
    local roadW = TirePressureSystem ~= nil and TirePressureSystem.shaderRoadW or 0.006
    for _, shape in ipairs(shapes) do
        if getHasShaderParameter ~= nil and getHasShaderParameter(shape, "morphPos") then
            local ok, x, y, z = pcall(getShaderParameter, shape, "morphPos")
            if ok and x ~= nil then
                pcall(setShaderParameter, shape, "morphPos", x, y, z, roadW, false)
                if getHasShaderParameter(shape, "prevMorphPos") then
                    pcall(setShaderParameter, shape, "prevMorphPos", x, y, z, roadW, false)
                end
            end
        end
    end
end

local function createWheelClone(vehicle, wheelIndex, name)
    if g_client == nil or createTransformGroup == nil or clone == nil then return nil end
    local wheel = getWheel(vehicle, wheelIndex)
    if wheel == nil then return nil end

    local root = createTransformGroup(name or "mudSystemWheelClone")
    link(getRootNode(), root)

    local visualWheels = wheel.visualWheels or {}
    local centerX, count = 0, 0
    for _, visualWheel in ipairs(visualWheels) do
        if nodeExists(visualWheel.node) then
            local x = select(1, getTranslation(visualWheel.node)) or 0
            centerX = centerX + x
            count = count + 1
        end
    end
    if count > 0 then centerX = centerX / count end

    for _, visualWheel in ipairs(visualWheels) do
        if nodeExists(visualWheel.node) then
            local copy = clone(visualWheel.node, false, false, false)
            if nodeExists(copy) then
                local x, y, z = getTranslation(visualWheel.node)
                local rx, ry, rz = getRotation(visualWheel.node)
                unlink(copy)
                link(root, copy)
                setTranslation(copy, (x or 0)-centerX, y or 0, z or 0)
                setRotation(copy, rx or 0, ry or 0, rz or 0)
                setVisibility(copy, true)
            end
        end
    end

    if getNumOfChildren(root) == 0 and nodeExists(wheel.repr) then
        local copy = clone(wheel.repr, false, false, false)
        if nodeExists(copy) then
            unlink(copy)
            link(root, copy)
            setTranslation(copy, 0, 0, 0)
            setRotation(copy, 0, 0, 0)
            setVisibility(copy, true)
        end
    end

    pcall(removeFromPhysics, root)
    return root
end

local function setPlacedTransform(node, state)
    if not nodeExists(node) or state == nil then return end
    setWorldTranslation(node, state.x or 0, state.y or 0, state.z or 0)
    -- A wheel clone has its axle along local X. A fixed 90-degree Z rotation
    -- points that axle straight up, so the wheel always lies flat on the ground.
    setWorldRotation(node, 0, 0, math.rad(90))
end

local function mountWheelCloneAtHub(node, vehicle, wheelIndex)
    if not nodeExists(node) then return false end
    local wheel = getWheel(vehicle, wheelIndex)
    if wheel == nil then return false end

    local commonParent, centerX, count = nil, 0, 0
    for _, visualWheel in ipairs(wheel.visualWheels or {}) do
        if nodeExists(visualWheel.node) then
            local parent = getParent(visualWheel.node)
            if not nodeExists(parent) or (commonParent ~= nil and commonParent ~= parent) then
                commonParent = nil
                count = 0
                break
            end
            commonParent = parent
            centerX = centerX + (select(1, getTranslation(visualWheel.node)) or 0)
            count = count + 1
        end
    end

    if nodeExists(commonParent) and count > 0 then
        unlink(node)
        link(commonParent, node)
        setTranslation(node, centerX/count, 0, 0)
        setRotation(node, 0, 0, 0)
        setVisibility(node, true)
        return true
    end

    local anchor = wheel.repr or wheel.driveNode
    if not nodeExists(anchor) then return false end
    unlink(node)
    link(getRootNode(), node)
    setWorldTranslation(node, getWorldTranslation(anchor))
    setWorldRotation(node, getWorldRotation(anchor))
    setVisibility(node, true)
    return true
end

local function getCameraData()
    if getCamera == nil then return nil end
    local camera = getCamera()
    if not nodeExists(camera) then return nil end
    local x, y, z = getWorldTranslation(camera)
    local dx, dy, dz = localDirectionToWorld(camera, 0, 0, -1)
    local length = math.sqrt(dx*dx + dy*dy + dz*dz)
    if length < 0.0001 then return nil end
    return camera, x, y, z, dx/length, dy/length, dz/length
end

local function isVehicleCollisionNode(node)
    if not nodeExists(node) then return false end
    if g_currentMission ~= nil and g_currentMission.getNodeObject ~= nil
        and Vehicle ~= nil then
        local object = g_currentMission:getNodeObject(node)
        if object ~= nil and object.isa ~= nil and object:isa(Vehicle) then
            return true
        end
    end
    for _, vehicle in pairs(getMissionVehicles()) do
        if vehicle ~= nil then
            if vehicle.rootNode == node then return true end
            if vehicle.vehicleNodes ~= nil and vehicle.vehicleNodes[node] ~= nil then
                return true
            end
        end
    end
    return false
end

local function isJackCollisionNode(node)
    if not nodeExists(node) then return false end
    local current = node
    while nodeExists(current) do
        if current == PunctureSystem.jackPreviewNode
            or current == PunctureSystem.jackCarriedNode then return true end
        local parent = getParent ~= nil and getParent(current) or nil
        if not nodeExists(parent) or parent == current then break end
        current = parent
    end
    for _, states in pairs(PunctureSystem.states or {}) do
        for _, state in pairs(states) do
            current = node
            while nodeExists(current) do
                if current == state.jackNode then return true end
                local parent = getParent ~= nil and getParent(current) or nil
                if not nodeExists(parent) or parent == current then break end
                current = parent
            end
        end
    end
    return false
end

local function getJackSupportHeight(x, z, startY, maxDistance)
    local terrainY
    if getTerrainHeightAtWorldPos ~= nil and g_terrainNode ~= nil then
        terrainY = getTerrainHeightAtWorldPos(g_terrainNode, x, 0, z)
    end

    if raycastAll ~= nil and CollisionFlag ~= nil then
        startY = tonumber(startY) or ((terrainY or 0)+4)
        maxDistance = math.max(1, tonumber(maxDistance) or 8)
        if terrainY ~= nil then
            maxDistance = math.max(maxDistance, startY-terrainY+1)
        end
        local result = {
            height=nil,
            raycastCallback=function(self, hitObjectId, _, hitY)
                if isVehicleCollisionNode(hitObjectId) or isJackCollisionNode(hitObjectId) then
                    return true
                end
                self.height = hitY
                return false
            end
        }
        local mask = (CollisionFlag.TERRAIN or 0)+(CollisionFlag.ROAD or 0)
            +(CollisionFlag.STATIC_OBJECT or 0)+(CollisionFlag.BUILDING or 0)
        raycastAll(x, startY, z, 0, -1, 0, maxDistance, "raycastCallback", result, mask)
        if result.height ~= nil then return result.height end
    end
    return terrainY
end

local function getLocalCurrentVehicle()
    if g_localPlayer ~= nil and g_localPlayer.getCurrentVehicle ~= nil then
        local vehicle = g_localPlayer:getCurrentVehicle()
        if vehicle ~= nil then return vehicle end
    end
    if g_currentMission ~= nil then
        return g_currentMission.controlledVehicle or g_currentMission.currentVehicle
    end
    return nil
end

local function getGazeScore(cx, cy, cz, dx, dy, dz, tx, ty, tz, radius, maxDistance)
    local vx, vy, vz = tx-cx, ty-cy, tz-cz
    local distance = math.sqrt(vx*vx + vy*vy + vz*vz)
    if distance < 0.05 or distance > maxDistance then return nil end
    local dot = (vx*dx + vy*dy + vz*dz) / distance
    local allowance = math.min(0.35, (radius or 0.5) / math.max(distance, 0.1))
    local minimumDot = math.cos(math.rad(8) + math.asin(math.min(0.90, allowance)))
    if dot < minimumDot then return nil end
    return (1-dot)*10 + distance*0.015
end

function PunctureSystem:getVehicleStates(vehicle, create)
    if vehicle == nil then return nil end
    local states = self.states[vehicle]
    if states == nil and create then
        states = {}
        self.states[vehicle] = states
    end
    return states
end

function PunctureSystem:getWheelState(vehicle, wheelIndex)
    local states = self:getVehicleStates(vehicle, false)
    return states ~= nil and states[wheelIndex] or nil
end

-- The jack belongs to the vehicle repair job, not to an individual wheel.
-- Its visual/network data is kept on one wheel state only so that old saves and
-- the existing events remain compatible without spawning a jack at every tire.
function PunctureSystem:getPlacedJackState(vehicle)
    local states = self:getVehicleStates(vehicle, false)
    local result, resultIndex
    for wheelIndex, state in pairs(states or {}) do
        if state.jackPlaced == true then
            local numericIndex = tonumber(wheelIndex) or math.huge
            if result == nil or numericIndex < resultIndex then
                result, resultIndex = state, numericIndex
            end
        end
    end
    return result
end

function PunctureSystem:getJackPlacementTargetState(vehicle, preferredWheelIndex)
    local preferred = preferredWheelIndex ~= nil
        and self:getWheelState(vehicle, preferredWheelIndex) or nil
    if preferred ~= nil and preferred.stage == self.STAGE_PUNCTURED then
        return preferred
    end

    local states = self:getVehicleStates(vehicle, false)
    local result, resultIndex
    for wheelIndex, state in pairs(states or {}) do
        if state.stage == self.STAGE_PUNCTURED then
            local numericIndex = tonumber(wheelIndex) or math.huge
            if result == nil or numericIndex < resultIndex then
                result, resultIndex = state, numericIndex
            end
        end
    end
    return result
end

function PunctureSystem:isWheelPunctured(vehicle, wheelIndex)
    if self.enabled ~= true then return false end
    local state = self:getWheelState(vehicle, wheelIndex)
    return state ~= nil and state.stage ~= self.STAGE_NONE
end

function PunctureSystem:playSoundAtState(soundKey, state, preferredNode)
    if MudSystemWorldSounds == nil or state == nil then return end
    if nodeExists(preferredNode) then
        MudSystemWorldSounds:playOneShotAtNode(soundKey, preferredNode)
    else
        MudSystemWorldSounds:playOneShotAtPosition(
            soundKey, state.x or 0, state.y or 0, state.z or 0
        )
    end
end

function PunctureSystem:applySoundAction(vehicle, wheelIndex, soundId)
    if MudSystemWorldSounds == nil or vehicle == nil then return end
    wheelIndex = math.floor(tonumber(wheelIndex) or 0)
    soundId = math.floor(tonumber(soundId) or 0)
    local state = self:getWheelState(vehicle, wheelIndex)
    local wheelNode = getWheelSoundNode(vehicle, wheelIndex)
    local jackTag = "jack:" .. tostring(wheelIndex)

    if soundId == self.SOUND_PUNCTURE then
        if nodeExists(wheelNode) then
            MudSystemWorldSounds:playOneShotAtNode("puncture", wheelNode)
        elseif state ~= nil then
            self:playSoundAtState("puncture", state, nil)
        end
    elseif soundId == self.SOUND_JACK_PLACE and state ~= nil then
        self:playSoundAtState("jackPlace", state, state.jackNode)
    elseif soundId == self.SOUND_WHEEL_REMOVE and state ~= nil then
        self:playSoundAtState("wheelRemove", state, wheelNode)
    elseif soundId == self.SOUND_WHEEL_REPAIR and state ~= nil then
        self:playSoundAtState("wheelRepair", state, state.placedNode)
    elseif soundId == self.SOUND_WHEEL_INSTALL and state ~= nil then
        self:playSoundAtState("wheelInstall", state, wheelNode)
    elseif soundId == self.SOUND_JACK_START and state ~= nil then
        MudSystemWorldSounds:startLoop(
            vehicle, jackTag, "jackWork", state.jackNode,
            {x=state.jackX or 0, y=state.jackY or 0, z=state.jackZ or 0}
        )
    elseif soundId == self.SOUND_JACK_STOP then
        MudSystemWorldSounds:stopLoop(vehicle, jackTag)
    end
end

function PunctureSystem:handleClientSoundRequest(connection, vehicle, wheelIndex, soundId)
    wheelIndex = math.floor(tonumber(wheelIndex) or 0)
    soundId = math.floor(tonumber(soundId) or 0)
    if self.enabled ~= true or getWheel(vehicle, wheelIndex) == nil
        or not canAccessVehicle(vehicle, connection) then return false end
    local state = self:getWheelState(vehicle, wheelIndex)
    local allowed = false

    if soundId == self.SOUND_JACK_PLACE then
        allowed = state ~= nil and state.stage == self.STAGE_PUNCTURED and state.jackPlaced == true
    elseif soundId == self.SOUND_WHEEL_REMOVE then
        allowed = state ~= nil and state.stage == self.STAGE_PUNCTURED
            and vehicleSpeedKph(vehicle) <= 0.5
            and (self.requireJack ~= true or self:isJackReady(state))
    elseif soundId == self.SOUND_WHEEL_REPAIR then
        allowed = state ~= nil and state.stage == self.STAGE_PLACED and state.repaired ~= true
    elseif soundId == self.SOUND_WHEEL_INSTALL then
        allowed = state ~= nil and state.stage == self.STAGE_HELD and state.repaired == true
            and vehicleSpeedKph(vehicle) <= 0.5
    elseif soundId == self.SOUND_JACK_START or soundId == self.SOUND_JACK_STOP then
        allowed = state ~= nil and state.jackPlaced == true
    end
    if not allowed then return false end

    self:applySoundAction(vehicle, wheelIndex, soundId)
    g_server:broadcastEvent(
        PunctureSoundEvent.new(vehicle, wheelIndex, soundId), nil, connection, vehicle
    )
    return true
end

function PunctureSystem:setLocalJackWorkSound(active, state)
    local isActive = self._localJackWorkSound == true
    if active == true and not isActive and state ~= nil then
        self._localJackWorkSound = true
        self._localJackWorkSoundVehicle = state.vehicle
        self._localJackWorkSoundWheelIndex = state.wheelIndex
        PunctureSoundEvent.send(state.vehicle, state.wheelIndex, self.SOUND_JACK_START)
    elseif active ~= true and isActive then
        local vehicle = self._localJackWorkSoundVehicle
        local wheelIndex = self._localJackWorkSoundWheelIndex
        self._localJackWorkSound = false
        self._localJackWorkSoundVehicle = nil
        self._localJackWorkSoundWheelIndex = nil
        if vehicle ~= nil then
            PunctureSoundEvent.send(vehicle, wheelIndex, self.SOUND_JACK_STOP)
        end
    end
end

function PunctureSystem:getWheelFrictionMultiplier(wheelPhysics)
    if self.enabled ~= true or wheelPhysics == nil then return 1 end
    local vehicle = wheelPhysics.vehicle
    local wheel = wheelPhysics.wheel
    local wheels = getVehicleWheels(vehicle)
    if vehicle == nil or wheel == nil or wheels == nil then return 1 end
    for index, candidate in ipairs(wheels) do
        if candidate == wheel then
            local state = self:getWheelState(vehicle, index)
            if state ~= nil then
                if state.stage == self.STAGE_PUNCTURED then
                    return self.puncturedFrictionMultiplier or 0.34
                elseif state.stage == self.STAGE_HELD or state.stage == self.STAGE_PLACED then
                    return self.removedFrictionMultiplier or 0.06
                end
            end
            break
        end
    end
    return 1
end

function PunctureSystem:destroyPlacedVisual(state)
    if state ~= nil and state.placedNode ~= nil then
        safeDelete(state.placedNode)
        state.placedNode = nil
    end
end

function PunctureSystem:destroyJackVisual(state)
    if state ~= nil and state.jackNode ~= nil then
        if state.jackCollisionActive == true and nodeExists(state.jackCollision)
            and removeFromPhysics ~= nil then
            pcall(removeFromPhysics, state.jackCollision)
        end
        safeDelete(state.jackNode)
        state.jackNode = nil
        state.jackPiston = nil
        state.jackCollision = nil
        state.jackCollisionActive = false
    end
end

function PunctureSystem:createJackVisual(state)
    if state == nil or state.jackPlaced ~= true
        or (g_client == nil and g_server == nil) then return end
    self:destroyJackVisual(state)
    state.jackNode, state.jackPiston, state.jackPistonBaseY, state.jackCollision = createJackModel(
        self.path, "mudSystemPlacedJack", self.jackModel
    )
    if nodeExists(state.jackNode) then
        unlink(state.jackNode)
        link(getRootNode(), state.jackNode)
        setWorldTranslation(state.jackNode, state.jackX or 0, state.jackY or 0, state.jackZ or 0)
        setWorldRotation(state.jackNode, 0, state.jackRotY or 0, 0)
        setVisibility(state.jackNode, true)
        setJackExtension(state, state.jackExtension or 0, self.jackVisualStroke)
        if g_server ~= nil and nodeExists(state.jackCollision) and addToPhysics ~= nil then
            if setRigidBodyType ~= nil and RigidBodyType ~= nil
                and RigidBodyType.KINEMATIC ~= nil then
                pcall(setRigidBodyType, state.jackCollision, RigidBodyType.KINEMATIC)
            end
            pcall(addToPhysics, state.jackCollision)
            state.jackCollisionActive = true
        end
    end
end

function PunctureSystem:clearOtherVehicleJacks(vehicle, exceptWheelIndex)
    local states = self:getVehicleStates(vehicle, false)
    for wheelIndex, state in pairs(states or {}) do
        if wheelIndex ~= exceptWheelIndex and state.jackPlaced == true then
            if self.jackPanel ~= nil and self.jackPanel.vehicle == vehicle
                and self.jackPanel.wheelIndex == wheelIndex then
                self:setJackPanelOpen(false)
            end
            self:destroyJackVisual(state)
            state.jackPlaced = false
            state.jackX, state.jackY, state.jackZ = 0, 0, 0
            state.jackRotY, state.jackExtension = 0, 0
            if MudSystemWorldSounds ~= nil then
                MudSystemWorldSounds:stopLoop(vehicle, "jack:" .. tostring(wheelIndex))
            end
        end
    end
end

function PunctureSystem:transferJackState(source, target)
    if source == nil or target == nil or source == target or source.jackPlaced ~= true then
        return false
    end

    if target.jackPlaced == true then self:destroyJackVisual(target) end
    target.jackPlaced = true
    target.jackX, target.jackY, target.jackZ = source.jackX, source.jackY, source.jackZ
    target.jackRotY, target.jackExtension = source.jackRotY, source.jackExtension
    target.jackNode, target.jackPiston = source.jackNode, source.jackPiston
    target.jackPistonBaseY = source.jackPistonBaseY
    target.jackCollision = source.jackCollision
    target.jackCollisionActive = source.jackCollisionActive == true

    source.jackPlaced = false
    source.jackX, source.jackY, source.jackZ = 0, 0, 0
    source.jackRotY, source.jackExtension = 0, 0
    source.jackNode, source.jackPiston, source.jackPistonBaseY = nil, nil, nil
    source.jackCollision, source.jackCollisionActive = nil, false

    if self.jackPanel ~= nil and self.jackPanel.vehicle == source.vehicle
        and self.jackPanel.wheelIndex == source.wheelIndex then
        self.jackPanel.wheelIndex = target.wheelIndex
    end
    if MudSystemWorldSounds ~= nil then
        MudSystemWorldSounds:stopLoop(source.vehicle, "jack:" .. tostring(source.wheelIndex))
    end
    return true
end

function PunctureSystem:clearLocalJackCarryVisuals()
    safeDelete(self.jackCarriedNode)
    safeDelete(self.jackPreviewNode)
    self.jackCarriedNode = nil
    self.jackCarriedPiston = nil
    self.jackPreviewNode = nil
    self.jackPreviewPiston = nil
    self.jackPreviewValid = false
    self.jackPreviewPosition = nil
end

function PunctureSystem:createLocalJackCarryVisuals()
    self:clearLocalJackCarryVisuals()
    self.jackCarriedNode, self.jackCarriedPiston, self.jackCarriedPistonBaseY =
        createJackModel(self.path, "mudSystemCarriedJack", self.jackModel)
    self.jackPreviewNode, self.jackPreviewPiston, self.jackPreviewPistonBaseY =
        createJackModel(self.path, "mudSystemJackPlacementPreview", self.jackModel)
    if nodeExists(self.jackPreviewNode) then
        setPreviewGreen(self.jackPreviewNode)
        setVisibility(self.jackPreviewNode, false)
    end
end

function PunctureSystem:clearLocalCarryVisuals()
    safeDelete(self.carriedNode)
    safeDelete(self.previewNode)
    self.carriedNode = nil
    self.previewNode = nil
    self.previewValid = false
    self.previewAtHub = false
    self.previewPosition = nil
end

function PunctureSystem:createLocalCarryVisuals(vehicle, wheelIndex, repaired)
    self:clearLocalCarryVisuals()
    if self.showCarriedWheel == true then
        self.carriedNode = createWheelClone(vehicle, wheelIndex, "mudSystemCarriedWheel")
    end
    self.previewNode = createWheelClone(vehicle, wheelIndex, "mudSystemWheelPlacementPreview")
    if repaired == true then
        setCloneTireInflated(self.carriedNode)
        setCloneTireInflated(self.previewNode)
    end
    if nodeExists(self.previewNode) then
        setPreviewGreen(self.previewNode)
        setVisibility(self.previewNode, false)
    end
end

function PunctureSystem:createPlacedVisual(state)
    if state == nil or state.stage ~= self.STAGE_PLACED then return end
    self:destroyPlacedVisual(state)
    state.placedNode = createWheelClone(state.vehicle, state.wheelIndex, "mudSystemPlacedWheel")
    if state.repaired == true then setCloneTireInflated(state.placedNode) end
    setPlacedTransform(state.placedNode, state)
end

function PunctureSystem:getNormalPressure01(vehicle, wheel)
    if TirePressureSystem ~= nil and TirePressureSystem.enabled == true
        and TirePressureSystem.getWheelPressure01 ~= nil and wheel ~= nil and wheel.physics ~= nil then
        return TirePressureSystem:getWheelPressure01(wheel.physics)
    end
    return 1
end

function PunctureSystem:applyWheelEffects(vehicle, wheelIndex, state)
    local wheel = getWheel(vehicle, wheelIndex)
    if wheel == nil or wheel.physics == nil then return end
    local physics = wheel.physics
    physics.__punctureOrigRadius = physics.__punctureOrigRadius
        or physics.__tpOrigRadius or physics.__mpOrigRadius or physics.__fgOrigRadius
        or physics.__uytOrigRadius or physics.radiusOriginal or physics.radius
    local originalRadius = physics.__punctureOrigRadius

    if self.enabled == true and state ~= nil then
        if state.stage == self.STAGE_PUNCTURED then
            physics.__punctureDesiredRadius = originalRadius * (self.puncturedRadiusFactor or 0.82)
            if TirePressureSystem ~= nil and TirePressureSystem.applyWheelVisualPressure ~= nil then
                TirePressureSystem:applyWheelVisualPressure(wheel, 0)
            end
        elseif state.stage == self.STAGE_HELD or state.stage == self.STAGE_PLACED then
            physics.__punctureDesiredRadius = originalRadius * (self.removedRadiusFactor or 0.35)
        end
    else
        physics.__punctureDesiredRadius = nil
        if TirePressureSystem ~= nil and TirePressureSystem.applyWheelVisualPressure ~= nil then
            TirePressureSystem:applyWheelVisualPressure(wheel, self:getNormalPressure01(vehicle, wheel))
        end
    end

    if _G.__MudRadiusCombiner ~= nil and _G.__MudRadiusCombiner.apply ~= nil then
        _G.__MudRadiusCombiner.apply(physics, 0.001)
    end
    physics.isFrictionDirty = true
end

function PunctureSystem:removeStateLocal(vehicle, wheelIndex)
    local states = self:getVehicleStates(vehicle, false)
    local old = states ~= nil and states[wheelIndex] or nil
    local jackTransferred = false
    if old ~= nil then
        self:destroyPlacedVisual(old)
        if old.jackPlaced == true then
            local target
            local targetIndex
            for candidateWheelIndex, candidate in pairs(states or {}) do
                if candidateWheelIndex ~= wheelIndex then
                    local priority = candidate.stage == self.STAGE_PUNCTURED and 0 or 1
                    local numericIndex = tonumber(candidateWheelIndex) or math.huge
                    local candidateIndex = priority*1000000+numericIndex
                    if target == nil or candidateIndex < targetIndex then
                        target, targetIndex = candidate, candidateIndex
                    end
                end
            end
            jackTransferred = self:transferJackState(old, target)
        end
        if not jackTransferred then self:destroyJackVisual(old) end
    end

    if self.localCarried ~= nil and self.localCarried.vehicle == vehicle
        and self.localCarried.wheelIndex == wheelIndex then
        self:clearLocalCarryVisuals()
        self.localCarried = nil
    end

    if self.localJackCarried ~= nil and self.localJackCarried.vehicle == vehicle
        and self.localJackCarried.wheelIndex == wheelIndex then
        self:clearLocalJackCarryVisuals()
        self.localJackCarried = nil
    end
    if not jackTransferred and self.jackPanel ~= nil and self.jackPanel.vehicle == vehicle
        and self.jackPanel.wheelIndex == wheelIndex then
        self:setJackPanelOpen(false)
    end

    if states ~= nil then
        states[wheelIndex] = nil
        if next(states) == nil then self.states[vehicle] = nil end
    end
    local repairTimers = self.aiAutoRepairTimers[vehicle]
    if repairTimers ~= nil then
        repairTimers[wheelIndex] = nil
        if next(repairTimers) == nil then self.aiAutoRepairTimers[vehicle] = nil end
    end
    if MudSystemWorldSounds ~= nil then
        MudSystemWorldSounds:stopLoop(vehicle, "jack:" .. tostring(wheelIndex))
    end
    setWheelVisible(vehicle, wheelIndex, true)
    self:applyWheelEffects(vehicle, wheelIndex, nil)
end

function PunctureSystem:showAIStateNotification(vehicle, repaired)
    if g_client == nil or g_currentMission == nil then return end
    local localFarmId = g_localPlayer ~= nil and g_localPlayer.farmId
        or (g_currentMission.player ~= nil and g_currentMission.player.farmId) or nil
    local vehicleFarmId = getVehicleFarmId(vehicle)
    if localFarmId ~= nil and vehicleFarmId ~= nil and vehicleFarmId ~= 0
        and localFarmId ~= vehicleFarmId then return end

    local helperName = tostring(getAIHelperName(vehicle) or "")
    local vehicleName = vehicle ~= nil and vehicle.getName ~= nil and vehicle:getName()
        or (vehicle ~= nil and vehicle.typeName) or "vehicle"
    local text
    local notificationType = FSBaseMission ~= nil
        and FSBaseMission.INGAME_NOTIFICATION_CRITICAL or nil
    if repaired == true then
        local cost = math.max(0, tonumber(self.aiAutoRepairCost) or 10)
        local formattedCost = g_i18n ~= nil and g_i18n.formatMoney ~= nil
            and g_i18n:formatMoney(cost, 0, true, true) or string.format("$%.0f", cost)
        text = string.format(
            getText("mudsys_puncture_ai_repaired", "%s repaired a tire on %s for %s and continues working"),
            helperName, tostring(vehicleName), tostring(formattedCost)
        )
        notificationType = FSBaseMission ~= nil
            and FSBaseMission.INGAME_NOTIFICATION_INFO or notificationType
    elseif self.aiAutoRepairEnabled == true then
        text = string.format(
            getText("mudsys_puncture_ai_warning_auto", "%s punctured a tire on %s and stopped for repairs"),
            helperName, tostring(vehicleName)
        )
    else
        text = string.format(
            getText("mudsys_puncture_ai_warning_manual", "%s punctured a tire on %s and stopped. Repair it manually"),
            helperName, tostring(vehicleName)
        )
    end

    if g_currentMission.addIngameNotification ~= nil and notificationType ~= nil then
        g_currentMission:addIngameNotification(notificationType, text)
    elseif g_currentMission.showBlinkingWarning ~= nil then
        g_currentMission:showBlinkingWarning(text, 5000)
    end
end

function PunctureSystem:applyNetworkState(vehicle, wheelIndex, stage, repaired, x, y, z, rotY, isLocalInitiator, aiEvent)
    wheelIndex = math.floor(tonumber(wheelIndex) or 0)
    stage = math.floor(tonumber(stage) or 0)
    if getWheel(vehicle, wheelIndex) == nil then return end

    if aiEvent == true then
        self:showAIStateNotification(vehicle, stage == self.STAGE_NONE and repaired == true)
    end

    if stage == self.STAGE_NONE then
        self:removeStateLocal(vehicle, wheelIndex)
        if repaired == true and isPuncturableWheel(vehicle, wheelIndex)
            and TirePressureSystem ~= nil and TirePressureSystem.setWheelTargetLocal ~= nil then
            TirePressureSystem:setWheelTargetLocal(
                vehicle, wheelIndex,
                TirePressureSystem.roadPressure or TirePressureSystem.maxPressure or 2.4
            )
        end
        return
    end

    -- Never accept a punctured/removed state for tracked vehicles, including an
    -- old savegame entry or a state received from another multiplayer client.
    if isCrawlerVehicle(vehicle) then
        self:removeStateLocal(vehicle, wheelIndex)
        return
    end

    -- Reject stale saves, foreign events and client requests for invisible
    -- implement support wheels. STAGE_NONE above remains allowed for cleanup.
    if not isPuncturableWheel(vehicle, wheelIndex) then
        self:removeStateLocal(vehicle, wheelIndex)
        return
    end

    local states = self:getVehicleStates(vehicle, true)
    local old = states[wheelIndex]
    if old ~= nil then
        self:destroyPlacedVisual(old)
    end

    if self.localCarried ~= nil and self.localCarried.vehicle == vehicle
        and self.localCarried.wheelIndex == wheelIndex and stage ~= self.STAGE_HELD then
        self:clearLocalCarryVisuals()
        self.localCarried = nil
    end

    local state = {
        vehicle = vehicle,
        wheelIndex = wheelIndex,
        stage = stage,
        repaired = repaired == true,
        x = tonumber(x) or 0,
        y = tonumber(y) or 0,
        z = tonumber(z) or 0,
        rotY = tonumber(rotY) or 0,
        jackPlaced = old ~= nil and old.jackPlaced == true,
        jackX = old ~= nil and old.jackX or 0,
        jackY = old ~= nil and old.jackY or 0,
        jackZ = old ~= nil and old.jackZ or 0,
        jackRotY = old ~= nil and old.jackRotY or 0,
        jackExtension = old ~= nil and old.jackExtension or 0,
        jackNode = old ~= nil and old.jackNode or nil,
        jackPiston = old ~= nil and old.jackPiston or nil,
        jackPistonBaseY = old ~= nil and old.jackPistonBaseY or nil,
        jackCollision = old ~= nil and old.jackCollision or nil,
        jackCollisionActive = old ~= nil and old.jackCollisionActive == true
    }
    states[wheelIndex] = state

    if stage == self.STAGE_PUNCTURED and TirePressureSystem ~= nil
        and TirePressureSystem.setWheelTargetLocal ~= nil then
        TirePressureSystem:setWheelTargetLocal(
            vehicle, wheelIndex,
            TirePressureSystem.minPressure or 0.8
        )
    end

    local removed = stage == self.STAGE_HELD or stage == self.STAGE_PLACED
    setWheelVisible(vehicle, wheelIndex, not removed)
    self:applyWheelEffects(vehicle, wheelIndex, state)

    if stage == self.STAGE_PLACED then
        if self.localCarried ~= nil and self.localCarried.vehicle == vehicle
            and self.localCarried.wheelIndex == wheelIndex then
            self:clearLocalCarryVisuals()
            self.localCarried = nil
        end
        self:createPlacedVisual(state)
    elseif stage == self.STAGE_HELD and isLocalInitiator == true and g_client ~= nil then
        self.localCarried = {vehicle=vehicle, wheelIndex=wheelIndex}
        self:createLocalCarryVisuals(vehicle, wheelIndex, state.repaired)
    end
    if state.jackPlaced == true and not nodeExists(state.jackNode) then
        self:createJackVisual(state)
    end
    if old ~= nil and old.stage == self.STAGE_HELD and stage == self.STAGE_PLACED
        and MudSystemWorldSounds ~= nil then
        self:playSoundAtState("wheelPlace", state, state.placedNode)
    end

    if old == nil and stage == self.STAGE_PUNCTURED and g_client ~= nil
        and g_currentMission ~= nil and g_currentMission.showBlinkingWarning ~= nil then
        local notify = false
        local controlled = getLocalCurrentVehicle()
        if controlled ~= nil then
            local root = controlled.getRootVehicle ~= nil and controlled:getRootVehicle() or controlled
            for _, child in pairs(root.getChildVehicles ~= nil and root:getChildVehicles() or {root}) do
                if child == vehicle then notify = true break end
            end
        end
        if not notify and nodeExists(vehicle.rootNode) and getCamera ~= nil and nodeExists(getCamera()) then
            notify = calcDistanceFrom(getCamera(), vehicle.rootNode) <= 30
        end
        if notify then
            g_currentMission:showBlinkingWarning(getText("mudsys_puncture_warning", "A tire has been punctured"), 4000)
        end
    end
end

function PunctureSystem:isJackReady(state)
    local jackState = state ~= nil and self:getPlacedJackState(state.vehicle) or nil
    return jackState ~= nil
        and (jackState.jackExtension or 0) >= (self.jackReadyThreshold or 0.72)
end

function PunctureSystem:applyJackNetworkState(vehicle, wheelIndex, placed, x, y, z, rotY, extension)
    wheelIndex = math.floor(tonumber(wheelIndex) or 0)
    local state = self:getWheelState(vehicle, wheelIndex)
    if state == nil then return end

    if self.requireJack ~= true then
        placed = false
        extension = 0
    end

    -- A vehicle can have only one physical jack. Receiving a new placement also
    -- cleans duplicate jacks left by an older save or a previous mod version.
    if placed == true then
        self:clearOtherVehicleJacks(vehicle, wheelIndex)
    end

    if placed == true and state.jackPlaced ~= true then
        local supportY = getJackSupportHeight(
            tonumber(x) or 0, tonumber(z) or 0, (tonumber(y) or 0)+3, 10
        )
        if supportY ~= nil then y = supportY+0.015 end
    end

    local samePlacement = state.jackPlaced == true and placed == true
        and math.abs((state.jackX or 0)-(tonumber(x) or 0)) < 0.01
        and math.abs((state.jackY or 0)-(tonumber(y) or 0)) < 0.01
        and math.abs((state.jackZ or 0)-(tonumber(z) or 0)) < 0.01
    if not samePlacement then self:destroyJackVisual(state) end
    state.jackPlaced = placed == true
    state.jackX = tonumber(x) or 0
    state.jackY = tonumber(y) or 0
    state.jackZ = tonumber(z) or 0
    state.jackRotY = tonumber(rotY) or 0
    state.jackExtension = clamp(extension, 0, self.jackMaxExtension or 1.5)

    if state.jackPlaced then
        if samePlacement and nodeExists(state.jackNode) then
            setJackExtension(state, state.jackExtension, self.jackVisualStroke)
        else
            self:createJackVisual(state)
        end
    elseif self.jackPanel ~= nil and self.jackPanel.vehicle == vehicle
        and self.jackPanel.wheelIndex == wheelIndex then
        self:setJackPanelOpen(false)
    end
    if not state.jackPlaced and MudSystemWorldSounds ~= nil then
        MudSystemWorldSounds:stopLoop(vehicle, "jack:" .. tostring(wheelIndex))
    end

    if self.localJackCarried ~= nil and self.localJackCarried.vehicle == vehicle
        and state.jackPlaced then
        self:clearLocalJackCarryVisuals()
        self.localJackCarried = nil
    end
end

function PunctureSystem:sendJackStateToConnection(connection, vehicle, wheelIndex)
    if connection == nil or PunctureJackEvent == nil then return end
    local state = self:getWheelState(vehicle, wheelIndex)
    connection:sendEvent(PunctureJackEvent.new(
        vehicle, wheelIndex, self.requireJack == true and state ~= nil and state.jackPlaced == true,
        state ~= nil and state.jackX or 0, state ~= nil and state.jackY or 0,
        state ~= nil and state.jackZ or 0, state ~= nil and state.jackRotY or 0,
        state ~= nil and state.jackExtension or 0
    ))
end

function PunctureSystem:handleClientJackRequest(connection, vehicle, wheelIndex, placed, x, y, z, rotY, extension)
    local state = self:getWheelState(vehicle, wheelIndex)
    if self.enabled ~= true or self.requireJack ~= true
        or state == nil or getWheel(vehicle, wheelIndex) == nil
        or not canAccessVehicle(vehicle, connection) then return false end
    if vehicleSpeedKph(vehicle) > 0.5 then return false end

    x, y, z = tonumber(x) or 0, tonumber(y) or 0, tonumber(z) or 0
    rotY = tonumber(rotY) or 0
    extension = clamp(extension, 0, self.jackMaxExtension or 1.5)
    if placed == true and state.jackPlaced ~= true then
        if state.stage ~= self.STAGE_PUNCTURED then return false end
        if self:getPlacedJackState(vehicle) ~= nil then return false end
        local wx, _, wz = getWheelWorldPosition(vehicle, wheelIndex)
        local distance = wx ~= nil and math.sqrt((x-wx)^2+(z-wz)^2) or math.huge
        if distance > (self.jackPlacementMaxDistance or 2.35) then return false end
        local supportY = getJackSupportHeight(x, z, y+3, 10)
        if supportY == nil then return false end
        y = supportY+0.015
    elseif placed == true and state.jackPlaced == true then
        -- Once placed, clients may only operate its piston, not teleport the jack.
        x, y, z, rotY = state.jackX, state.jackY, state.jackZ, state.jackRotY
    elseif placed ~= true then
        if state.jackPlaced ~= true then return false end
        x, y, z, rotY, extension = 0, 0, 0, 0, 0
    end

    local finalPlaced = placed == true
    self:applyJackNetworkState(vehicle, wheelIndex, finalPlaced, x, y, z, rotY, extension)
    g_server:broadcastEvent(
        PunctureJackEvent.new(vehicle, wheelIndex, finalPlaced, x, y, z, rotY, extension),
        nil, connection, vehicle
    )
    return true
end

function PunctureSystem:getStateSnapshot(vehicle, wheelIndex)
    local state = self:getWheelState(vehicle, wheelIndex)
    if state == nil then
        return self.STAGE_NONE, false, 0, 0, 0, 0
    end
    return state.stage, state.repaired == true, state.x or 0, state.y or 0, state.z or 0, state.rotY or 0
end

function PunctureSystem:sendWheelStateToConnection(connection, vehicle, wheelIndex)
    if connection == nil or PunctureStateEvent == nil then return end
    local stage, repaired, x, y, z, rotY = self:getStateSnapshot(vehicle, wheelIndex)
    connection:sendEvent(PunctureStateEvent.new(vehicle, wheelIndex, stage, repaired, x, y, z, rotY))
    self:sendJackStateToConnection(connection, vehicle, wheelIndex)
end

function PunctureSystem:sendFullState(connection)
    if connection == nil then return end
    for vehicle, states in pairs(self.states) do
        for wheelIndex, state in pairs(states) do
            if isPuncturableWheel(vehicle, wheelIndex) then
                connection:sendEvent(PunctureStateEvent.new(
                    vehicle, wheelIndex, state.stage, state.repaired,
                    state.x, state.y, state.z, state.rotY
                ))
                self:sendJackStateToConnection(connection, vehicle, wheelIndex)
            end
        end
    end
end

function PunctureSystem:isAllowedTransition(oldState, stage, repaired)
    if oldState == nil then return stage == self.STAGE_PUNCTURED end
    if stage == oldState.stage and repaired == oldState.repaired then return true end
    if oldState.stage == self.STAGE_PUNCTURED then
        return stage == self.STAGE_HELD and repaired ~= true
    elseif oldState.stage == self.STAGE_HELD then
        return stage == self.STAGE_PLACED or (stage == self.STAGE_NONE and oldState.repaired == true)
    elseif oldState.stage == self.STAGE_PLACED then
        if stage == self.STAGE_HELD then return true end
        if stage == self.STAGE_PLACED and oldState.repaired ~= true and repaired == true then return true end
    end
    return false
end

function PunctureSystem:handleClientStateRequest(connection, vehicle, wheelIndex, stage, repaired, x, y, z, rotY)
    if self.enabled ~= true or getWheel(vehicle, wheelIndex) == nil
        or not canAccessVehicle(vehicle, connection) then return false end
    if stage ~= self.STAGE_NONE and not isPuncturableWheel(vehicle, wheelIndex) then return false end
    local oldState = self:getWheelState(vehicle, wheelIndex)
    if oldState == nil and stage == self.STAGE_PUNCTURED
        and isVehicleAIActive(vehicle) and self.aiPuncturesEnabled ~= true then
        return false
    end
    if oldState ~= nil and oldState.stage == self.STAGE_HELD
        and oldState.carrierConnection ~= nil and oldState.carrierConnection ~= connection then
        return false
    end
    if not self:isAllowedTransition(oldState, stage, repaired == true) then return false end
    if oldState ~= nil and oldState.stage == self.STAGE_PUNCTURED and vehicleSpeedKph(vehicle) > 0.5 then
        return false
    end
    if self.requireJack == true and oldState ~= nil and oldState.stage == self.STAGE_PUNCTURED
        and stage == self.STAGE_HELD and not self:isJackReady(oldState) then
        return false
    end

    self:applyNetworkState(vehicle, wheelIndex, stage, repaired, x, y, z, rotY, false)
    local state = self:getWheelState(vehicle, wheelIndex)
    if state ~= nil and stage == self.STAGE_HELD then state.carrierConnection = connection end

    g_server:broadcastEvent(
        PunctureStateEvent.new(vehicle, wheelIndex, stage, repaired, x, y, z, rotY),
        nil, connection, vehicle
    )
    return true
end

function PunctureSystem:getPlacementPosition()
    local camera, cx, cy, cz, dx, _, dz = getCameraData()
    if camera == nil then return nil end
    local horizontalLength = math.sqrt(dx*dx + dz*dz)
    if horizontalLength < 0.001 then return nil end
    dx, dz = dx/horizontalLength, dz/horizontalLength
    local x, z = cx + dx*2.4, cz + dz*2.4
    local hitY
    if RaycastUtil ~= nil and RaycastUtil.raycastClosest ~= nil and CollisionFlag ~= nil then
        local mask = (CollisionFlag.TERRAIN or 0) + (CollisionFlag.ROAD or 0)
            + (CollisionFlag.STATIC_OBJECT or 0) + (CollisionFlag.BUILDING or 0)
        local _, _, y = RaycastUtil.raycastClosest(x, cy+2.0, z, 0, -1, 0, 7, mask)
        hitY = y
    end
    if hitY == nil and getTerrainHeightAtWorldPos ~= nil and g_terrainNode ~= nil then
        hitY = getTerrainHeightAtWorldPos(g_terrainNode, x, 0, z)
    end
    if hitY == nil then return nil end

    local carried = self.localCarried
    local wheel = carried ~= nil and getWheel(carried.vehicle, carried.wheelIndex) or nil
    local y = hitY + getWheelWidth(wheel)*0.50 + 0.015
    return x, y, z, 0
end

function PunctureSystem:getJackPlacementPosition()
    local carried = self.localJackCarried
    if carried == nil then return nil end
    local camera, cx, cy, cz, dx, _, dz = getCameraData()
    if camera == nil then return nil end
    local horizontalLength = math.sqrt(dx*dx+dz*dz)
    if horizontalLength < 0.001 then return nil end
    dx, dz = dx/horizontalLength, dz/horizontalLength
    local x, z = cx+dx*1.65, cz+dz*1.65
    local hitY = getJackSupportHeight(x, z, cy+1.5, 8)
    if hitY == nil then return nil end

    -- While carrying the shared jack, allow it to be moved beside any still
    -- punctured wheel on this vehicle. The nearest wheel becomes its new owner.
    local targetState, targetDistance
    local states = self:getVehicleStates(carried.vehicle, false)
    for wheelIndex, state in pairs(states or {}) do
        if state.stage == self.STAGE_PUNCTURED then
            local wx, _, wz = getWheelWorldPosition(carried.vehicle, wheelIndex)
            if wx ~= nil then
                local distance = math.sqrt((x-wx)^2+(z-wz)^2)
                if targetDistance == nil or distance < targetDistance then
                    targetState, targetDistance = state, distance
                end
            end
        end
    end
    if targetState == nil
        or targetDistance > (self.jackPlacementMaxDistance or 2.35) then return nil end
    local rotY = math.atan2(dx, dz)+math.pi
    return x, hitY+0.015, z, rotY, targetState.wheelIndex
end

function PunctureSystem:updateJackCarryVisuals()
    if self.localJackCarried == nil then
        self.jackPreviewValid = false
        if nodeExists(self.jackPreviewNode) then setVisibility(self.jackPreviewNode, false) end
        return
    end

    local camera = getCamera ~= nil and getCamera() or nil
    if nodeExists(self.jackCarriedNode) and nodeExists(camera) then
        if getParent(self.jackCarriedNode) ~= camera then
            unlink(self.jackCarriedNode)
            link(camera, self.jackCarriedNode)
        end
        setTranslation(self.jackCarriedNode, 0.35, -0.48, -0.92)
        setRotation(self.jackCarriedNode, 0, math.rad(18), 0)
        setVisibility(self.jackCarriedNode, true)
    end

    local x, y, z, rotY, wheelIndex = self:getJackPlacementPosition()
    self.jackPreviewValid = x ~= nil
    if self.jackPreviewValid then
        self.jackPreviewPosition = {x=x, y=y, z=z, rotY=rotY, wheelIndex=wheelIndex}
        if nodeExists(self.jackPreviewNode) then
            unlink(self.jackPreviewNode)
            link(getRootNode(), self.jackPreviewNode)
            setWorldTranslation(self.jackPreviewNode, x, y, z)
            setWorldRotation(self.jackPreviewNode, 0, rotY, 0)
            setVisibility(self.jackPreviewNode, true)
        end
    else
        self.jackPreviewPosition = nil
        if nodeExists(self.jackPreviewNode) then setVisibility(self.jackPreviewNode, false) end
    end
end

function PunctureSystem:updateCarryVisuals()
    if self.localCarried == nil then
        self.previewValid = false
        self.previewAtHub = false
        if nodeExists(self.previewNode) then setVisibility(self.previewNode, false) end
        return
    end

    local camera = getCamera ~= nil and getCamera() or nil
    if nodeExists(self.carriedNode) and nodeExists(camera) then
        if getParent(self.carriedNode) ~= camera then
            unlink(self.carriedNode)
            link(camera, self.carriedNode)
        end
        local carriedWheel = getWheel(self.localCarried.vehicle, self.localCarried.wheelIndex)
        local radius = getWheelRadius(carriedWheel)
        -- Keep the wheel close to the player's torso and slightly below the
        -- crosshair. Larger wheels are moved only a little farther away.
        setTranslation(
            self.carriedNode,
            0.43 + math.min(radius, 1.4)*0.07,
            -0.58 - math.min(radius, 1.4)*0.10,
            -1.15 - math.min(radius, 1.4)*0.22
        )
        setRotation(self.carriedNode, 0, math.rad(90), 0)
    end

    local state = self:getWheelState(self.localCarried.vehicle, self.localCarried.wheelIndex)
    local cameraData = {getCameraData()}
    local installing = self.repairing ~= nil and self.repairing.kind == "install"
        and self.repairing.vehicle == self.localCarried.vehicle
        and self.repairing.wheelIndex == self.localCarried.wheelIndex
    local snapToHub = state ~= nil and state.repaired == true
        and (installing or (cameraData[1] ~= nil
            and self:findGazedHub(state.vehicle, state.wheelIndex, cameraData)))
    if snapToHub and mountWheelCloneAtHub(
        self.previewNode, self.localCarried.vehicle, self.localCarried.wheelIndex
    ) then
        self.previewValid = true
        self.previewAtHub = true
        self.previewPosition = nil
        return
    end

    self.previewAtHub = false
    local x, y, z, rotY = self:getPlacementPosition()
    self.previewValid = x ~= nil
    if self.previewValid then
        self.previewPosition = {x=x, y=y, z=z, rotY=rotY}
        if nodeExists(self.previewNode) then
            unlink(self.previewNode)
            link(getRootNode(), self.previewNode)
            setWorldTranslation(self.previewNode, x, y, z)
            setWorldRotation(self.previewNode, 0, 0, math.rad(90))
            setVisibility(self.previewNode, true)
        end
    elseif nodeExists(self.previewNode) then
        setVisibility(self.previewNode, false)
        self.previewPosition = nil
    end
end

function PunctureSystem:findGazedHub(vehicle, wheelIndex, cameraData)
    local _, cx, cy, cz, dx, dy, dz = unpack(cameraData)
    local x, y, z = getWheelWorldPosition(vehicle, wheelIndex)
    if x == nil then return false end
    local wheel = getWheel(vehicle, wheelIndex)
    return getGazeScore(cx, cy, cz, dx, dy, dz, x, y, z, getWheelRadius(wheel), self.interactionDistance) ~= nil
end

function PunctureSystem:findGazedJack(state, cameraData)
    if state == nil or state.jackPlaced ~= true then return false end
    local _, cx, cy, cz, dx, dy, dz = unpack(cameraData)
    local y = (state.jackY or 0)+0.42+(state.jackExtension or 0)*0.16
    return getGazeScore(
        cx, cy, cz, dx, dy, dz,
        state.jackX or 0, y, state.jackZ or 0,
        0.58, self.interactionDistance
    ) ~= nil
end

function PunctureSystem:updateInteractionTarget()
    self.currentAction = nil
    self.currentTarget = nil
    if self.enabled ~= true or g_client == nil or g_currentMission == nil
        or g_localPlayer == nil or getLocalCurrentVehicle() ~= nil
        or self.repairing ~= nil or self.jackPanel ~= nil then
        return
    end

    local cameraData = {getCameraData()}
    if cameraData[1] == nil then return end
    local _, cx, cy, cz, dx, dy, dz = unpack(cameraData)

    if self.localJackCarried ~= nil then
        local vehicle = self.localJackCarried.vehicle
        local state = self:getJackPlacementTargetState(vehicle, self.localJackCarried.wheelIndex)
        local previewState = self.jackPreviewPosition ~= nil
            and self:getWheelState(vehicle, self.jackPreviewPosition.wheelIndex) or nil
        if state == nil or self:getPlacedJackState(vehicle) ~= nil then
            self:clearLocalJackCarryVisuals()
            self.localJackCarried = nil
        elseif self.jackPreviewValid and previewState ~= nil
            and previewState.stage == self.STAGE_PUNCTURED then
            self.currentAction = "placeJack"
            self.currentTarget = previewState
        end
        return
    end

    if self.localCarried ~= nil then
        local state = self:getWheelState(self.localCarried.vehicle, self.localCarried.wheelIndex)
        if state == nil or state.stage ~= self.STAGE_HELD then
            self:clearLocalCarryVisuals()
            self.localCarried = nil
            return
        end
        if state.repaired == true and self:findGazedHub(state.vehicle, state.wheelIndex, cameraData) then
            self.currentAction = "install"
            self.currentTarget = state
        elseif self.previewValid then
            self.currentAction = "place"
            self.currentTarget = state
        end
        return
    end

    local bestScore, bestAction, bestState
    for vehicle, states in pairs(self.states) do
        local canAccess = canAccessVehicle(vehicle, nil)
        local sharedJack = canAccess and self:getPlacedJackState(vehicle) or nil
        local sharedJackReady = sharedJack ~= nil
            and (sharedJack.jackExtension or 0) >= (self.jackReadyThreshold or 0.72)
        for wheelIndex, state in pairs(states) do
            local tx, ty, tz, radius, action
            if canAccess and state.stage == self.STAGE_PUNCTURED then
                if self.requireJack ~= true then
                    tx, ty, tz = getWheelWorldPosition(vehicle, wheelIndex)
                    radius = getWheelRadius(getWheel(vehicle, wheelIndex))
                    action = "remove"
                elseif sharedJack == nil then
                    tx, ty, tz = getWheelWorldPosition(vehicle, wheelIndex)
                    radius = getWheelRadius(getWheel(vehicle, wheelIndex))
                    action = "takeJack"
                elseif sharedJackReady then
                    tx, ty, tz = getWheelWorldPosition(vehicle, wheelIndex)
                    radius = getWheelRadius(getWheel(vehicle, wheelIndex))
                    action = "remove"
                end
            elseif canAccess and state.stage == self.STAGE_PLACED then
                tx, ty, tz = state.x, state.y, state.z
                radius = getWheelRadius(getWheel(vehicle, wheelIndex))
                action = state.repaired and "pickup" or "repair"
            end
            if tx ~= nil and action ~= nil then
                local score = getGazeScore(cx, cy, cz, dx, dy, dz, tx, ty, tz, radius, self.interactionDistance)
                if score ~= nil and (bestScore == nil or score < bestScore) then
                    bestScore, bestAction, bestState = score, action, state
                end
            end

            -- Jack controls remain available even when the wheel that originally
            -- owned the jack has already been removed or placed on the ground.
            if canAccess and self.requireJack == true and state.jackPlaced == true then
                local jackY = (state.jackY or 0)+0.42+(state.jackExtension or 0)*0.16
                local jackScore = getGazeScore(
                    cx, cy, cz, dx, dy, dz,
                    state.jackX or 0, jackY, state.jackZ or 0,
                    0.58, self.interactionDistance
                )
                if jackScore ~= nil and (bestScore == nil or jackScore < bestScore) then
                    bestScore, bestAction, bestState = jackScore, "operateJack", state
                end
            end
        end
    end
    self.currentAction = bestAction
    self.currentTarget = bestState
end

function PunctureSystem:getActionText()
    if self.currentAction == "takeJack" then return getText("mudsys_puncture_takeJack", "Take jack") end
    if self.currentAction == "placeJack" then return getText("mudsys_puncture_placeJack", "Place jack") end
    if self.currentAction == "operateJack" then return getText("mudsys_puncture_operateJack", "Operate jack") end
    if self.currentAction == "remove" then return getText("mudsys_puncture_remove", "Remove punctured wheel") end
    if self.currentAction == "place" then return getText("mudsys_puncture_place", "Place wheel") end
    if self.currentAction == "repair" then return getText("mudsys_puncture_repair", "Unseat and patch tire") end
    if self.currentAction == "pickup" then return getText("mudsys_puncture_pickup", "Pick up repaired wheel") end
    if self.currentAction == "install" then return getText("mudsys_puncture_install", "Install wheel") end
    return ""
end

function PunctureSystem:onInteract()
    local action, state = self.currentAction, self.currentTarget
    if action == nil or state == nil then return end

    if action == "takeJack" then
        if vehicleSpeedKph(state.vehicle) > 0.5 then
            g_currentMission:showBlinkingWarning(getText("mudsys_puncture_needStop", "Stop the vehicle first"), 2500)
            return
        end
        if self:getPlacedJackState(state.vehicle) ~= nil then return end
        self.localJackCarried = {vehicle=state.vehicle, wheelIndex=state.wheelIndex}
        self:createLocalJackCarryVisuals()
    elseif action == "placeJack" and self.jackPreviewPosition ~= nil then
        local p = self.jackPreviewPosition
        PunctureJackEvent.send(
            state.vehicle, state.wheelIndex, true,
            p.x, p.y, p.z, p.rotY, 0
        )
        PunctureSoundEvent.send(state.vehicle, state.wheelIndex, self.SOUND_JACK_PLACE)
    elseif action == "operateJack" then
        self:setJackPanelOpen(true, state)
    elseif action == "remove" then
        if vehicleSpeedKph(state.vehicle) > 0.5 then
            g_currentMission:showBlinkingWarning(getText("mudsys_puncture_needStop", "Stop the vehicle first"), 2500)
            return
        end
        if self.requireJack == true and not self:isJackReady(state) then
            g_currentMission:showBlinkingWarning(
                getText("mudsys_puncture_needJackRaised", "Raise the vehicle with the jack first"), 2500
            )
            return
        end
        self.repairing = {
            kind="remove",
            vehicle=state.vehicle,
            wheelIndex=state.wheelIndex,
            remaining=self.removalDurationMs,
            total=self.removalDurationMs,
            uiElapsed=0
        }
        self.operationHudOutro = nil
        self.currentAction = nil
        self.currentTarget = nil
        PunctureSoundEvent.send(state.vehicle, state.wheelIndex, self.SOUND_WHEEL_REMOVE)
        self:enforcePlayerMovementBlock()
    elseif action == "place" and self.previewPosition ~= nil then
        local p = self.previewPosition
        PunctureStateEvent.send(state.vehicle, state.wheelIndex, self.STAGE_PLACED, state.repaired, p.x, p.y, p.z, p.rotY)
    elseif action == "repair" then
        self.repairing = {
            kind="repair",
            vehicle=state.vehicle,
            wheelIndex=state.wheelIndex,
            remaining=self.repairDurationMs,
            total=self.repairDurationMs,
            uiElapsed=0
        }
        self.operationHudOutro = nil
        self.currentAction = nil
        self.currentTarget = nil
        PunctureSoundEvent.send(state.vehicle, state.wheelIndex, self.SOUND_WHEEL_REPAIR)
        self:enforcePlayerMovementBlock()
    elseif action == "pickup" then
        PunctureStateEvent.send(state.vehicle, state.wheelIndex, self.STAGE_HELD, true, state.x, state.y, state.z, state.rotY)
    elseif action == "install" and state.repaired == true then
        if vehicleSpeedKph(state.vehicle) > 0.5 then
            g_currentMission:showBlinkingWarning(getText("mudsys_puncture_needStop", "Stop the vehicle first"), 2500)
            return
        end
        self.repairing = {
            kind="install",
            vehicle=state.vehicle,
            wheelIndex=state.wheelIndex,
            remaining=self.installationDurationMs,
            total=self.installationDurationMs,
            uiElapsed=0
        }
        self.operationHudOutro = nil
        self.currentAction = nil
        self.currentTarget = nil
        PunctureSoundEvent.send(state.vehicle, state.wheelIndex, self.SOUND_WHEEL_INSTALL)
        self:enforcePlayerMovementBlock()
    end
end

function PunctureSystem:updateJackPanelCameraInputBlock()
    if self.jackPanel == nil or g_inputBinding == nil or InputAction == nil then return end
    local contextName = PlayerInputComponent ~= nil and PlayerInputComponent.INPUT_CONTEXT_NAME or "PLAYER"
    local context = g_inputBinding.contexts ~= nil and g_inputBinding.contexts[contextName] or nil
    if context == nil or context.actionEvents == nil or g_inputBinding.nameActions == nil then return end
    self._jackCameraEventStates = self._jackCameraEventStates or {}
    for _, actionName in ipairs({
        InputAction.AXIS_LOOK_UPDOWN_PLAYER,
        InputAction.AXIS_LOOK_LEFTRIGHT_PLAYER
    }) do
        local action = actionName ~= nil and g_inputBinding.nameActions[actionName] or nil
        for _, event in ipairs(action ~= nil and context.actionEvents[action] or {}) do
            if event.id ~= nil and self._jackCameraEventStates[event] == nil then
                self._jackCameraEventStates[event] = {
                    eventId=event.id,
                    wasActive=event.isActive == true
                }
            end
            if event.id ~= nil and event.isActive == true
                and g_inputBinding.setActionEventActive ~= nil then
                g_inputBinding:setActionEventActive(event.id, false)
            end
        end
    end
end

function PunctureSystem:restoreJackPanelCameraInput()
    local saved = self._jackCameraEventStates
    self._jackCameraEventStates = nil
    if saved == nil or g_inputBinding == nil or g_inputBinding.events == nil
        or g_inputBinding.setActionEventActive == nil then return end
    for event, data in pairs(saved) do
        if g_inputBinding.events[data.eventId] == event then
            g_inputBinding:setActionEventActive(data.eventId, data.wasActive == true)
        end
    end
end

function PunctureSystem:setJackPanelOpen(open, state)
    local willOpen = open == true
    if willOpen then
        if state == nil or state.jackPlaced ~= true then return end
        if TirePressureSystem ~= nil and TirePressureSystem.panelOpen == true
            and TirePressureSystem.setPanelOpen ~= nil then
            TirePressureSystem:setPanelOpen(false)
        end
        self.jackPanel = {vehicle=state.vehicle, wheelIndex=state.wheelIndex}
        self._jackHoldDirection = 0
        self:setLocalJackWorkSound(false)
        self:updateJackPanelCameraInputBlock()
        if g_inputBinding ~= nil and g_inputBinding.setShowMouseCursor ~= nil then
            self._mouseCursorStateBeforeJack = g_inputBinding.getShowMouseCursor ~= nil
                and g_inputBinding:getShowMouseCursor() == true or false
            g_inputBinding:setShowMouseCursor(true, true)
        end
        self:enforcePlayerMovementBlock()
    elseif self.jackPanel ~= nil then
        self:setLocalJackWorkSound(false)
        self.jackPanel = nil
        self._jackHoldDirection = 0
        self:restoreJackPanelCameraInput()
        if g_inputBinding ~= nil and g_inputBinding.setShowMouseCursor ~= nil then
            g_inputBinding:setShowMouseCursor(self._mouseCursorStateBeforeJack == true, true)
        end
        self._mouseCursorStateBeforeJack = nil
    end
end

function PunctureSystem:getJackPanelRects()
    return {
        card={x=0.735, y=0.215, w=0.220, h=0.500},
        up={x=0.785, y=0.485, w=0.120, h=0.075},
        down={x=0.785, y=0.390, w=0.120, h=0.075},
        done={x=0.785, y=0.310, w=0.120, h=0.050},
        relocate={x=0.785, y=0.240, w=0.120, h=0.050}
    }
end

local function pointInRect(x, y, rect)
    return rect ~= nil and x >= rect.x and x <= rect.x+rect.w
        and y >= rect.y and y <= rect.y+rect.h
end

function PunctureSystem:setLocalJackExtension(extension, forceSend)
    local panel = self.jackPanel
    if panel == nil then return end
    local state = self:getWheelState(panel.vehicle, panel.wheelIndex)
    if state == nil or state.jackPlaced ~= true then
        self:setJackPanelOpen(false)
        return
    end
    extension = clamp(extension, 0, self.jackMaxExtension or 1.5)
    if math.abs(extension-(state.jackExtension or 0)) < 0.002 and forceSend ~= true then return end
    state.jackExtension = extension
    setJackExtension(state, extension, self.jackVisualStroke)

    local now = tonumber(g_time) or 0
    if forceSend == true or now-(self._lastJackSyncTime or -10000) >= (self.jackSyncIntervalMs or 90) then
        self._lastJackSyncTime = now
        PunctureJackEvent.send(
            state.vehicle, state.wheelIndex, true,
            state.jackX, state.jackY, state.jackZ, state.jackRotY, extension
        )
    else
        self._pendingJackExtension = extension
    end
end

function PunctureSystem:mouseEvent(posX, posY, isDown, isUp, button)
    if self.jackPanel == nil then return end
    if button ~= nil and Input ~= nil and button ~= Input.MOUSE_BUTTON_LEFT then return end
    local rects = self:getJackPanelRects()
    local leftButton = button == nil or Input == nil or button == Input.MOUSE_BUTTON_LEFT
    if isDown and leftButton then
        if pointInRect(posX, posY, rects.relocate) then
            local panel = self.jackPanel
            local state = panel ~= nil and self:getWheelState(panel.vehicle, panel.wheelIndex) or nil
            if state == nil or state.jackPlaced ~= true then
                self:setJackPanelOpen(false)
                return
            end
            if vehicleSpeedKph(state.vehicle) > 0.5 then
                if g_currentMission ~= nil and g_currentMission.showBlinkingWarning ~= nil then
                    g_currentMission:showBlinkingWarning(
                        getText("mudsys_puncture_needStop", "Stop the vehicle first"), 2500
                    )
                end
                return
            end
            local vehicle, wheelIndex = state.vehicle, state.wheelIndex
            local targetState = self:getJackPlacementTargetState(vehicle, wheelIndex)
            self._pendingJackExtension = nil
            self._jackHoldDirection = 0
            self:setLocalJackWorkSound(false)
            PunctureJackEvent.send(vehicle, wheelIndex, false, 0, 0, 0, 0, 0)
            if targetState ~= nil and self:getPlacedJackState(vehicle) == nil then
                self.localJackCarried = {
                    vehicle=vehicle,
                    wheelIndex=targetState.wheelIndex
                }
                self:createLocalJackCarryVisuals()
            end
            return
        end
        if pointInRect(posX, posY, rects.done) then
            self:setLocalJackExtension(
                (self:getWheelState(self.jackPanel.vehicle, self.jackPanel.wheelIndex) or {}).jackExtension or 0,
                true
            )
            self:setJackPanelOpen(false)
            return
        end
        if pointInRect(posX, posY, rects.up) then
            self._jackHoldDirection = 1
        elseif pointInRect(posX, posY, rects.down) then
            self._jackHoldDirection = -1
        end
    end
    if isUp and leftButton then
        local panel = self.jackPanel
        local state = panel ~= nil and self:getWheelState(panel.vehicle, panel.wheelIndex) or nil
        self._jackHoldDirection = 0
        self:setLocalJackWorkSound(false)
        if state ~= nil then self:setLocalJackExtension(state.jackExtension or 0, true) end
    end
end

function PunctureSystem:updateJackPanelOperation(dt)
    local direction = tonumber(self._jackHoldDirection) or 0
    if self.jackPanel == nil or direction == 0 then return end
    local state = self:getWheelState(self.jackPanel.vehicle, self.jackPanel.wheelIndex)
    if state == nil then
        self:setLocalJackWorkSound(false)
        return
    end
    local delta = direction*(self.jackExtensionRatePerSecond or 0.20)*(math.max(0, dt or 0)/1000)
    local oldExtension = state.jackExtension or 0
    local newExtension = clamp(oldExtension+delta, 0, self.jackMaxExtension or 1.5)
    local moving = math.abs(newExtension-oldExtension) >= 0.00001
    self:setLocalJackWorkSound(moving, state)
    if moving then self:setLocalJackExtension(newExtension, false) end
end

PunctureWheelActivatable = {}
local PunctureWheelActivatable_mt = Class(PunctureWheelActivatable)

function PunctureWheelActivatable.new(system)
    local self = setmetatable({}, PunctureWheelActivatable_mt)
    self.system = system
    self.activateText = ""
    return self
end

function PunctureWheelActivatable:getIsActivatable()
    self.activateText = self.system:getActionText()
    return self.system.currentAction ~= nil
end

function PunctureWheelActivatable:getHasAccess()
    return true
end

function PunctureWheelActivatable:getDistance()
    return self.system.currentAction ~= nil and 0 or math.huge
end

function PunctureWheelActivatable:run()
    self.system:onInteract()
end

function PunctureSystem:registerActivatable()
    if self.activatableRegistered or g_currentMission == nil
        or g_currentMission.activatableObjectsSystem == nil then return end
    self.activatable = PunctureWheelActivatable.new(self)
    self.activatable.activateText = self:getActionText()
    g_currentMission.activatableObjectsSystem:addActivatable(self.activatable)
    self.activatableRegistered = true
end

function PunctureSystem:unregisterActivatable()
    if self.activatableRegistered and g_currentMission ~= nil
        and g_currentMission.activatableObjectsSystem ~= nil then
        g_currentMission.activatableObjectsSystem:removeActivatable(self.activatable)
    end
    self.activatableRegistered = false
    self.activatable = nil
end

function PunctureSystem:hasAnyState(vehicle)
    local states = self:getVehicleStates(vehicle, false)
    return states ~= nil and next(states) ~= nil
end

function PunctureSystem:hasForeignPuncture(vehicle)
    if vehicle == nil then return false end
    local spec = vehicle.spec_puncture or vehicle.spec_FS25_Puncture
    return spec ~= nil and spec.active == true
end

function PunctureSystem:isCrawlerVehicle(vehicle)
    return isCrawlerVehicle(vehicle)
end

function PunctureSystem:isWheelPuncturable(vehicle, wheelIndex)
    return isPuncturableWheel(vehicle, wheelIndex)
end

function PunctureSystem:getPuncturableWheelIndices(vehicle)
    return getPuncturableWheelIndices(vehicle)
end

function PunctureSystem:isVehicleAIActive(vehicle)
    return isVehicleAIActive(vehicle)
end

function PunctureSystem:getPunctureEligibility(vehicle)
    if isCrawlerVehicle(vehicle) then return false, "crawler" end
    if isVehicleAIActive(vehicle) and self.aiPuncturesEnabled ~= true then
        return false, "aiDisabled"
    end
    if not hasPuncturableWheel(vehicle) then return false, "noVisualTire" end
    return true, nil
end

function PunctureSystem:stopAIForPuncture(vehicle)
    local root = getActiveAIVehicle(vehicle)
    if root == nil or root.stopCurrentAIJob == nil then return false end
    local message = AIMessageErrorBlockedByObject ~= nil
        and AIMessageErrorBlockedByObject.new ~= nil
        and AIMessageErrorBlockedByObject.new() or nil
    local ok = pcall(root.stopCurrentAIJob, root, message)
    return ok
end

function PunctureSystem:triggerPuncture(vehicle, wheelIndex)
    if self.enabled ~= true or not isPuncturableWheel(vehicle, wheelIndex) then return false end
    if not self:getPunctureEligibility(vehicle) then return false end
    if self:getWheelState(vehicle, wheelIndex) ~= nil then return false end
    local aiVehicle = getActiveAIVehicle(vehicle)
    local x, y, z = getWheelWorldPosition(vehicle, wheelIndex)
    PunctureStateEvent.send(
        vehicle, wheelIndex, self.STAGE_PUNCTURED, false, x, y, z, 0, aiVehicle ~= nil
    )
    PunctureSoundEvent.send(vehicle, wheelIndex, self.SOUND_PUNCTURE)

    if aiVehicle ~= nil then
        if self.aiAutoRepairEnabled == true then
            -- Stop immediately without ending the job. The WheelsUtil hook keeps
            -- the brakes applied until the puncture state is cleared.
            if aiVehicle.brake ~= nil then pcall(aiVehicle.brake, aiVehicle, 1) end
            if aiVehicle.stopVehicle ~= nil then pcall(aiVehicle.stopVehicle, aiVehicle) end
        else
            self:stopAIForPuncture(aiVehicle)
        end
    end

    return true
end

function PunctureSystem:getWheelPunctureRiskMultiplier(vehicle, wheel)
    local compatibility = rawget(_G, "UseYourTyresCompatibility")
    if compatibility ~= nil and compatibility.active == true
        and compatibility.getPunctureRiskMultiplier ~= nil then
        local ok, multiplier, wear = pcall(
            compatibility.getPunctureRiskMultiplier,
            compatibility, vehicle, wheel
        )
        if ok and type(multiplier) == "number" then
            return clamp(multiplier, 1, 50), wear
        end
    end
    return 1, nil
end

function PunctureSystem:selectWeightedPunctureWheel(candidates, totalWeight)
    if candidates == nil or #candidates == 0 then return nil end
    if type(totalWeight) ~= "number" or totalWeight <= 0 then
        return candidates[math.random(1, #candidates)].index
    end

    local roll = math.random() * totalWeight
    local accumulated = 0
    for _, candidate in ipairs(candidates) do
        accumulated = accumulated + (candidate.weight or 1)
        if roll <= accumulated then return candidate.index end
    end
    return candidates[#candidates].index
end

function PunctureSystem:updateRandomPunctures(dt)
    if g_server == nil or self.enabled ~= true or g_currentMission == nil then return end
    self._randomAccumulatedDt = (self._randomAccumulatedDt or 0) + (dt or 0)
    self._randomUpdateTimer = (self._randomUpdateTimer or 0) - (dt or 0)
    if self._randomUpdateTimer > 0 then return end
    local sampledDt = self._randomAccumulatedDt
    self._randomAccumulatedDt = 0
    self._randomUpdateTimer = 250

    for _, vehicle in pairs(getMissionVehicles()) do
        local wheels = not punctureVehicleDeleting(vehicle)
            and getVehicleWheels(vehicle) or nil
        if wheels ~= nil and #wheels > 0 and vehicle.spec_locomotive == nil then
            local runtime = self.travel[vehicle]
            local eligible = self:getPunctureEligibility(vehicle)
            local blocked = not eligible or self:hasAnyState(vehicle) or self:hasForeignPuncture(vehicle)
            if blocked then
                if runtime ~= nil then
                    runtime.forcedRemaining = nil
                    runtime.forcedWheelIndex = nil
                end
            else
                local movedDistance = math.abs(tonumber(vehicle.lastSpeedReal) or 0) * sampledDt
                if runtime ~= nil and runtime.forcedRemaining ~= nil then
                    -- The kilometre test counts actual movement at any non-zero speed and
                    -- suppresses the normal random roll until its guaranteed puncture.
                    runtime.forcedRemaining = math.max(0, runtime.forcedRemaining-movedDistance)
                    if runtime.forcedRemaining <= 0 then
                        local wheelIndex = runtime.forcedWheelIndex
                        runtime.forcedRemaining = nil
                        runtime.forcedWheelIndex = nil
                        runtime.distance = 0
                        self:triggerPuncture(vehicle, wheelIndex)
                    end
                elseif vehicleSpeedKph(vehicle) >= 5 then
                    if runtime == nil then
                        runtime = {distance=0}
                        self.travel[vehicle] = runtime
                    end
                    runtime.distance = (runtime.distance or 0) + movedDistance
                    if runtime.distance >= 100 then
                        local distance = runtime.distance
                        runtime.distance = 0
                        local candidates = {}
                        local totalWeight = 0
                        for index, wheel in ipairs(wheels) do
                            if wheelHasVisualTire(wheel) then
                                local multiplier, wear = self:getWheelPunctureRiskMultiplier(vehicle, wheel)
                                multiplier = clamp(multiplier, 1, 50)
                                totalWeight = totalWeight + multiplier
                                candidates[#candidates+1] = {
                                    index=index,
                                    weight=multiplier,
                                    wear=wear
                                }
                            end
                        end

                        -- The selected chance applies to the selected distance
                        -- interval. A 10% chance over 5 km therefore remains
                        -- mathematically consistent when evaluated every 100 m.
                        local averageRisk = #candidates > 0 and (totalWeight/#candidates) or 1
                        local chanceDistanceM = math.max(1, self.chanceDistanceKm or 1) * 1000
                        local probability = 1 - math.pow(
                            1-clamp(self.chancePerKm, 0.01, 0.50),
                            averageRisk * distance/chanceDistanceM
                        )
                        if math.random() < probability then
                            if #candidates > 0 then
                                local wheelIndex = self:selectWeightedPunctureWheel(candidates, totalWeight)
                                self:triggerPuncture(vehicle, wheelIndex)
                            end
                        end
                    end
                end
            end
        end
    end
end

function PunctureSystem:updateAIAutoRepairs(dt)
    if g_server == nil then return end
    if self.enabled ~= true or self.aiAutoRepairEnabled ~= true then
        self.aiAutoRepairTimers = setmetatable({}, {__mode="k"})
        if self.enabled == true and self.aiAutoRepairEnabled ~= true then
            self._aiManualStopTimer = (self._aiManualStopTimer or 0) - (dt or 0)
            if self._aiManualStopTimer <= 0 then
                self._aiManualStopTimer = 250
                local stopped = setmetatable({}, {__mode="k"})
                for stateVehicle, states in pairs(self.states) do
                    local root = not punctureVehicleDeleting(stateVehicle)
                        and getActiveAIVehicle(stateVehicle) or nil
                    if root ~= nil and stopped[root] ~= true then
                        for _, state in pairs(states) do
                            if state.stage == self.STAGE_PUNCTURED then
                                stopped[root] = true
                                self:stopAIForPuncture(root)
                                break
                            end
                        end
                    end
                end
            end
        end
        return
    end
    self._aiManualStopTimer = 0

    local completed = {}
    for vehicle, states in pairs(self.states) do
        local aiVehicle = not punctureVehicleDeleting(vehicle)
            and getActiveAIVehicle(vehicle) or nil
        if aiVehicle ~= nil and not isCrawlerVehicle(vehicle) then
            -- AIDrivable treats five seconds without movement as being stuck.
            -- Auto-repair is an intentional pause, so it must not dismiss or
            -- block the helper while the ten-second repair timer is running.
            local aiDrivable = aiVehicle.spec_aiDrivable
            if aiDrivable ~= nil then aiDrivable.stuckTime = 0 end
            local timers = self.aiAutoRepairTimers[vehicle]
            if timers == nil then
                timers = {}
                self.aiAutoRepairTimers[vehicle] = timers
            end
            for wheelIndex, state in pairs(states) do
                if state.stage == self.STAGE_PUNCTURED then
                    local remaining = timers[wheelIndex]
                    if remaining == nil then remaining = self.aiAutoRepairDelayMs or 10000 end
                    remaining = remaining-(dt or 0)
                    timers[wheelIndex] = remaining
                    if remaining <= 0 then
                        completed[#completed+1] = {
                            vehicle=vehicle, wheelIndex=wheelIndex, aiVehicle=aiVehicle
                        }
                    end
                else
                    timers[wheelIndex] = nil
                end
            end
        else
            self.aiAutoRepairTimers[vehicle] = nil
        end
    end

    for _, item in ipairs(completed) do
        local state = self:getWheelState(item.vehicle, item.wheelIndex)
        if not punctureVehicleDeleting(item.vehicle)
            and state ~= nil and state.stage == self.STAGE_PUNCTURED
            and getActiveAIVehicle(item.vehicle) ~= nil and not isCrawlerVehicle(item.vehicle) then
            local cost = math.max(0, tonumber(self.aiAutoRepairCost) or 10)
            local farmId = getVehicleFarmId(item.aiVehicle or item.vehicle)
            if cost > 0 and farmId ~= nil and farmId ~= 0
                and g_currentMission ~= nil and g_currentMission.addMoney ~= nil then
                local moneyType = MoneyType ~= nil
                    and (MoneyType.VEHICLE_REPAIR or MoneyType.OTHER) or nil
                g_currentMission:addMoney(-cost, farmId, moneyType, true, true)
            end
            PunctureStateEvent.send(
                item.vehicle, item.wheelIndex, self.STAGE_NONE, true, 0, 0, 0, 0, true
            )
            local timers = self.aiAutoRepairTimers[item.vehicle]
            if timers ~= nil then timers[item.wheelIndex] = nil end
            local name = item.vehicle.getName ~= nil and item.vehicle:getName()
                or item.vehicle.typeName or "vehicle"
            print(string.format(
                "[MudSystemPhysics] AI auto-repaired %s wheel %d for $%.0f",
                tostring(name), item.wheelIndex, cost
            ))
        end
    end
end

function PunctureSystem:shouldBlockAIForPuncture(vehicle)
    if self.enabled ~= true or self.aiAutoRepairEnabled ~= true
        or punctureVehicleDeleting(vehicle) then return false end
    local root = getActiveAIVehicle(vehicle)
    if root == nil then return false end
    for stateVehicle, states in pairs(self.states) do
        if not punctureVehicleDeleting(stateVehicle)
            and getActiveAIVehicle(stateVehicle) == root then
            for _, state in pairs(states) do
                if state.stage == self.STAGE_PUNCTURED then return true end
            end
        end
    end
    return false
end

function PunctureSystem:updateDisconnectedCarriers(dt)
    if g_server == nil then return end
    self._carrierCheckTimer = (self._carrierCheckTimer or 0) - (dt or 0)
    if self._carrierCheckTimer > 0 then return end
    self._carrierCheckTimer = 1000

    local drops = {}
    for vehicle, states in pairs(self.states) do
        if not punctureVehicleDeleting(vehicle) then
            for wheelIndex, state in pairs(states) do
                if state.stage == self.STAGE_HELD and state.carrierConnection ~= nil
                    and state.carrierConnection.isConnected == false then
                    local x, z = state.x or 0, state.z or 0
                    local groundY
                    if RaycastUtil ~= nil and CollisionFlag ~= nil then
                        local mask = (CollisionFlag.TERRAIN or 0) + (CollisionFlag.ROAD or 0)
                            + (CollisionFlag.STATIC_OBJECT or 0) + (CollisionFlag.BUILDING or 0)
                        local _, _, y = RaycastUtil.raycastClosest(x, (state.y or 0)+3, z, 0, -1, 0, 10, mask)
                        groundY = y
                    end
                    if groundY == nil and getTerrainHeightAtWorldPos ~= nil and g_terrainNode ~= nil then
                        groundY = getTerrainHeightAtWorldPos(g_terrainNode, x, 0, z)
                    end
                    drops[#drops+1] = {
                        vehicle=vehicle,
                        wheelIndex=wheelIndex,
                        repaired=state.repaired,
                        x=x,
                        y=(groundY or state.y or 0)+getWheelWidth(getWheel(vehicle, wheelIndex))*0.52,
                        z=z,
                        rotY=state.rotY or 0
                    }
                end
            end
        end
    end
    for _, drop in ipairs(drops) do
        PunctureStateEvent.send(
            drop.vehicle, drop.wheelIndex, self.STAGE_PLACED, drop.repaired,
            drop.x, drop.y, drop.z, drop.rotY
        )
    end
end

function PunctureSystem:isMovementBlockedForInput(inputComponent)
    return (self.repairing ~= nil or self.jackPanel ~= nil) and inputComponent ~= nil
        and g_localPlayer ~= nil and inputComponent.player == g_localPlayer
end

function PunctureSystem:enforcePlayerMovementBlock()
    if (self.repairing == nil and self.jackPanel == nil) or g_localPlayer == nil then return end
    local input = g_localPlayer.inputComponent
    if input == nil then return end

    input.moveForward = 0
    input.moveRight = 0
    input.walkAxis = 0
    input.runAxis = 0
    input.flightAxis = 0
    input.jumpPower = 0
    input.hasMovementInputs = false
    if input.setMovementDirection ~= nil then
        input:setMovementDirection(0, 0)
    else
        input.worldDirectionX = 0
        input.worldDirectionZ = 0
    end

    local mover = g_localPlayer.mover
    if mover ~= nil then
        mover.currentVelocityX = 0
        mover.currentVelocityZ = 0
        mover.currentSpeed = 0
    end
end

function PunctureSystem:updateRepair(dt)
    local operation = self.repairing
    if operation == nil then return end
    self:enforcePlayerMovementBlock()
    operation.uiElapsed = (operation.uiElapsed or 0) + (dt or 0)

    local state = self:getWheelState(operation.vehicle, operation.wheelIndex)
    local kind = operation.kind or "repair"
    local valid
    if kind == "remove" then
        valid = state ~= nil and state.stage == self.STAGE_PUNCTURED
            and vehicleSpeedKph(operation.vehicle) <= 0.5
            and (self.requireJack ~= true or self:isJackReady(state))
    elseif kind == "install" then
        valid = state ~= nil and state.stage == self.STAGE_HELD and state.repaired == true
            and vehicleSpeedKph(operation.vehicle) <= 0.5
            and self.localCarried ~= nil
            and self.localCarried.vehicle == operation.vehicle
            and self.localCarried.wheelIndex == operation.wheelIndex
    else
        valid = state ~= nil and state.stage == self.STAGE_PLACED and state.repaired ~= true
    end
    if getLocalCurrentVehicle() ~= nil then valid = false end
    if not valid then
        self.repairing = nil
        return
    end

    operation.remaining = math.max(0, operation.remaining-(dt or 0))
    if operation.remaining > 0 then return end
    self.operationHudOutro = {
        kind=kind,
        vehicle=operation.vehicle,
        wheelIndex=operation.wheelIndex,
        remaining=0,
        total=operation.total,
        uiElapsed=operation.uiElapsed,
        elapsed=0,
        duration=220,
        success=true
    }
    self.repairing = nil

    if kind == "remove" then
        local x, y, z = getWheelWorldPosition(state.vehicle, state.wheelIndex)
        PunctureStateEvent.send(
            state.vehicle, state.wheelIndex, self.STAGE_HELD, false,
            x or 0, y or 0, z or 0, 0
        )
    elseif kind == "install" then
        PunctureStateEvent.send(
            state.vehicle, state.wheelIndex, self.STAGE_NONE, true,
            0, 0, 0, 0
        )
    else
        PunctureStateEvent.send(
            state.vehicle, state.wheelIndex, self.STAGE_PLACED, true,
            state.x, state.y, state.z, state.rotY
        )
        if g_currentMission ~= nil then
            g_currentMission:showBlinkingWarning(getText("mudsys_puncture_repaired", "Tire repaired"), 2500)
        end
    end
end

function PunctureSystem:dropLocalCarriedWheelForSafety()
    local carried = self.localCarried
    if carried == nil then return end
    local state = self:getWheelState(carried.vehicle, carried.wheelIndex)
    if state == nil or state.stage ~= self.STAGE_HELD then return end
    local p = self.previewPosition
    local x, y, z, rotY
    if p ~= nil then
        x, y, z, rotY = p.x, p.y, p.z, p.rotY
    else
        x, y, z = getWheelWorldPosition(state.vehicle, state.wheelIndex)
        y = (y or 0) + getWheelWidth(getWheel(state.vehicle, state.wheelIndex))*0.52
        rotY = 0
    end
    PunctureStateEvent.send(
        state.vehicle, state.wheelIndex, self.STAGE_PLACED, state.repaired,
        x or 0, y or 0, z or 0, rotY or 0
    )
end

function PunctureSystem:refreshAllWheelEffects()
    for vehicle, states in pairs(self.states) do
        if not punctureVehicleDeleting(vehicle) then
            for wheelIndex, state in pairs(states) do
                setWheelVisible(vehicle, wheelIndex, state.stage == self.STAGE_PUNCTURED)
                self:applyWheelEffects(vehicle, wheelIndex, state)
            end
        end
    end
end

function PunctureSystem:onUpdate(dt)
    self:registerActivatable()
    self:updateRandomPunctures(dt)
    self:updateAIAutoRepairs(dt)
    self:updateDisconnectedCarriers(dt)
    if self.operationHudOutro ~= nil then
        self.operationHudOutro.elapsed = (self.operationHudOutro.elapsed or 0) + (dt or 0)
        if self.operationHudOutro.elapsed >= (self.operationHudOutro.duration or 220) then
            self.operationHudOutro = nil
        end
    end
    self:updateRepair(dt)
    self:enforcePlayerMovementBlock()
    if self.localCarried ~= nil and getLocalCurrentVehicle() ~= nil then
        self:dropLocalCarriedWheelForSafety()
    end
    if self.localJackCarried ~= nil and getLocalCurrentVehicle() ~= nil then
        self:clearLocalJackCarryVisuals()
        self.localJackCarried = nil
    end
    self:updateCarryVisuals()
    self:updateJackCarryVisuals()
    if self.jackPanel ~= nil then
        local state = self:getWheelState(self.jackPanel.vehicle, self.jackPanel.wheelIndex)
        local camera = getCamera ~= nil and getCamera() or nil
        local tooFar = state ~= nil and nodeExists(camera) and state.jackNode ~= nil
            and calcDistanceFrom(camera, state.jackNode) > self.interactionDistance+1.5
        if state == nil or state.jackPlaced ~= true or getLocalCurrentVehicle() ~= nil or tooFar then
            self:setJackPanelOpen(false)
        else
            self:updateJackPanelCameraInputBlock()
            self:updateJackPanelOperation(dt)
            if self._pendingJackExtension ~= nil
                and (tonumber(g_time) or 0)-(self._lastJackSyncTime or 0) >= (self.jackSyncIntervalMs or 90) then
                local pending = self._pendingJackExtension
                self._pendingJackExtension = nil
                self:setLocalJackExtension(pending, true)
            end
        end
    end
    self:updateInteractionTarget()

    self._effectTimer = (self._effectTimer or 0) - (dt or 0)
    if self._effectTimer <= 0 then
        self._effectTimer = 150
        self:refreshAllWheelEffects()
    end

    if self._syncPending == true then
        self._syncDelay = (self._syncDelay or 1000) - (dt or 0)
        if self._syncDelay <= 0 then
            PunctureSyncRequestEvent.send()
            self._syncPending = false
        end
    end

    if #self.pending > 0 then self:applyPendingStates() end
end

function PunctureSystem:update(dt)
    self:onUpdate(dt)
end

local function smoothStep01(value)
    value = clamp(value, 0, 1)
    return value*value*(3-2*value)
end

local function drawPunctureHudArc(cx, cy, radiusX, radiusY, angleStart, angleEnd,
        thickness, r, g, b, a, fraction)
    fraction = clamp(fraction == nil and 1 or fraction, 0, 1)
    if fraction <= 0 then return end
    angleEnd = angleStart + (angleEnd-angleStart)*fraction
    -- At 12 ring segments a single chord is visually indistinguishable from a short arc
    -- and avoids multiplying immediate-mode draw calls every frame.
    local pieces = 1
    local previousX = cx + math.cos(angleStart)*radiusX
    local previousY = cy + math.sin(angleStart)*radiusY
    for piece=1,pieces do
        local angle = angleStart + (angleEnd-angleStart)*(piece/pieces)
        local x = cx + math.cos(angle)*radiusX
        local y = cy + math.sin(angle)*radiusY
        drawLine2D(previousX, previousY, x, y, thickness, r, g, b, a)
        previousX, previousY = x, y
    end
end

local function drawPunctureHudCircle(cx, cy, radiusX, radiusY, thickness, segments, r, g, b, a)
    local previousX, previousY = cx + radiusX, cy
    for index=1,segments do
        local angle = math.pi*2*(index/segments)
        local x = cx + math.cos(angle)*radiusX
        local y = cy + math.sin(angle)*radiusY
        drawLine2D(previousX, previousY, x, y, thickness, r, g, b, a)
        previousX, previousY = x, y
    end
end

local function drawPunctureHudNut(cx, cy, radiusX, radiusY, slotAngle, r, g, b, a)
    drawPunctureHudCircle(cx, cy, radiusX, radiusY, 0.0017, 6, r, g, b, a)
    local innerX, innerY = radiusX*0.58, radiusY*0.58
    drawFilledRect(cx-innerX, cy-innerY, innerX*2, innerY*2, r*0.58, g*0.58, b*0.58, a)
    local lineX = math.cos(slotAngle)*radiusX*0.68
    local lineY = math.sin(slotAngle)*radiusY*0.68
    drawLine2D(cx-lineX, cy-lineY, cx+lineX, cy+lineY, 0.0014, 0.94, 0.96, 0.90, a)
end

function PunctureSystem:drawJackPanel()
    local panel = self.jackPanel
    if panel == nil then return end
    local state = self:getWheelState(panel.vehicle, panel.wheelIndex)
    if state == nil then return end
    local rects = self:getJackPanelRects()
    local card, up, down, done, relocate =
        rects.card, rects.up, rects.down, rects.done, rects.relocate
    local standardExtension = self.jackStandardExtension or 1
    local maximumExtension = math.max(standardExtension, self.jackMaxExtension or 1.5)
    local extension = clamp(state.jackExtension, 0, maximumExtension)
    local extensionFraction = extension/maximumExtension
    local standardFraction = standardExtension/maximumExtension
    local accentR, accentG, accentB = 0.58, 0.86, 0.04
    local extraR, extraG, extraB = 1.00, 0.58, 0.05
    local time = tonumber(g_time) or 0
    local pulse = 0.72+0.28*math.sin(time*0.009)

    drawFilledRect(card.x, card.y, card.w, card.h, 0.035, 0.045, 0.047, 0.93)
    drawFilledRect(card.x, card.y+card.h-0.004, card.w, 0.004, accentR, accentG, accentB, 0.95)

    local progressX, progressY, progressW, progressH = 0.785, 0.575, 0.120, 0.009
    drawFilledRect(progressX, progressY, progressW, progressH, 0.17, 0.20, 0.20, 1)
    drawFilledRect(progressX, progressY,
        progressW*math.min(extensionFraction, standardFraction), progressH,
        accentR, accentG, accentB, 0.95)
    if extension > standardExtension then
        drawFilledRect(progressX+progressW*standardFraction, progressY,
            progressW*(extensionFraction-standardFraction), progressH,
            extraR, extraG, extraB, 0.98)
    end
    local readyX = progressX+progressW*((self.jackReadyThreshold or 0.72)/maximumExtension)
    drawLine2D(readyX, progressY-0.004, readyX, progressY+progressH+0.004,
        0.0015, accentR, accentG, accentB, 0.65+0.30*pulse)
    local standardX = progressX+progressW*standardFraction
    drawLine2D(standardX, progressY-0.004, standardX, progressY+progressH+0.004,
        0.0015, 0.96, 0.97, 0.94, 0.82)

    local upHeld = self._jackHoldDirection == 1
    local downHeld = self._jackHoldDirection == -1
    drawFilledRect(up.x, up.y, up.w, up.h,
        upHeld and accentR or 0.15, upHeld and accentG or 0.18,
        upHeld and accentB or 0.18, 0.98)
    drawFilledRect(down.x, down.y, down.w, down.h,
        downHeld and accentR or 0.15, downHeld and accentG or 0.18,
        downHeld and accentB or 0.18, 0.98)

    local arrowR = upHeld and 0.08 or 0.88
    local arrowG = upHeld and 0.11 or 0.91
    local arrowB = upHeld and 0.08 or 0.90
    local cx, upCy, arrowX, arrowY = up.x+up.w*0.5, up.y+up.h*0.5, 0.020, 0.016
    drawLine2D(cx-arrowX, upCy-arrowY, cx, upCy+arrowY, 0.0032, arrowR, arrowG, arrowB, 1)
    drawLine2D(cx, upCy+arrowY, cx+arrowX, upCy-arrowY, 0.0032, arrowR, arrowG, arrowB, 1)
    drawLine2D(cx-arrowX, upCy-arrowY, cx+arrowX, upCy-arrowY, 0.0032, arrowR, arrowG, arrowB, 1)

    arrowR = downHeld and 0.08 or 0.88
    arrowG = downHeld and 0.11 or 0.91
    arrowB = downHeld and 0.08 or 0.90
    local downCy = down.y+down.h*0.5
    drawLine2D(cx-arrowX, downCy+arrowY, cx, downCy-arrowY, 0.0032, arrowR, arrowG, arrowB, 1)
    drawLine2D(cx, downCy-arrowY, cx+arrowX, downCy+arrowY, 0.0032, arrowR, arrowG, arrowB, 1)
    drawLine2D(cx-arrowX, downCy+arrowY, cx+arrowX, downCy+arrowY, 0.0032, arrowR, arrowG, arrowB, 1)

    drawFilledRect(done.x, done.y, done.w, done.h,
        extension >= (self.jackReadyThreshold or 0.72) and accentR or 0.18,
        extension >= (self.jackReadyThreshold or 0.72) and accentG or 0.22,
        extension >= (self.jackReadyThreshold or 0.72) and accentB or 0.22, 0.98)
    drawFilledRect(relocate.x, relocate.y, relocate.w, relocate.h,
        0.13, 0.16, 0.16, 0.98)
    drawFilledRect(relocate.x, relocate.y, 0.0035, relocate.h,
        accentR, accentG, accentB, 0.95)

    setTextAlignment(RenderText.ALIGN_CENTER)
    setTextBold(true)
    setTextColor(0.95, 0.98, 0.96, 1)
    renderText(card.x+card.w*0.5, card.y+card.h-0.055, 0.017,
        getText("mudsys_puncture_jackTitle", "HYDRAULIC JACK"))
    setTextBold(false)
    setTextColor(0.72, 0.76, 0.75, 1)
    renderText(card.x+card.w*0.5, card.y+card.h-0.090, 0.011,
        getText("mudsys_puncture_jackInstruction", "Hold an arrow to raise or lower"))
    setTextBold(false)
    setTextColor(0.72, 0.76, 0.75, 1)
    renderText(standardX, progressY-0.014, 0.0085, "100%")
    setTextAlignment(RenderText.ALIGN_RIGHT)
    renderText(progressX+progressW, progressY-0.014, 0.0085,
        string.format("%d%%", math.floor(maximumExtension*100+0.5)))
    setTextAlignment(RenderText.ALIGN_CENTER)
    setTextBold(true)
    if extension > standardExtension then
        setTextColor(extraR, extraG, extraB, 1)
    else
        setTextColor(accentR, accentG, accentB, 1)
    end
    renderText(card.x+card.w*0.5, 0.595, 0.017, string.format("%d%%", math.floor(extension*100+0.5)))
    setTextColor(extension >= (self.jackReadyThreshold or 0.72) and 0.08 or 0.88,
        extension >= (self.jackReadyThreshold or 0.72) and 0.11 or 0.91,
        extension >= (self.jackReadyThreshold or 0.72) and 0.08 or 0.90, 1)
    renderText(done.x+done.w*0.5, done.y+0.014, 0.015,
        getText("mudsys_puncture_jackDone", "DONE"))
    setTextColor(0.88, 0.91, 0.90, 1)
    renderText(relocate.x+relocate.w*0.5, relocate.y+0.014, 0.0125,
        getText("mudsys_puncture_jackRelocate", "RELOCATE JACK"))
    setTextBold(false)
    setTextColor(1, 1, 1, 1)
    setTextAlignment(RenderText.ALIGN_LEFT)
end

function PunctureSystem:drawPuncturedWheelOutlines()
    if self.highlightPuncturedWheel ~= true or DebugShapeOutline == nil then return end

    if setOutlineColor ~= nil then pcall(setOutlineColor, 1.0, 0.16, 0.04, 0.95) end
    for vehicle, states in pairs(self.states) do
        if canAccessVehicle(vehicle, nil) then
            for wheelIndex, state in pairs(states) do
                if state.stage == self.STAGE_PUNCTURED then
                    local wheel = getWheel(vehicle, wheelIndex)
                    for _, visualWheel in ipairs(wheel ~= nil and wheel.visualWheels or {}) do
                        if nodeExists(visualWheel.node) then
                            DebugShapeOutline.render(visualWheel.node, true)
                        end
                    end
                end
            end
        end
    end
    if setOutlineColor ~= nil then pcall(setOutlineColor, 1, 1, 1, 1) end
end

function PunctureSystem:draw()
    if self.enabled ~= true then return end
    self:drawPuncturedWheelOutlines()
    if self.previewValid and nodeExists(self.previewNode) and DebugShapeOutline ~= nil then
        if setOutlineColor ~= nil then pcall(setOutlineColor, 0.15, 1, 0.08, 0.95) end
        DebugShapeOutline.render(self.previewNode, true)
        if setOutlineColor ~= nil then pcall(setOutlineColor, 1, 1, 1, 1) end
    end
    if self.jackPreviewValid and nodeExists(self.jackPreviewNode) and DebugShapeOutline ~= nil then
        if setOutlineColor ~= nil then pcall(setOutlineColor, 0.15, 1, 0.08, 0.95) end
        DebugShapeOutline.render(self.jackPreviewNode, true)
        if setOutlineColor ~= nil then pcall(setOutlineColor, 1, 1, 1, 1) end
    end
    self:drawJackPanel()

    local operation = self.repairing or self.operationHudOutro
    if operation == nil then return end
    local progress = 1-clamp(operation.remaining/math.max(1, operation.total), 0, 1)
    local isOutro = self.repairing == nil
    local fade, appear = 1, 1
    if isOutro then
        fade = 1-smoothStep01((operation.elapsed or 0)/math.max(1, operation.duration or 220))
    else
        appear = smoothStep01((operation.uiElapsed or 0)/180)
    end

    local aspect = math.max(1, tonumber(g_screenAspectRatio) or (16/9))
    local scale = (0.90+appear*0.10)*(isOutro and (1+0.035*(1-fade)) or 1)
    local y = 0.145
    local cx = 0.5
    local ringCy = y+0.070*scale
    local time = tonumber(g_time) or 0
    local pulse = 0.78+0.22*math.sin(time*0.012)
    local accentR, accentG, accentB = 0.58, 0.86, 0.04

    -- Determinate 12-segment ring, filling clockwise from the top.
    local ringRadiusX = 0.035*scale
    local ringRadiusY = ringRadiusX*aspect
    for index=1,12 do
        local centerAngle = math.pi*0.5-(index-1)*(math.pi*2/12)
        local angleStart = centerAngle+math.rad(9)
        local angleEnd = centerAngle-math.rad(9)
        drawPunctureHudArc(
            cx, ringCy, ringRadiusX, ringRadiusY, angleStart, angleEnd,
            0.0036, 0.16, 0.19, 0.19, 0.95*fade*appear, 1
        )
        local segmentProgress = clamp(progress*12-(index-1), 0, 1)
        if segmentProgress > 0 then
            local currentPulse = segmentProgress < 1 and pulse or 1
            drawPunctureHudArc(
                cx, ringCy, ringRadiusX, ringRadiusY, angleStart, angleEnd,
                0.0042, accentR, accentG, accentB,
                (0.82+0.18*currentPulse)*fade*appear, segmentProgress
            )
        end
    end

    local hubX = cx
    if operation.kind == "repair" then
        -- Repair has its own language: tire casing, opened bead, tear and a growing patch.
        local beadWork = 1-smoothStep01((progress-0.34)/0.18)
        local beadPulse = math.sin(time*0.011)*0.0012*beadWork
        drawPunctureHudCircle(cx, ringCy, 0.026*scale, 0.026*scale*aspect,
            0.0030, 24, 0.25, 0.29, 0.29, fade*appear)
        drawPunctureHudCircle(cx, ringCy, (0.0205+beadPulse)*scale,
            (0.0205+beadPulse)*scale*aspect, 0.0014, 20,
            0.46, 0.51, 0.50, (0.65+0.25*beadWork)*fade*appear)
        drawPunctureHudCircle(cx, ringCy, (0.0145-beadPulse*0.65)*scale,
            (0.0145-beadPulse*0.65)*scale*aspect, 0.0018, 18,
            0.18, 0.22, 0.22, fade*appear)

        local patchProgress = smoothStep01((progress-0.48)/0.38)
        local tearFade = 1-smoothStep01((progress-0.58)/0.24)
        local markX = cx+0.0235*scale
        local markY = ringCy-0.0205*scale*aspect
        local zigX = 0.0032*scale
        local zigY = zigX*aspect
        drawLine2D(markX-zigX*1.25, markY+zigY, markX-zigX*0.30, markY-zigY*0.15,
            0.0024, 0.92, 0.10, 0.06, tearFade*fade*appear)
        drawLine2D(markX-zigX*0.30, markY-zigY*0.15, markX+zigX*0.35, markY+zigY*0.30,
            0.0024, 0.92, 0.10, 0.06, tearFade*fade*appear)
        drawLine2D(markX+zigX*0.35, markY+zigY*0.30, markX+zigX*1.20, markY-zigY,
            0.0024, 0.92, 0.10, 0.06, tearFade*fade*appear)

        if patchProgress > 0 then
            local patchRadiusX = (0.0022+0.0046*patchProgress)*scale
            local patchRadiusY = patchRadiusX*aspect
            drawPunctureHudCircle(markX, markY, patchRadiusX, patchRadiusY,
                0.0022, 14, accentR, accentG, accentB, patchProgress*fade*appear)
            drawLine2D(markX-patchRadiusX*0.58, markY, markX+patchRadiusX*0.58, markY,
                0.0016, accentR, accentG, accentB, patchProgress*fade*appear)
            drawLine2D(markX, markY-patchRadiusY*0.58, markX, markY+patchRadiusY*0.58,
                0.0016, accentR, accentG, accentB, patchProgress*fade*appear)

            local wave = (time*0.0014)%1
            drawPunctureHudCircle(markX, markY,
                patchRadiusX+0.0055*wave, patchRadiusY+0.0055*aspect*wave,
                0.0012, 16, accentR, accentG, accentB,
                patchProgress*(1-wave)*0.65*fade*appear)
        end
    else
        -- Removal loosens outward; installation is the exact reverse and tightens inward.
        local nutOrbitX = 0.0195*scale
        local nutOrbitY = nutOrbitX*aspect
        for index=1,5 do
            local angle = math.pi*0.5-(index-1)*(math.pi*2/5)
            local phase = clamp(progress*5-(index-1), 0, 1)
            if operation.kind == "install" then
                phase = clamp(progress*5-(5-index), 0, 1)
            end
            local easedPhase = smoothStep01(phase)
            local outwardX = operation.kind == "remove"
                and 0.006*easedPhase or 0.006*(1-easedPhase)
            local outwardY = outwardX*aspect
            local nutX = cx+math.cos(angle)*(nutOrbitX+outwardX)
            local nutY = ringCy+math.sin(angle)*(nutOrbitY+outwardY)
            local nutScale = operation.kind == "remove"
                and (1-0.22*easedPhase) or (0.78+0.22*easedPhase)
            local nr, ng, nb = 0.30, 0.34, 0.34
            if phase >= 1 then nr, ng, nb = accentR*0.72, accentG*0.72, accentB*0.72 end
            if phase > 0 and phase < 1 then nr, ng, nb = accentR*pulse, accentG*pulse, accentB*pulse end
            local rotationDirection = operation.kind == "install" and 1 or -1
            drawPunctureHudNut(
                nutX, nutY, 0.0037*scale*nutScale, 0.0037*scale*nutScale*aspect,
                angle+rotationDirection*easedPhase*math.pi*2-time*0.0015*rotationDirection,
                nr, ng, nb, fade*appear
            )
        end

        local wobble = operation.kind == "remove" and smoothStep01((progress-0.78)/0.22) or 0
        local installScale = operation.kind == "install"
            and (0.84+0.16*smoothStep01(progress/0.18)) or 1
        hubX = cx+math.sin(time*0.018)*0.0015*wobble
        drawPunctureHudCircle(hubX, ringCy, 0.0105*scale*installScale,
            0.0105*scale*aspect*installScale, 0.0018, 16,
            0.40, 0.45, 0.44, fade*appear)
        drawPunctureHudCircle(hubX, ringCy, 0.0062*scale*installScale,
            0.0062*scale*aspect*installScale, 0.0015, 12,
            0.12, 0.15, 0.15, fade*appear)
    end

    local finalFlash = smoothStep01((progress-0.92)/0.08)
    if finalFlash > 0 then
        local flashPulse = isOutro and fade or (0.45+0.55*pulse)
        drawPunctureHudCircle(cx, ringCy, ringRadiusX+0.005*finalFlash,
            ringRadiusY+0.005*aspect*finalFlash, 0.0022, 24,
            accentR, accentG, accentB, finalFlash*flashPulse*fade*appear)
    end

    setTextAlignment(RenderText.ALIGN_CENTER)
    setTextBold(true)
    setTextColor(0.94, 0.97, 0.95, fade*appear)
    local operationText
    if operation.kind == "remove" then
        operationText = getText("mudsys_puncture_removing", "Removing wheel")
    elseif operation.kind == "install" then
        operationText = getText("mudsys_puncture_installing", "Installing wheel")
    else
        operationText = getText("mudsys_puncture_repairing", "Patching tire")
    end
    renderText(cx, y-0.020*scale, 0.0145*scale, operationText)
    local seconds = math.max(0, operation.remaining/1000)
    setTextColor(1, 1, 1, fade*appear)
    renderText(hubX, ringCy-0.0085*scale, 0.0155*scale, string.format("%.1f", seconds))
    setTextBold(false)
    setTextColor(1, 1, 1, 1)
    setTextAlignment(RenderText.ALIGN_LEFT)
end

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

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

    local index = 0
    for vehicle, states in pairs(self.states) do
        local vehicleId = getVehicleId(vehicle)
        if vehicleId ~= nil and not isCrawlerVehicle(vehicle) then
            for wheelIndex, state in pairs(states) do
                if isPuncturableWheel(vehicle, wheelIndex) then
                    local stage = state.stage
                    local x, y, z, rotY = state.x, state.y, state.z, state.rotY
                    if stage == self.STAGE_HELD then
                        stage = self.STAGE_PLACED
                        x, y, z = getWheelWorldPosition(vehicle, wheelIndex)
                        local wheel = getWheel(vehicle, wheelIndex)
                        y = (y or 0) + getWheelWidth(wheel)*0.52
                        rotY = 0
                    end
                    local key = string.format("%s.wheel(%d)", self.SAVE_ROOT, index)
                    setXMLString(xml, key .. "#vehicleId", vehicleId)
                    setXMLInt(xml, key .. "#wheelIndex", wheelIndex)
                    setXMLInt(xml, key .. "#stage", stage)
                    setXMLBool(xml, key .. "#repaired", state.repaired == true)
                    setXMLFloat(xml, key .. "#x", x or 0)
                    setXMLFloat(xml, key .. "#y", y or 0)
                    setXMLFloat(xml, key .. "#z", z or 0)
                    setXMLFloat(xml, key .. "#rotY", rotY or 0)
                    setXMLBool(xml, key .. "#jackPlaced", self.requireJack == true and state.jackPlaced == true)
                    setXMLFloat(xml, key .. "#jackX", state.jackX or 0)
                    setXMLFloat(xml, key .. "#jackY", state.jackY or 0)
                    setXMLFloat(xml, key .. "#jackZ", state.jackZ or 0)
                    setXMLFloat(xml, key .. "#jackRotY", state.jackRotY or 0)
                    setXMLFloat(xml, key .. "#jackExtension", state.jackExtension or 0)
                    index = index + 1
                end
            end
        end
    end
    setXMLInt(xml, self.SAVE_ROOT .. "#count", index)
    saveXMLFile(xml)
    delete(xml)
end

function PunctureSystem:loadFromXML()
    self.pending = {}
    if g_server == nil then return end
    local path = self:getSavePath()
    if path == nil or not fileExists(path) then return end
    local xml = loadXMLFile("MudSystemPunctures", path)
    if xml == nil or xml == 0 then return end
    local count = getXMLInt(xml, self.SAVE_ROOT .. "#count") or 0
    for index=0,count-1 do
        local key = string.format("%s.wheel(%d)", self.SAVE_ROOT, index)
        local vehicleId = getXMLString(xml, key .. "#vehicleId")
        local wheelIndex = getXMLInt(xml, key .. "#wheelIndex")
        if vehicleId ~= nil and wheelIndex ~= nil then
            self.pending[#self.pending+1] = {
                vehicleId=vehicleId,
                wheelIndex=wheelIndex,
                stage=getXMLInt(xml, key .. "#stage") or self.STAGE_PUNCTURED,
                repaired=getXMLBool(xml, key .. "#repaired") == true,
                x=getXMLFloat(xml, key .. "#x") or 0,
                y=getXMLFloat(xml, key .. "#y") or 0,
                z=getXMLFloat(xml, key .. "#z") or 0,
                rotY=getXMLFloat(xml, key .. "#rotY") or 0,
                jackPlaced=getXMLBool(xml, key .. "#jackPlaced") == true,
                jackX=getXMLFloat(xml, key .. "#jackX") or 0,
                jackY=getXMLFloat(xml, key .. "#jackY") or 0,
                jackZ=getXMLFloat(xml, key .. "#jackZ") or 0,
                jackRotY=getXMLFloat(xml, key .. "#jackRotY") or 0,
                jackExtension=getXMLFloat(xml, key .. "#jackExtension") or 0
            }
        end
    end
    delete(xml)
end

function PunctureSystem:applyPendingStates()
    if self.enabled ~= true or g_currentMission == nil or #self.pending == 0 then return end
    local byId = {}
    for _, vehicle in pairs(getMissionVehicles()) do
        local id = getVehicleId(vehicle)
        if id ~= nil then byId[id] = vehicle end
    end
    for index=#self.pending,1,-1 do
        local pending = self.pending[index]
        local vehicle = byId[pending.vehicleId]
        if vehicle ~= nil and getWheel(vehicle, pending.wheelIndex) ~= nil then
            if isPuncturableWheel(vehicle, pending.wheelIndex) then
                self:applyNetworkState(
                    vehicle, pending.wheelIndex, pending.stage, pending.repaired,
                    pending.x, pending.y, pending.z, pending.rotY, false
                )
                if self.requireJack == true and pending.jackPlaced == true then
                    self:applyJackNetworkState(
                        vehicle, pending.wheelIndex, true,
                        pending.jackX, pending.jackY, pending.jackZ,
                        pending.jackRotY, pending.jackExtension
                    )
                end
            end
            table.remove(self.pending, index)
        end
    end
end

function PunctureSystem:clearAllStatesLocal()
    local remove = {}
    for vehicle, states in pairs(self.states) do
        for wheelIndex in pairs(states) do
            remove[#remove+1] = {vehicle=vehicle, wheelIndex=wheelIndex}
        end
    end
    for _, item in ipairs(remove) do self:removeStateLocal(item.vehicle, item.wheelIndex) end
    self.pending = {}
    self.aiAutoRepairTimers = setmetatable({}, {__mode="k"})
    self.repairing = nil
    self.operationHudOutro = nil
    self.currentAction = nil
    self.currentTarget = nil
    self:clearLocalCarryVisuals()
    self.localCarried = nil
    self:clearLocalJackCarryVisuals()
    self.localJackCarried = nil
    self:setJackPanelOpen(false)
end

function PunctureSystem:clearVehicleLocal(vehicle)
    local states = self:getVehicleStates(vehicle, false)
    if states ~= nil then
        for _, state in pairs(states) do
            self:destroyPlacedVisual(state)
            self:destroyJackVisual(state)
        end
    end
    if self.localCarried ~= nil and self.localCarried.vehicle == vehicle then
        self:clearLocalCarryVisuals()
        self.localCarried = nil
    end
    if self.localJackCarried ~= nil and self.localJackCarried.vehicle == vehicle then
        self:clearLocalJackCarryVisuals()
        self.localJackCarried = nil
    end
    if self.jackPanel ~= nil and self.jackPanel.vehicle == vehicle then
        self:setJackPanelOpen(false)
    end
    if self.repairing ~= nil and self.repairing.vehicle == vehicle then self.repairing = nil end
    if self.operationHudOutro ~= nil and self.operationHudOutro.vehicle == vehicle then
        self.operationHudOutro = nil
    end
    if self.currentTarget ~= nil and self.currentTarget.vehicle == vehicle then
        self.currentAction = nil
        self.currentTarget = nil
    end
    if MudSystemWorldSounds ~= nil then
        MudSystemWorldSounds:stopOwner(vehicle)
    end
    self.states[vehicle] = nil
    self.travel[vehicle] = nil
    self.drivePenaltyCache[vehicle] = nil
    self.aiAutoRepairTimers[vehicle] = nil
end

function PunctureSystem:onSettingsChanged()
    self.enabled = self.enabled == true
    self.requireJack = self.requireJack ~= false
    self.showCarriedWheel = self.showCarriedWheel == true
    self.highlightPuncturedWheel = self.highlightPuncturedWheel ~= false
    self.aiPuncturesEnabled = self.aiPuncturesEnabled == true
    self.aiAutoRepairEnabled = self.aiAutoRepairEnabled ~= false
    self.aiAutoRepairCost = math.max(0, tonumber(self.aiAutoRepairCost) or 10)
    self.aiAutoRepairDelayMs = math.max(1000, tonumber(self.aiAutoRepairDelayMs) or 10000)
    self.chancePerKm = clamp(self.chancePerKm, 0.01, 0.50)
    self.chanceDistanceKm = math.floor(clamp(self.chanceDistanceKm, 1, 100) + 0.5)

    -- Road debris is a child mode of wheel punctures. Apply the dependency
    -- immediately so disabling punctures removes every debris point in the same
    -- settings action instead of waiting for a later update tick.
    if RoadGarbageSystem ~= nil
        and type(RoadGarbageSystem.onSettingsChanged) == "function" then
        RoadGarbageSystem:onSettingsChanged()
    end

    if self.aiAutoRepairEnabled ~= true then
        self.aiAutoRepairTimers = setmetatable({}, {__mode="k"})
        if g_server ~= nil then
            local stopped = setmetatable({}, {__mode="k"})
            for vehicle, states in pairs(self.states) do
                local root = getActiveAIVehicle(vehicle)
                if root ~= nil and stopped[root] ~= true then
                    for _, state in pairs(states) do
                        if state.stage == self.STAGE_PUNCTURED then
                            stopped[root] = true
                            self:stopAIForPuncture(root)
                            break
                        end
                    end
                end
            end
        end
    end

    if self.requireJack ~= true then
        self:setJackPanelOpen(false)
        self:clearLocalJackCarryVisuals()
        self.localJackCarried = nil
        if self.currentAction == "takeJack" or self.currentAction == "placeJack"
            or self.currentAction == "operateJack" then
            self.currentAction = nil
            self.currentTarget = nil
        end

        local placedJacks = {}
        for vehicle, states in pairs(self.states) do
            for wheelIndex, state in pairs(states) do
                if state.jackPlaced == true then
                    placedJacks[#placedJacks+1] = {vehicle=vehicle, wheelIndex=wheelIndex}
                end
            end
        end
        for _, item in ipairs(placedJacks) do
            if g_server ~= nil and PunctureJackEvent ~= nil then
                PunctureJackEvent.send(item.vehicle, item.wheelIndex, false, 0, 0, 0, 0, 0)
            else
                self:applyJackNetworkState(item.vehicle, item.wheelIndex, false, 0, 0, 0, 0, 0)
            end
        end
    end

    if self.localCarried ~= nil then
        local state = self:getWheelState(self.localCarried.vehicle, self.localCarried.wheelIndex)
        if state ~= nil and state.stage == self.STAGE_HELD then
            self:createLocalCarryVisuals(state.vehicle, state.wheelIndex, state.repaired)
        end
    elseif self.showCarriedWheel ~= true and nodeExists(self.carriedNode) then
        safeDelete(self.carriedNode)
        self.carriedNode = nil
    end

    if not self.enabled then
        local coordinator = _G.__MudDriveCoordinator
        if coordinator ~= nil and coordinator.requestSpeedLimit ~= nil
            and g_currentMission ~= nil and g_currentMission.vehicles ~= nil then
            for _, vehicle in pairs(g_currentMission.vehicles) do
                if not punctureVehicleDeleting(vehicle) then
                    coordinator:requestSpeedLimit(vehicle, "puncture", nil)
                end
            end
        end
        self._randomAccumulatedDt = 0
        self._randomUpdateTimer = 0
        self:clearAllStatesLocal()
        if g_server ~= nil and g_currentMission ~= nil then self:saveToXML() end
    end
end

function PunctureSystem:getDrivePenalty(vehicle)
    if self.enabled ~= true or punctureVehicleDeleting(vehicle) then return nil end
    local root = vehicle.getRootVehicle ~= nil and vehicle:getRootVehicle()
        or vehicle.rootVehicle or vehicle
    local now = g_time or (g_currentMission ~= nil and g_currentMission.time) or 0
    local cached = self.drivePenaltyCache[root]
    if cached ~= nil and now-cached.time <= (self.drivePenaltyCacheMs or 100) then
        return cached.value ~= false and cached.value or nil
    end

    local combination = TirePressureSystem ~= nil and TirePressureSystem.getCombination ~= nil
        and TirePressureSystem:getCombination(root) or {root}
    local speedFactor = 1
    local steeringPull = 0
    local hasPenalty = false
    local hasRemovedWheel = false

    for _, child in ipairs(combination) do
        local states = self:getVehicleStates(child, false)
        local wheels = getVehicleWheels(child) or {}
        if states ~= nil then
            local minZ, maxZ = math.huge, -math.huge
            for _, wheel in ipairs(wheels) do
                local z = tonumber(wheel.startPositionZ)
                    or tonumber(wheel.physics ~= nil and wheel.physics.positionZ) or 0
                minZ, maxZ = math.min(minZ, z), math.max(maxZ, z)
            end
            local middleZ = minZ ~= math.huge and (minZ+maxZ)*0.5 or 0

            for wheelIndex, state in pairs(states) do
                local wheel = getWheel(child, wheelIndex)
                if wheel ~= nil and state.stage == self.STAGE_PUNCTURED then
                    hasPenalty = true
                    speedFactor = math.min(speedFactor, self.puncturedSpeedFactor or 0.40)

                    local loadFactor = 1
                    if WheelLoadSystem ~= nil and WheelLoadSystem.getWheelLoadFactor ~= nil then
                        local ok, value = pcall(WheelLoadSystem.getWheelLoadFactor, WheelLoadSystem, child, wheel)
                        if ok and value ~= nil then loadFactor = clamp(value, 0.60, 1.65) end
                    end
                    local z = tonumber(wheel.startPositionZ)
                        or tonumber(wheel.physics ~= nil and wheel.physics.positionZ) or 0
                    local axleFactor = maxZ-minZ > 0.5 and (z >= middleZ and 1 or 0.65) or 0.85
                    local attachmentFactor = child == root and 1 or 0.55
                    -- Input axis convention in FS25: negative is left, positive is right.
                    local side = wheel.isLeft == false and 1 or -1
                    steeringPull = steeringPull + side * (self.puncturedSteeringPull or 0.22)
                        * loadFactor * axleFactor * attachmentFactor
                elseif wheel ~= nil and (state.stage == self.STAGE_HELD or state.stage == self.STAGE_PLACED) then
                    hasPenalty = true
                    hasRemovedWheel = true
                    speedFactor = math.min(speedFactor, 0.08)
                end
            end
        end
    end

    local penalty = nil
    if hasPenalty then
        penalty = {
            root=root,
            speedFactor=speedFactor,
            steeringPull=clamp(steeringPull, -(self.maximumSteeringPull or 0.32), self.maximumSteeringPull or 0.32),
            hasRemovedWheel=hasRemovedWheel
        }
    end
    self.drivePenaltyCache[root] = {time=now, value=penalty or false}
    return penalty
end

function PunctureSystem:getDriveSpeedLimitKph(vehicle, penalty)
    penalty = penalty or self:getDrivePenalty(vehicle)
    if penalty == nil then return nil end
    if penalty.hasRemovedWheel then return self.removedSpeedLimitKph or 2 end

    local root = penalty.root or vehicle
    local motor
    if root ~= nil and root.getMotor ~= nil then
        local ok, value = pcall(root.getMotor, root)
        if ok then motor = value end
    end
    motor = motor or (root ~= nil and root.spec_motorized ~= nil and root.spec_motorized.motor) or nil
    local maximumSpeed = motor ~= nil
        and tonumber(motor.maxForwardSpeedOrigin or motor.maxForwardSpeed) or nil
    local baseKph = maximumSpeed ~= nil and maximumSpeed*3.6 or 50
    return math.max(self.puncturedMinimumSpeedKph or 6, baseKph*(penalty.speedFactor or 0.40))
end

function PunctureSystem:installHooks()
    if self._hooksInstalled then return end
    self._hooksInstalled = true

    if PlayerInputComponent ~= nil then
        local movementFunctions = {
            "onInputMoveSide",
            "onInputMoveForward",
            "onInputRun",
            "onInputJump",
            "onInputChangeAltitude"
        }
        for _, functionName in ipairs(movementFunctions) do
            local original = PlayerInputComponent[functionName]
            if original ~= nil then
                PlayerInputComponent[functionName] = Utils.overwrittenFunction(
                    original,
                    function(inputComponent, superFunc, ...)
                        if PunctureSystem:isMovementBlockedForInput(inputComponent) then
                            return
                        end
                        return superFunc(inputComponent, ...)
                    end
                )
            end
        end
    end

    -- Hired workers do not use Drivable.updateVehiclePhysics. Their drive
    -- strategies call WheelsUtil.updateWheelsPhysics directly, so this is the
    -- authoritative point where an AI vehicle must be held during auto-repair.
    if WheelsUtil ~= nil and WheelsUtil.updateWheelsPhysics ~= nil
        and _G.__MudSystemPunctureAIWheelPhysicsHooked ~= true then
        WheelsUtil.updateWheelsPhysics = Utils.overwrittenFunction(
            WheelsUtil.updateWheelsPhysics,
            function(vehicle, superFunc, dt, currentSpeed, acceleration, doHandbrake, stopAndGoBraking)
                if PunctureSystem:shouldBlockAIForPuncture(vehicle) then
                    acceleration = 0
                    doHandbrake = true
                    local motor = vehicle ~= nil and vehicle.getMotor ~= nil and vehicle:getMotor()
                        or (vehicle ~= nil and vehicle.spec_motorized ~= nil
                            and vehicle.spec_motorized.motor) or nil
                    if motor ~= nil and motor.setSpeedLimit ~= nil then
                        motor:setSpeedLimit(0)
                    end
                end
                return superFunc(
                    vehicle, dt, currentSpeed, acceleration, doHandbrake, stopAndGoBraking
                )
            end
        )
        _G.__MudSystemPunctureAIWheelPhysicsHooked = true
    end

    if WheelPhysics ~= nil and WheelPhysics.updateTireFriction ~= nil then
        WheelPhysics.updateTireFriction = Utils.overwrittenFunction(
            WheelPhysics.updateTireFriction,
            function(wheelPhysics, superFunc, ...)
                local old = wheelPhysics ~= nil and wheelPhysics.frictionScale or nil
                local multiplier = PunctureSystem:getWheelFrictionMultiplier(wheelPhysics)
                if wheelPhysics ~= nil and multiplier ~= 1 then
                    wheelPhysics.frictionScale = (old or 1) * multiplier
                end
                if superFunc ~= nil then superFunc(wheelPhysics, ...) end
                if wheelPhysics ~= nil and multiplier ~= 1 then wheelPhysics.frictionScale = old end
            end
        )
    end

    if Vehicle ~= nil and Vehicle.getSpeedLimit ~= nil then
        Vehicle.getSpeedLimit = Utils.overwrittenFunction(
            Vehicle.getSpeedLimit,
            function(vehicle, superFunc, ...)
                local limit, doCheck = superFunc(vehicle, ...)
                if PunctureSystem.enabled == true then
                    local penalty = PunctureSystem:getDrivePenalty(vehicle)
                    local punctureLimit = PunctureSystem:getDriveSpeedLimitKph(vehicle, penalty)
                    if punctureLimit ~= nil then
                        limit = math.min(limit, punctureLimit)
                    end
                end
                return limit, doCheck
            end
        )
    end

    if Drivable ~= nil and Drivable.updateVehiclePhysics ~= nil then
        Drivable.updateVehiclePhysics = Utils.overwrittenFunction(
            Drivable.updateVehiclePhysics,
            function(vehicle, superFunc, axisForward, axisSide, doHandbrake, dt)
                if PunctureSystem:shouldBlockAIForPuncture(vehicle) then
                    axisForward = 0
                    axisSide = 0
                    doHandbrake = true
                end
                local penalty = PunctureSystem:getDrivePenalty(vehicle)
                local coordinator = _G.__MudDriveCoordinator
                if penalty ~= nil then
                    local speedLimit = PunctureSystem:getDriveSpeedLimitKph(vehicle, penalty)
                    if coordinator ~= nil and coordinator.requestSpeedLimit ~= nil then
                        coordinator:requestSpeedLimit(vehicle, "puncture", speedLimit)
                    end

                    local startKph = PunctureSystem.steeringPullStartKph or 3
                    local fullKph = math.max(startKph+1, PunctureSystem.steeringPullFullKph or 25)
                    local speedScale = clamp((vehicleSpeedKph(vehicle)-startKph)/(fullKph-startKph), 0, 1)
                    local movementDirection = tonumber(vehicle.movingDirection) or 1
                    local directionScale = movementDirection < 0 and -1 or 1
                    axisSide = clamp((tonumber(axisSide) or 0)
                        + (penalty.steeringPull or 0)*speedScale*directionScale, -1, 1)
                elseif coordinator ~= nil and coordinator.requestSpeedLimit ~= nil then
                    coordinator:requestSpeedLimit(vehicle, "puncture", nil)
                end
                return superFunc(vehicle, axisForward, axisSide, doHandbrake, dt)
            end
        )
    end


    if Vehicle ~= nil and Vehicle.delete ~= nil and _G.__MudSystemPunctureVehicleDeleteHooked ~= true then
        Vehicle.delete = Utils.prependedFunction(
            Vehicle.delete,
            function(vehicle)
                if vehicle ~= nil then vehicle.__mudSystemDeleting = true end
                if PunctureSystem ~= nil then PunctureSystem:clearVehicleLocal(vehicle) end
            end
        )
        _G.__MudSystemPunctureVehicleDeleteHooked = true
    end
end

function PunctureSystem:findConsoleVehicleOnFoot(maxDistance)
    if g_currentMission == nil then return nil end

    local cameraData = {getCameraData()}
    if cameraData[1] == nil then return nil end
    local _, cx, cy, cz, dx, dy, dz = unpack(cameraData)
    local distanceLimit = math.max(1, tonumber(maxDistance) or 25)
    local bestGazedVehicle, bestGazeScore
    local nearestVehicle, nearestDistance

    for _, vehicle in pairs(getMissionVehicles()) do
        local wheels = getVehicleWheels(vehicle)
        if vehicle ~= nil and vehicle.isDeleted ~= true and vehicle.spec_locomotive == nil
            and wheels ~= nil and hasPuncturableWheel(vehicle) then
            local vehicleDistance
            for _, wheel in ipairs(wheels) do
                if wheelHasVisualTire(wheel) then
                    local node = wheel.repr or wheel.driveNode
                    if nodeExists(node) then
                        local wx, wy, wz = getWorldTranslation(node)
                        local vx, vy, vz = wx-cx, wy-cy, wz-cz
                        local distance = math.sqrt(vx*vx + vy*vy + vz*vz)
                        if vehicleDistance == nil or distance < vehicleDistance then
                            vehicleDistance = distance
                        end

                        local score = getGazeScore(
                            cx, cy, cz, dx, dy, dz,
                            wx, wy, wz, getWheelRadius(wheel), distanceLimit
                        )
                        if score ~= nil and (bestGazeScore == nil or score < bestGazeScore) then
                            bestGazeScore = score
                            bestGazedVehicle = vehicle
                        end
                    end
                end
            end

            if vehicleDistance ~= nil and vehicleDistance <= distanceLimit
                and (nearestDistance == nil or vehicleDistance < nearestDistance) then
                nearestDistance = vehicleDistance
                nearestVehicle = vehicle
            end
        end
    end

    return bestGazedVehicle or nearestVehicle
end

function PunctureSystem:getConsoleVehicle(componentIndex)
    local controlledVehicle = getLocalCurrentVehicle()
    local vehicle = controlledVehicle or self:findConsoleVehicleOnFoot(25)
    if vehicle == nil then return nil end

    local hasExplicitComponent = componentIndex ~= nil and tostring(componentIndex) ~= ""
    if controlledVehicle == nil and not hasExplicitComponent then
        -- On foot the exact vehicle being looked at wins, including an attached trailer.
        return vehicle
    end

    if controlledVehicle ~= nil and not hasExplicitComponent
        and TirePressureSystem ~= nil and TirePressureSystem.getSelectedPressureVehicle ~= nil then
        local selected = TirePressureSystem:getSelectedPressureVehicle(controlledVehicle)
        if selected ~= nil then return selected end
    end

    local root = vehicle.getRootVehicle ~= nil and vehicle:getRootVehicle() or vehicle
    local combination = TirePressureSystem ~= nil and TirePressureSystem.getCombination ~= nil
        and TirePressureSystem:getCombination(root) or {root}
    return combination[math.max(1, math.floor(tonumber(componentIndex) or 1))]
end

function PunctureSystem:getConsoleAIVehicle(componentIndex)
    local selected = self:getConsoleVehicle(componentIndex)
    local selectedRoot = getActiveAIVehicle(selected)
    if selectedRoot ~= nil and hasPuncturableWheel(selected) then
        return selected, selectedRoot
    end

    local camera = getCamera ~= nil and getCamera() or nil
    local bestVehicle, bestRoot, bestDistance
    for _, candidate in pairs(getMissionVehicles()) do
        local root = getActiveAIVehicle(candidate)
        if root ~= nil and candidate.spec_locomotive == nil and hasPuncturableWheel(candidate) then
            local distance = 0
            local node = candidate.rootNode or (candidate.components ~= nil
                and candidate.components[1] ~= nil and candidate.components[1].node) or nil
            if nodeExists(camera) and nodeExists(node) then
                distance = calcDistanceFrom(camera, node)
            elseif bestVehicle ~= nil then
                distance = math.huge
            end
            if bestDistance == nil or distance < bestDistance then
                bestVehicle, bestRoot, bestDistance = candidate, root, distance
            end
        end
    end

    if bestRoot ~= nil and componentIndex ~= nil and tostring(componentIndex) ~= "" then
        local combination = TirePressureSystem ~= nil and TirePressureSystem.getCombination ~= nil
            and TirePressureSystem:getCombination(bestRoot) or {bestRoot}
        local component = combination[math.max(1, math.floor(tonumber(componentIndex) or 1))]
        if component ~= nil and hasPuncturableWheel(component) then return component, bestRoot end
    end
    return bestVehicle, bestRoot
end

function PunctureSystem:consoleCommandAIPuncture(wheelIndex, componentIndex)
    if g_server == nil then
        return "[MudSystemPhysics] The AI puncture test must be started by the server/host"
    end
    if self.enabled ~= true then
        return "[MudSystemPhysics] Enable wheel punctures in settings first"
    end
    if self.aiPuncturesEnabled ~= true then
        return "[MudSystemPhysics] AI vehicle punctures are disabled in settings; test cancelled"
    end

    local vehicle, root = self:getConsoleAIVehicle(componentIndex)
    if vehicle == nil or root == nil then
        return "[MudSystemPhysics] No active AI worker with pneumatic tires found"
    end
    for stateVehicle, states in pairs(self.states) do
        if getActiveAIVehicle(stateVehicle) == root and next(states) ~= nil then
            return "[MudSystemPhysics] Repair or clear the AI worker's current puncture first"
        end
    end

    local candidates = getPuncturableWheelIndices(vehicle)
    local command = string.lower(tostring(wheelIndex or "random"))
    local index
    if command == "" or command == "random" or command == "test" then
        index = candidates[math.random(1, #candidates)]
    else
        index = math.floor(tonumber(wheelIndex) or 0)
        if not isPuncturableWheel(vehicle, index) then
            return string.format(
                "Usage: gsMudAIPuncture <wheel 1-%d|random> [component 1..N]", #getVehicleWheels(vehicle)
            )
        end
    end

    local helperName = getAIHelperName(root)
    local vehicleName = vehicle.getName ~= nil and vehicle:getName() or vehicle.typeName or "vehicle"
    if not self:triggerPuncture(vehicle, index) then
        return "[MudSystemPhysics] Could not puncture the selected AI wheel"
    end
    if self.aiAutoRepairEnabled == true then
        return string.format(
            "[MudSystemPhysics] Test puncture: %s, %s wheel %d; stopped for %.0f s, repair cost $%.0f",
            tostring(helperName), tostring(vehicleName), index,
            (self.aiAutoRepairDelayMs or 10000)/1000, self.aiAutoRepairCost or 10
        )
    end
    return string.format(
        "[MudSystemPhysics] Test puncture: %s, %s wheel %d; AI job stopped for manual repair",
        tostring(helperName), tostring(vehicleName), index
    )
end

function PunctureSystem:consoleCommandPuncture(wheelIndex, componentIndex)
    local command = string.lower(tostring(wheelIndex or ""))
    if command == "status" or command == "scan" then
        local wheeledCount = 0
        for _, candidate in pairs(getMissionVehicles()) do
            local wheels = getVehicleWheels(candidate)
            if candidate ~= nil and candidate.spec_locomotive == nil
                and wheels ~= nil and hasPuncturableWheel(candidate) then
                wheeledCount = wheeledCount + 1
            end
        end
        local target = self:getConsoleVehicle(componentIndex)
        local targetName = target ~= nil
            and (target.getName ~= nil and target:getName() or target.typeName or "vehicle")
            or "none"
        local runtime = target ~= nil and self.travel[target] or nil
        local scheduled = runtime ~= nil and runtime.forcedRemaining ~= nil
            and string.format(", km-test=%.0f m (wheel %d)", runtime.forcedRemaining, runtime.forcedWheelIndex or 0)
            or ""
        local penalty = target ~= nil and self:getDrivePenalty(target) or nil
        local driveEffect = penalty ~= nil
            and string.format(", limit=%.1f km/h, steeringPull=%+.3f",
                self:getDriveSpeedLimitKph(target, penalty) or 0, penalty.steeringPull or 0)
            or ""
        local tyreWearEffect = ""
        local compatibility = rawget(_G, "UseYourTyresCompatibility")
        if target ~= nil and compatibility ~= nil and compatibility.active == true then
            local wearTotal, wearMaximum, riskTotal, count = 0, 0, 0, 0
            for _, wheel in ipairs(getVehicleWheels(target) or {}) do
                if wheelHasVisualTire(wheel) then
                    local risk, wear = self:getWheelPunctureRiskMultiplier(target, wheel)
                    if wear ~= nil then
                        wearTotal = wearTotal + wear
                        wearMaximum = math.max(wearMaximum, wear)
                        riskTotal = riskTotal + risk
                        count = count + 1
                    end
                end
            end
            if count > 0 then
                tyreWearEffect = string.format(
                    ", tyreWear avg/max=%.0f%%/%.0f%%, wearRisk=x%.2f",
                    wearTotal/count*100, wearMaximum*100, riskTotal/count
                )
            else
                tyreWearEffect = ", UseYourTyres=unsupported"
            end
        end
        return string.format(
            "[MudSystemPhysics] Punctures=%s, chance=%.2f%%/%d km, AI punctures=%s, AI auto-repair=%s, wheeled vehicles=%d, target=%s%s%s%s",
            self.enabled and "ON" or "OFF", (self.chancePerKm or 0)*100,
            math.floor(tonumber(self.chanceDistanceKm) or 1),
            self.aiPuncturesEnabled and "ON" or "OFF",
            self.aiAutoRepairEnabled and "ON" or "OFF",
            wheeledCount, tostring(targetName), scheduled, driveEffect, tyreWearEffect
        )
    end
    if command == "clear" then
        if g_server == nil then return "[MudSystemPhysics] 'clear' is server-only" end
        local remove = {}
        for vehicle, states in pairs(self.states) do
            for index in pairs(states) do remove[#remove+1] = {vehicle=vehicle, wheelIndex=index} end
        end
        for _, item in ipairs(remove) do
            PunctureStateEvent.send(item.vehicle, item.wheelIndex, self.STAGE_NONE, false, 0, 0, 0, 0)
        end
        for _, runtime in pairs(self.travel) do
            runtime.forcedRemaining = nil
            runtime.forcedWheelIndex = nil
            runtime.distance = 0
        end
        return "[MudSystemPhysics] All test punctures and kilometre tests cleared"
    end
    local vehicle = self:getConsoleVehicle(componentIndex)
    if vehicle == nil then
        return "[MudSystemPhysics] No wheeled vehicle found: look at one or move within 25 m"
    end
    local wheels = getVehicleWheels(vehicle) or {}
    local puncturableIndices = getPuncturableWheelIndices(vehicle)
    if #wheels == 0 then return "[MudSystemPhysics] Selected component has no wheels" end
    if #puncturableIndices == 0 then
        return "[MudSystemPhysics] Selected component has no visible pneumatic tires"
    end
    local index
    if wheelIndex == nil or command == "" or command == "random" or command == "test" then
        index = puncturableIndices[math.random(1, #puncturableIndices)]
    else
        index = math.floor(tonumber(wheelIndex) or 0)
    end
    if getWheel(vehicle, index) == nil then
        return string.format("Usage: gsMudPuncture <wheel 1-%d|test|random|clear|status> [component 1..N]", #wheels)
    end
    if not isPuncturableWheel(vehicle, index) then
        return "[MudSystemPhysics] Selected wheel is an invisible implement support and has no puncturable tire"
    end
    if not self.enabled then return "[MudSystemPhysics] Enable wheel punctures in settings first" end
    local eligible, reason = self:getPunctureEligibility(vehicle)
    if not eligible and reason == "crawler" then
        return "[MudSystemPhysics] Tracked vehicles cannot receive tire punctures"
    elseif not eligible and reason == "aiDisabled" then
        return "[MudSystemPhysics] AI vehicle punctures are disabled in settings"
    elseif not eligible and reason == "noVisualTire" then
        return "[MudSystemPhysics] Selected component has no visible pneumatic tires"
    end
    if self:triggerPuncture(vehicle, index) then
        local vehicleName = vehicle.getName ~= nil and vehicle:getName() or vehicle.typeName or "vehicle"
        return string.format("[MudSystemPhysics] Punctured %s, wheel %s", tostring(vehicleName), tostring(index))
    end
    return "[MudSystemPhysics] That wheel is already in the puncture workflow"
end

function PunctureSystem:consoleCommandPunctureKm(wheelIndex, componentIndex)
    if g_server == nil then
        return "[MudSystemPhysics] The kilometre test must be started by the server/host"
    end
    local vehicle = self:getConsoleVehicle(componentIndex)
    if vehicle == nil then
        return "[MudSystemPhysics] No wheeled vehicle found: look at one or move within 25 m"
    end
    if not self.enabled then
        return "[MudSystemPhysics] Enable wheel punctures in settings first"
    end
    local eligible, reason = self:getPunctureEligibility(vehicle)
    if not eligible and reason == "crawler" then
        return "[MudSystemPhysics] Tracked vehicles cannot receive tire punctures"
    elseif not eligible and reason == "aiDisabled" then
        return "[MudSystemPhysics] AI vehicle punctures are disabled in settings"
    elseif not eligible and reason == "noVisualTire" then
        return "[MudSystemPhysics] Selected component has no visible pneumatic tires"
    end
    if self:hasAnyState(vehicle) then
        return "[MudSystemPhysics] Repair or clear the current puncture first"
    end
    if self:hasForeignPuncture(vehicle) then
        return "[MudSystemPhysics] The selected vehicle already has a foreign puncture"
    end

    local wheels = getVehicleWheels(vehicle) or {}
    local command = string.lower(tostring(wheelIndex or "random"))
    local runtime = self.travel[vehicle]
    if runtime == nil then
        runtime = {distance=0}
        self.travel[vehicle] = runtime
    end

    if command == "clear" or command == "cancel" then
        runtime.forcedRemaining = nil
        runtime.forcedWheelIndex = nil
        return "[MudSystemPhysics] Kilometre puncture test cancelled"
    end

    local index
    if command == "" or command == "random" or command == "test" then
        local candidates = {}
        for candidateIndex, wheel in ipairs(wheels) do
            if wheelHasVisualTire(wheel) then
                candidates[#candidates+1] = candidateIndex
            end
        end
        if #candidates == 0 then
            return "[MudSystemPhysics] Selected vehicle has no visible pneumatic tires"
        end
        index = candidates[math.random(1, #candidates)]
    else
        index = math.floor(tonumber(wheelIndex) or 0)
        local wheel = getWheel(vehicle, index)
        if wheel == nil or wheel.physics == nil then
            return string.format("Usage: gsMudPunctureKm <wheel 1-%d|random|cancel> [component 1..N]", #wheels)
        end
        if not wheelHasVisualTire(wheel) then
            return "[MudSystemPhysics] Selected wheel is an invisible implement support and has no puncturable tire"
        end
    end

    runtime.distance = 0
    runtime.forcedRemaining = 1000
    runtime.forcedWheelIndex = index
    local vehicleName = vehicle.getName ~= nil and vehicle:getName() or vehicle.typeName or "vehicle"
    return string.format(
        "[MudSystemPhysics] Kilometre test armed: %s, wheel %d will puncture after 1000 m",
        tostring(vehicleName), index
    )
end

function PunctureSystem:loadMap()
    self:installHooks()
    if self.enabled then
        self:loadFromXML()
    else
        self.pending = {}
    end
    self:registerActivatable()
    if not self._consoleRegistered then
        addConsoleCommand("gsMudPuncture", "Puncture test: gsMudPuncture <wheel|test|random|clear|status> [component]", "consoleCommandPuncture", self)
        self._consoleRegistered = true
    end
    if not self._consoleKmRegistered then
        addConsoleCommand("gsMudPunctureKm", "Guaranteed puncture after 1 km: gsMudPunctureKm <wheel|random|cancel> [component]", "consoleCommandPunctureKm", self)
        self._consoleKmRegistered = true
    end
    if not self._consoleAIRegistered then
        addConsoleCommand(
            "gsMudAIPuncture",
            "Puncture active AI worker: gsMudAIPuncture <wheel|random> [component]",
            "consoleCommandAIPuncture",
            self
        )
        self._consoleAIRegistered = true
    end
    if g_currentMission ~= nil then
        FSBaseMission.registerToLoadOnMapFinished(g_currentMission, self)
    end
end

function PunctureSystem:onLoadMapFinished()
    self:applyPendingStates()
    if g_server == nil then
        self._syncPending = true
        self._syncDelay = 1000
    end
end

function PunctureSystem:deleteMap()
    self:unregisterActivatable()
    self:clearAllStatesLocal()
    self.states = setmetatable({}, {__mode="k"})
    self.travel = setmetatable({}, {__mode="k"})
    self.drivePenaltyCache = setmetatable({}, {__mode="k"})
    self.aiAutoRepairTimers = setmetatable({}, {__mode="k"})
    self._syncPending = false
end

local function punctureSavegameHook()
    if PunctureSystem ~= nil then PunctureSystem:saveToXML() end
end

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

-- Install before vehicle types register their Drivable functions. Installing only
-- in loadMap is too late because SpecializationUtil copies the function reference.
PunctureSystem:installHooks()
addModEventListener(PunctureSystem)
