--[[
    REA_terrainDepth.lua (ALL-IN)
    -----------------------------
    Features:
    - Terrainverformung (persistente Height-Änderung)
    - Felder + Feldwege + Waldwege (auch bei EngineSink=0 via künstlichem Sink)
    - Asphalt/Beton ausgeschlossen (wenn TerrainMaterial vorhanden)
    - Spurtiefe abhängig von:
      * Bodenfeuchte (DEINE Werte)
      * Fahrzeuggewicht
      * Rad-Verdichtung (Aufschaukeln)
      * Reifenprofil (Mud/Offroad vs Street)
      * Diff-Lock / Allrad (helfen gegen Festfahren)
    - Festfahren-System (RAD-basiert, mit Hysterese):
      * bleibt aktiv beim "Rausfahren"
      * kann sich verschlimmern (eingraben)
      * kann sich lösen, aber nur bei echter Entlastung
    - Optisches "Rappeln" (Pitch/Nicken) statt echtes Springen (Engine-limit)
    - Akustisches Rappeln: versucht vorhandene Slip-/Reifen-Sounds zu triggern (falls verfügbar)
]]--

REA_terrainDepth = {}
local REA_terrainDepth_mt = Class(REA_terrainDepth)

local function clamp(v, a, b)
    if v < a then return a end
    if v > b then return b end
    return v
end

local function lerp(a, b, t)
    return a + (b - a) * t
end

------------------------------------------------------------
-- Load factor: 0 = empty, 1 = full
------------------------------------------------------------

------------------------------------------------------------
-- Trailer detection
------------------------------------------------------------
local function isTrailerVehicle(vehicle)
    if vehicle == nil then return false end
    if vehicle.spec_trailer ~= nil then return true end
    if vehicle.spec_attachable ~= nil and vehicle.spec_attachable.attacherVehicle ~= nil then
        return true
    end
    return false
end
local function getVehicleLoadFactor(vehicle)
    if vehicle == nil or vehicle.getFillUnits == nil then
        return 0
    end

    local totalCap = 0
    local totalFill = 0

    for _, unit in pairs(vehicle:getFillUnits()) do
        if unit.capacity ~= nil and unit.capacity > 0 then
            totalCap  = totalCap  + unit.capacity
            totalFill = totalFill + (unit.fillLevel or 0)
        end
    end

    if totalCap <= 0 then
        return 0
    end

    return clamp(totalFill / totalCap, 0, 1)
end

------------------------------------------------------------
-- Track cells (fatigue / rut memory) helpers
------------------------------------------------------------
local function makeCellKey(x, z, cellSize)
    cellSize = cellSize or 1.5
    local cx = math.floor(x / cellSize)
    local cz = math.floor(z / cellSize)
    return tostring(cx) .. ":" .. tostring(cz)
end

local function getTerrainNormalXZ(terrainNode, x, z)
    if terrainNode == nil or getTerrainNormalAtWorldPos == nil then
        return 0, 1, 0
    end
    local nx, ny, nz = getTerrainNormalAtWorldPos(terrainNode, x, 0, z)
    return nx or 0, ny or 1, nz or 0
end

local function isReverseDriving(vehicle)
    if vehicle == nil then return false end
    if vehicle.getIsReverseDriving ~= nil then
        local ok, r = pcall(vehicle.getIsReverseDriving, vehicle)
        if ok and r ~= nil then return r == true end
    end
    if vehicle.spec_drivable ~= nil and vehicle.spec_drivable.isReverseDriving ~= nil then
        return vehicle.spec_drivable.isReverseDriving == true
    end
    return false
end

-- Winter tire bonus / street tire malus (heuristic) based on name hints.
local function getSeasonTireGripMultiplier(wheel)
    local airTemp = getAirTemperature()
    local groundTemp = getVirtualGroundTemperature()
    local winter = (airTemp <= 3.0) or (groundTemp <= 3.0)

    if not winter then
        return 1.0
    end

    local hay = ""
    if wheel ~= nil then
        if wheel.tireType ~= nil then
            hay = hay .. " " .. lower(wheel.tireType.name or wheel.tireType.typeName or wheel.tireType)
        end
        if wheel.tireConfigName ~= nil then
            hay = hay .. " " .. lower(wheel.tireConfigName)
        end
        if wheel.filename ~= nil then
            hay = hay .. " " .. lower(wheel.filename)
        end
    end

    if string.find(hay, "winter", 1, true)
        or string.find(hay, "snow", 1, true)
        or string.find(hay, "chain", 1, true)
        or string.find(hay, "chains", 1, true) then
        return 1.15
    end

    if string.find(hay, "street", 1, true)
        or string.find(hay, "road", 1, true)
        or string.find(hay, "asphalt", 1, true)
        or string.find(hay, "tarmac", 1, true) then
        return 0.88
    end

    return 1.0
end


------------------------------------------------------------
-- Winter realism: ground freeze/thaw + cold engine warm-up
------------------------------------------------------------
local function getSoilMoisture()
    if g_currentMission ~= nil
        and g_currentMission.environment ~= nil
        and g_currentMission.environment.weather ~= nil then
        return g_currentMission.environment.weather.soilMoisture or 0
    end
    return 0
end

local function getAirTemperature()
    if g_currentMission ~= nil
        and g_currentMission.environment ~= nil
        and g_currentMission.environment.weather ~= nil then
        return g_currentMission.environment.weather.airTemperature or 5
    end
    return 5
end

-- Approximate ground temperature from air temp, time of day and moisture.
-- Goal: frozen ground is hard; thaw (around 0..2°C) is extremely soft.
local function getVirtualGroundTemperature()
    local airTemp = getAirTemperature()
    local soil = getSoilMoisture()

    local hour = 12
    if g_currentMission ~= nil
        and g_currentMission.environment ~= nil
        and g_currentMission.environment.currentHour ~= nil then
        hour = g_currentMission.environment.currentHour
    end

    local nightCool = (hour < 7 or hour > 19) and -2.5 or 0
    local moistureEffect = soil * 3.0

    return airTemp + nightCool - moistureEffect
end

-- Multiplier for sink/digging:
--   frozen  -> < 1 (hard)
--   thaw    -> > 1 (very soft)
local function getFreezeThawFactor()
    local t = getVirtualGroundTemperature()

    if t <= -1.0 then
        return 0.30 -- frozen: hard ground
    end

    if t > -1.0 and t < 2.0 then
        return 1.80 -- thaw: worst case, very soft
    end

    return 1.00
end

-- Virtual engine warm-up per vehicle: 0..1
-- Cold weather => sluggish for ~2–3 minutes (best to warm up).
local function updateEngineTemperature(vehicle, dt)
    if vehicle == nil or dt == nil or dt <= 0 then
        return 1.0, false
    end

    local airTemp = getAirTemperature()
    local winterMode = (airTemp <= 5.0) or (getVirtualGroundTemperature() <= 5.0)

    if not winterMode then
        vehicle.reaEngineTemp = 1.0
        return 1.0, false
    end

    vehicle.reaEngineTemp = vehicle.reaEngineTemp or 0.0

    local motorLoad = 0
    if vehicle.getMotorLoad ~= nil then
        local ok, ml = pcall(vehicle.getMotorLoad, vehicle)
        if ok and type(ml) == "number" then motorLoad = ml end
    end

    local dtSec = dt / 1000
    local coldFactor = clamp((5.0 - airTemp) / 15.0, 0.0, 1.0)

    -- Warm-up target: ~150s at idle, faster under load; slower when very cold.
    local baseWarmPerSec = 1.0 / 150.0
    local loadBoost = clamp(0.90 + 0.50 * clamp(motorLoad, 0.0, 1.0), 0.90, 1.40)
    local warmPerSec = baseWarmPerSec * loadBoost * (1.0 - 0.45 * coldFactor)

    vehicle.reaEngineTemp = clamp(vehicle.reaEngineTemp + warmPerSec * dtSec, 0.0, 1.0)

    return vehicle.reaEngineTemp, true
end

local function getRootNode(vehicle)
    if vehicle == nil then return nil end
    if vehicle.rootNode ~= nil then return vehicle.rootNode end
    if vehicle.components ~= nil and vehicle.components[1] ~= nil and vehicle.components[1].node ~= nil then
        return vehicle.components[1].node
    end
    return nil
end

local function safeGetRotation(node)
    if node == nil or getRotation == nil then return 0, 0, 0 end
    return getRotation(node)
end

local function safeSetRotation(node, rx, ry, rz)
    if node == nil or setRotation == nil then return end
    pcall(function() setRotation(node, rx, ry, rz) end)
end

local function safeAddForce(node, fx, fy, fz)
    if node == nil or addForce == nil then return end
    pcall(function()
        -- (node, fx, fy, fz, px, py, pz, isWorldForce)
        addForce(node, fx, fy, fz, 0, 0, 0, true)
    end)
end

local function safeGetLinearVelocity(node)
    if node == nil or getLinearVelocity == nil then return 0, 0, 0 end
    return getLinearVelocity(node)
end

-- ------------------------------------------------------------
-- Helpers: Reifenprofil / Diff-Lock / AWD erkennen (best-effort)
-- ------------------------------------------------------------

local function lower(s)
    if s == nil then return "" end
    return string.lower(tostring(s))
end

local function guessTireProfileFromWheel(wheel)
    -- Rückgabe: profileName, gripFactor
    -- gripFactor >1 = besser im Schlamm, <1 = schlechter
    -- best-effort Heuristik (FS-Fahrzeuge nutzen unterschiedliche Namen)
    local candidates = {}

    if wheel ~= nil then
        -- oft vorhanden:
        if wheel.tireType ~= nil then
            table.insert(candidates, wheel.tireType.name or wheel.tireType.typeName or wheel.tireType)
        end
        if wheel.tireConfig ~= nil then
            table.insert(candidates, wheel.tireConfig.name or wheel.tireConfig)
        end
        if wheel.tireConfigName ~= nil then
            table.insert(candidates, wheel.tireConfigName)
        end
        if wheel.filename ~= nil then
            table.insert(candidates, wheel.filename)
        end
    end

    local hay = lower(table.concat(candidates, " "))

    -- MUD / OFFROAD / FORESTRY
    if string.find(hay, "mud", 1, true)
    or string.find(hay, "offroad", 1, true)
    or string.find(hay, "off-road", 1, true)
    or string.find(hay, "forest", 1, true)
    or string.find(hay, "forestry", 1, true)
    or string.find(hay, "dirt", 1, true)
    or string.find(hay, "terrain", 1, true) then
        return "MUD", 1.18
    end

    -- STREET / ROAD
    if string.find(hay, "street", 1, true)
    or string.find(hay, "road", 1, true)
    or string.find(hay, "tarmac", 1, true)
    or string.find(hay, "asphalt", 1, true) then
        return "STREET", 0.88
    end

    -- STANDARD
    return "STANDARD", 1.00
end

local function detectDiffLock(vehicle)
    -- best-effort: verschiedene Mods/Specs nennen das unterschiedlich
    if vehicle == nil then return false end

    -- Häufig: getIsDifferentialLocked / spec_drivable flags
    if vehicle.getIsDifferentialLocked ~= nil then
        local ok, v = pcall(vehicle.getIsDifferentialLocked, vehicle)
        if ok and v ~= nil then return v == true end
    end

    if vehicle.spec_drivable ~= nil then
        if vehicle.spec_drivable.isDifferentialLocked ~= nil then
            return vehicle.spec_drivable.isDifferentialLocked == true
        end
        if vehicle.spec_drivable.diffLockEngaged ~= nil then
            return vehicle.spec_drivable.diffLockEngaged == true
        end
    end

    -- Manche Motorized-Setups:
    if vehicle.spec_motorized ~= nil then
        if vehicle.spec_motorized.isDifferentialLocked ~= nil then
            return vehicle.spec_motorized.isDifferentialLocked == true
        end
    end

    return false
end

local function detectAWD(vehicle)
    -- best-effort: 4WD/Allrad aktiv?
    if vehicle == nil then return false end

    if vehicle.getIsAWDActive ~= nil then
        local ok, v = pcall(vehicle.getIsAWDActive, vehicle)
        if ok and v ~= nil then return v == true end
    end

    if vehicle.getIsAllWheelDrive ~= nil then
        local ok, v = pcall(vehicle.getIsAllWheelDrive, vehicle)
        if ok and v ~= nil then return v == true end
    end

    if vehicle.spec_drivable ~= nil then
        if vehicle.spec_drivable.isAWDActive ~= nil then
            return vehicle.spec_drivable.isAWDActive == true
        end
        if vehicle.spec_drivable.isAllWheelDriveActive ~= nil then
            return vehicle.spec_drivable.isAllWheelDriveActive == true
        end
        if vehicle.spec_drivable.driveMode ~= nil then
            -- manche Systeme: 0=2WD, 1=AWD
            return tostring(vehicle.spec_drivable.driveMode) ~= "0"
        end
    end

    return false
end

-- ------------------------------------------------------------
-- Optional: Akustisches Rappeln (nur falls vorhandene Samples existieren)
-- ------------------------------------------------------------

local function tryPlayRattleSound(vehicle, intensity)
    -- intensity: 0..1
    -- Ohne zusätzliche Sound-Dateien können wir nur vorhandene Samples nutzen.
    -- Deshalb: best-effort, sonst no-op.
    if vehicle == nil then return end
    intensity = clamp(intensity or 0, 0, 1)

    -- Viele Fahrzeuge haben spec_wheels mit Slip-Sounds, aber die Felder sind nicht standardisiert.
    -- Wir versuchen ein paar typische Stellen.
    local wheels = vehicle.spec_wheels and vehicle.spec_wheels.wheels or nil
    if wheels == nil then return end

    for _, w in ipairs(wheels) do
        -- Kandidaten: w.slipSample / w.surfaceSample / w.tireSample ...
        local sample = w.slipSample or w.tireSlipSample or w.surfaceSample or nil
        if sample ~= nil and g_soundManager ~= nil and g_soundManager.playSample ~= nil then
            pcall(function()
                -- Nicht alle Sample-Objekte lassen Lautstärke setzen; daher nur play versuchen.
                g_soundManager:playSample(sample, 1, intensity, 0, 0, 0)
            end)
            return
        end
    end
end

-- ------------------------------------------------------------

function REA_terrainDepth.new(isServer, isClient)
    local self = setmetatable({}, REA_terrainDepth_mt)
    self.isServer = isServer
    self.isClient = isClient

    self.tracks = {}
    self.maxTracks = 50000
    self.saveRootKey = "REA_TERRAIN_DEPTH"
    self.hookedWheelTerrain = false

    -- pro Rad: Verdichtung (Aufschaukeln)
    self.wheelCompaction = {}

    -- pro Rad: Festfahr-Zustand (Hysterese)
    self.wheelStuck = {}

    -- pro Fahrzeug: optisches Rappeln (cooldown, phase)
    self.vehicleState = {}

    return self
end

-----------------------------------------------------------------------
-- Engine Hooks
-----------------------------------------------------------------------

function REA_terrainDepth:loadMap(mapName)
    if not self.isServer then return end
    self:hookWheelTerrain()
    self:loadFromXML()
end

function REA_terrainDepth:saveToXMLFile(xmlFile, key)
    self:saveToOwnXMLFile()
end

function REA_terrainDepth:update(dt)
    if dt == nil or dt <= 0 then return end

    -- FS25: WheelTerrainHandler is sometimes not ready in loadMap()
    if self.isServer and not self.hookedWheelTerrain then
        if WheelTerrainHandler ~= nil and Utils ~= nil and Utils.overwrittenFunction ~= nil then
            self:hookWheelTerrain()
        end
    end

    -- reset per tick, then onWheelSink will rebuild it
    if g_currentMission ~= nil and g_currentMission.vehicles ~= nil then
        for _, v in pairs(g_currentMission.vehicles) do
            if v ~= nil and v.spec_motorized ~= nil then
                v.reaSlipIndex = 0
                -- advance engine warm-up in winter even while idling
                updateEngineTemperature(v, dt)
            end
        end
    end
end

-----------------------------------------------------------------------
-- Hook WheelTerrainHandler
-----------------------------------------------------------------------

function REA_terrainDepth:hookWheelTerrain()
    if self.hookedWheelTerrain then return end

    if WheelTerrainHandler == nil or Utils == nil or Utils.overwrittenFunction == nil then
        print("REA_terrainDepth: WheelTerrainHandler/Utils.overwrittenFunction nicht gefunden!")
        return
    end

    print("REA_terrainDepth: Hooking WheelTerrainHandler.setWheelSink ...")

    WheelTerrainHandler.setWheelSink = Utils.overwrittenFunction(
        WheelTerrainHandler.setWheelSink,
        function(handler, superFunc, wheel, dt)
            superFunc(handler, wheel, dt)
            g_reaTerrainDepth:onWheelSink(wheel, dt)
        end
    )

    self.hookedWheelTerrain = true
end

-----------------------------------------------------------------------
-- Kernlogik pro Rad
-----------------------------------------------------------------------

function REA_terrainDepth:onWheelSink(wheel, dt)
    if not self.isServer then return end
    if wheel == nil or wheel.node == nil then return end
    if dt == nil or dt <= 0 then return end

    local vehicle = wheel.vehicle or wheel.nodeObject
    if vehicle == nil then return end

    -- nur Traktoren (Motorized + Wheels)
    if vehicle.spec_motorized == nil or vehicle.spec_wheels == nil then
        return
    end

    local terrain = g_currentMission and g_currentMission.terrainRootNode
    if terrain == nil then return end

    local x, _, z = getWorldTranslation(wheel.node)

    -- track cell (fatigue/rut memory)
    local cellKey = makeCellKey(x, z, self.trackCellSize)
    local cell = self.trackCells[cellKey]
    if cell == nil then
        cell = { fatigue = 0.0, depth = 0.0 }
        self.trackCells[cellKey] = cell
    end


    -------------------------------------------------------------------
    -- Asphalt/Beton ausschließen (falls verfügbar)
    -------------------------------------------------------------------
    if getTerrainMaterialAtWorldPos ~= nil and TerrainMaterial ~= nil then
        local _, material = getTerrainMaterialAtWorldPos(terrain, x, 0, z)
        if material == TerrainMaterial.ASPHALT or material == TerrainMaterial.CONCRETE then
            return
        end
    end

    -------------------------------------------------------------------
    -- Basis-Sink (wenn Engine 0 liefert -> Wege/Wald trotzdem)
    -------------------------------------------------------------------
    local sink = wheel.lastTerrainSink or 0
    if sink <= 0 then
        sink = 0.25
    end

    -------------------------------------------------------------------
    -- Bodenfeuchte (DEINE Werte)
    -------------------------------------------------------------------
    local soilMoisture = 0
    if g_currentMission ~= nil and g_currentMission.environment ~= nil
        and g_currentMission.environment.weather ~= nil then
        soilMoisture = g_currentMission.environment.weather.soilMoisture or 0
    end

    local moistureFactor = 1
    if soilMoisture < 0.20 then
        moistureFactor = 0.9
    elseif soilMoisture < 0.50 then
        moistureFactor = 1.9
    else
        moistureFactor = 2.5
    end

    local reverse = isReverseDriving(vehicle)
    local reverseSinkFactor = reverse and 0.88 or 1.00

    -------------------------------------------------------------------
    -- Gewicht
    -------------------------------------------------------------------
    local mass = 8000
    if vehicle.getTotalMass ~= nil then
        mass = vehicle:getTotalMass()
    end
    local weightFactor = clamp(mass / 8000, 0.8, 2.5)

    -- Load-based tire compression (visible)
    local rawLoadFactor = getVehicleLoadFactor(vehicle)
    vehicle.reaLoadSpring = vehicle.reaLoadSpring or rawLoadFactor
    local springRate = 0.002  -- slow rebound when unloading
    vehicle.reaLoadSpring = vehicle.reaLoadSpring + (rawLoadFactor - vehicle.reaLoadSpring) * springRate
    local loadFactor = vehicle.reaLoadSpring
    local trailerBoost = isTrailerVehicle(vehicle) and 1.25 or 1.00
    local loadWeightFactor = lerp(1.00, 1.15 * trailerBoost, loadFactor)

    -------------------------------------------------------------------
    -- Reifenprofil
    -------------------------------------------------------------------
    local tireProfile, tireGrip = guessTireProfileFromWheel(wheel)
    -- tireGrip >1: besser im Matsch (weniger festfahren), aber kann mehr "graben"
    -- tireGrip <1: schlechter im Matsch (mehr festfahren)
    local tireStuckPenalty = 1.0 / tireGrip
    local tireDigFactor = lerp(0.95, 1.10, clamp((tireGrip - 1.0) / 0.25, 0, 1)) -- Mud gräbt etwas mehr

    -------------------------------------------------------------------
    -- Diff-Lock / AWD
    -------------------------------------------------------------------
    local diffLock = detectDiffLock(vehicle)
    local awd = detectAWD(vehicle)

    local diffHelp = diffLock and 0.78 or 1.00  -- weniger Festfahren-Aufbau
    local awdHelp  = awd and 0.82 or 1.00

    -------------------------------------------------------------------
    -- Motorlast / Radspeed (für Festfahren)
    -------------------------------------------------------------------
    local motorLoad = 0
    if vehicle.getMotorLoad ~= nil then
        motorLoad = vehicle:getMotorLoad() or 0
    end
    local wheelSpeed = math.abs(wheel.lastSpeed or 0)

    local wheelId = tostring(wheel.node)
    local vehId = tostring(vehicle)

    if self.wheelCompaction[wheelId] == nil then self.wheelCompaction[wheelId] = 0 end
    if self.wheelStuck[wheelId] == nil then self.wheelStuck[wheelId] = 0 end
    if self.vehicleState[vehId] == nil then
        self.vehicleState[vehId] = { phase = 0, cooldown = 0, rattle = 0 }
    end

    -------------------------------------------------------------------
    -- Aufschaukeln (Verdichtung) PRO RAD
    -------------------------------------------------------------------
    local stuckLikeNow = (motorLoad > 0.70 and wheelSpeed < 1.0)
    if stuckLikeNow then
        -- Mud/Offroad -> weniger compaction buildup als Street
        local compBuild = 0.15 * tireStuckPenalty
        self.wheelCompaction[wheelId] = math.min(self.wheelCompaction[wheelId] + dt * compBuild, 1.5)
    end
    local compactionFactor = 1.0 + self.wheelCompaction[wheelId]

    -------------------------------------------------------------------
    -- FESTFAHREN-ZUSTAND (Hysterese) PRO RAD
    -------------------------------------------------------------------
    local stuckState = self.wheelStuck[wheelId]

    -- Aufbau (eingraben) – stärker bei Nässe, Straßenreifen, ohne AWD/Diff
    if motorLoad > 0.72 and wheelSpeed < 1.2 then
        local wet = lerp(0.16, 0.34, clamp((soilMoisture - 0.20) / 0.50, 0, 1))
        local setup = tireStuckPenalty * diffHelp * awdHelp
        local build = wet * setup
        stuckState = stuckState + dt * build
    end

    -- Abbau: nur wenn wirklich frei (schneller + geringe Last) – AWD/Diff helfen
    if wheelSpeed > 2.8 and motorLoad < 0.40 then
        local releaseHelp = (diffLock and 1.18 or 1.00) * (awd and 1.12 or 1.00) * tireGrip
        stuckState = stuckState - dt * (0.22 * releaseHelp)
    else
        stuckState = stuckState - dt * 0.03
    end

    stuckState = clamp(stuckState, 0.0, 1.6)
    self.wheelStuck[wheelId] = stuckState

    -------------------------------------------------------------------
    -- Finaler Sink (Spur)
    -------------------------------------------------------------------
    local stuckFactor = 1.0 + lerp(0.0, 1.35, clamp(stuckState / 1.2, 0, 1))
    local freezeThaw = getFreezeThawFactor()

    -- Cold engine in winter: a bit less effective (sluggish), tires feel stiffer -> slightly less grip.
    local engineTemp, winterMode = 1.0, false
    if vehicle ~= nil and vehicle.spec_motorized ~= nil then
        engineTemp, winterMode = updateEngineTemperature(vehicle, dt)
    end
    local coldGripPenalty = (winterMode and lerp(1.15, 1.00, engineTemp)) or 1.0

    local sidewallFactor = isTrailerVehicle(vehicle) and lerp(1.00, 1.10, loadFactor) or 1.00
    sink = sink * sidewallFactor * moistureFactor * weightFactor * compactionFactor * stuckFactor * tireDigFactor * freezeThaw * coldGripPenalty
    wheel.lastTerrainSink = sink

    -------------------------------------------------------------------
    -- Terrain ändern (persistente Verformung)
    -------------------------------------------------------------------
    local h = getTerrainHeightAtWorldPos(terrain, x, 0, z)
    local newH = h - sink * 0.02
    setTerrainHeightAtWorldPos(terrain, x, newH, z)
    self:addTrackPoint(x, z, newH)

    -- update cell fatigue/depth
    local addFatigue = clamp((finalSink * 0.03), 0.0, 0.08)
    cell.fatigue = clamp((cell.fatigue or 0) + addFatigue, 0.0, 1.0)

    local rutAdd = clamp((finalSink * 0.015) * (freezeThaw > 1.2 and 1.6 or 1.0), 0.0, 0.06)
    cell.depth = clamp((cell.depth or 0) + rutAdd, 0.0, 2.0)


    -------------------------------------------------------------------
    -- OPTISCHES "RAPPELN" (A): Nicken statt echtes Springen
    -- Intensität abhängig von stuckState + Last + Setup
    -------------------------------------------------------------------
    local vState = self.vehicleState[vehId]
    local rootNode = getRootNode(vehicle)

    if rootNode ~= nil then
        local sevStuck = clamp((stuckState - 0.35) / 0.95, 0, 1)
        local sevLoad  = clamp((motorLoad - 0.60) / 0.40, 0, 1)
        local sevWet   = clamp((soilMoisture - 0.25) / 0.55, 0, 1)

        -- Street-Reifen -> mehr Rappeln; Mud -> weniger
        local setupRattle = clamp(1.20 * tireStuckPenalty * (diffLock and 0.85 or 1.0) * (awd and 0.90 or 1.0), 0.7, 1.35)

        local severity = clamp((0.55 * sevStuck + 0.30 * sevLoad + 0.15 * sevWet) * setupRattle, 0, 1)

        -- Rattle nur wenn wir wirklich "arbeiten"
        if severity > 0.10 and motorLoad > 0.55 then
            -- Frequenz: je schlimmer, desto schneller
            local period = lerp(520, 180, severity) -- ms
            vState.phase = ((vState.phase or 0) + dt) % period

            if vState.phase < dt then
                local dir = ((g_currentMission.time % 400) < 200) and 1 or -1
                local pitch = lerp(0.0018, 0.0105, severity)

                local rx, ry, rz = safeGetRotation(rootNode)
                safeSetRotation(rootNode, rx + pitch * dir, ry, rz)

                -- leichte Drag-Pulse (fühlt sich "zerrend" an)
                local lvx, lvy, lvz = safeGetLinearVelocity(rootNode)
                local drag = (mass * 0.18) * lerp(0.4, 1.6, severity)
                safeAddForce(rootNode, -lvx * drag, 0, -lvz * drag)

                -- Akustik (wenn möglich)
                tryPlayRattleSound(vehicle, severity)
            end
        end
    end

    -------------------------------------------------------------------
    -- PHYSIKALISCHES BLOCKIEREN (B): ab hoher stuckState schwer / fast unmöglich
    -- AWD/Diff/Mud helfen ein bisschen, aber falsches Gas kann dich "töten".
    -------------------------------------------------------------------
    if rootNode ~= nil and stuckState > 0.85 then
        local lvx, lvy, lvz = safeGetLinearVelocity(rootNode)
        local speed = math.sqrt(lvx * lvx + lvz * lvz)

        local block = clamp((stuckState - 0.85) / 0.55, 0, 1)

        -- Setup hilft (Mud + AWD + Diff)
        local help = (tireGrip) * (diffLock and 1.10 or 1.0) * (awd and 1.08 or 1.0)
        local invHelp = 1.0 / clamp(help, 0.85, 1.35)

        if speed > 0.1 then
            local drag = (mass * 0.85) * lerp(0.6, 2.2, block) * invHelp
            safeAddForce(rootNode, -lvx * drag, 0, -lvz * drag)
        end

        local suck = (mass * 0.30) * lerp(0.4, 1.6, block) * invHelp
        safeAddForce(rootNode, 0, -suck, 0)
    end
end

-----------------------------------------------------------------------
-- Track Speicher
-----------------------------------------------------------------------

function REA_terrainDepth:addTrackPoint(x, z, h)
    if #self.tracks >= self.maxTracks then
        table.remove(self.tracks, 1)
    end
    table.insert(self.tracks, { x = x, z = z, h = h })
end

-----------------------------------------------------------------------
-- XML I/O (Persistenz)
-----------------------------------------------------------------------

function REA_terrainDepth:getSavegameFilename()
    if g_currentMission == nil or g_currentMission.missionInfo == nil then
        return nil
    end
    local dir = g_currentMission.missionInfo.savegameDirectory
    if dir == nil or dir == "" then
        return nil
    end
    return dir .. "/reaTerrainDepth.xml"
end

function REA_terrainDepth:loadFromXML()
    local filename = self:getSavegameFilename()
    if filename == nil or not fileExists(filename) then return end

    print("REA_terrainDepth: Lade Terrain-Spuren aus " .. filename)

    local xml = loadXMLFile("REA_TERRAIN_DEPTH", filename)
    if xml == nil then return end

    self.tracks = {}
    local i = 0
    while true do
        local key = string.format("%s.track(%d)", self.saveRootKey, i)
        if not hasXMLProperty(xml, key) then break end

        local x = getXMLFloat(xml, key .. "#x")
        local z = getXMLFloat(xml, key .. "#z")
        local h = getXMLFloat(xml, key .. "#h")

        if x and z and h then
            table.insert(self.tracks, { x = x, z = z, h = h })
            if g_currentMission.terrainRootNode ~= nil then
                setTerrainHeightAtWorldPos(g_currentMission.terrainRootNode, x, h, z)
            end
        end

        i = i + 1
    end

    delete(xml)
end

function REA_terrainDepth:saveToOwnXMLFile()
    local filename = self:getSavegameFilename()
    if filename == nil then return end

    print("REA_terrainDepth: Speichere Terrain-Spuren nach " .. filename)

    local xml = createXMLFile("REA_TERRAIN_DEPTH", filename, self.saveRootKey)
    if xml == nil then return end

    for i, t in ipairs(self.tracks) do
        local key = string.format("%s.track(%d)", self.saveRootKey, i - 1)
        setXMLFloat(xml, key .. "#x", t.x)
        setXMLFloat(xml, key .. "#z", t.z)
        setXMLFloat(xml, key .. "#h", t.h)
    end

    saveXMLFile(xml)
    delete(xml)
end

-----------------------------------------------------------------------
-- Registrierung
-----------------------------------------------------------------------

g_reaTerrainDepth = REA_terrainDepth.new(g_server ~= nil, g_client ~= nil)
addModEventListener(g_reaTerrainDepth)
