-- Author: U_BMP
-- Group: https://vk.com/biomodprod_utilit_fs
-- Date: 19.11.2025

FieldGroundMudPhysics = {}
FieldGroundMudPhysics.name = g_currentModName or "FieldGroundMudPhysics"
FieldGroundMudPhysics.path = g_currentModDirectory or ""
addModEventListener(FieldGroundMudPhysics)

FieldGroundMudPhysics.dirtColorEnable          = true
FieldGroundMudPhysics.dirtColorUseSprayLayers  = true
FieldGroundMudPhysics.dirtColorMinEffMud       = 0.03
FieldGroundMudPhysics.dirtColorBodyRiseSpeed   = 0.55
FieldGroundMudPhysics.dirtColorWheelRiseSpeed  = 0.85
FieldGroundMudPhysics.dirtColorDryReturnSpeed  = 0.18
FieldGroundMudPhysics.dirtColorTintBlendSpeed  = 0.22
FieldGroundMudPhysics.dirtColorBodyHoldSec     = 1.80
FieldGroundMudPhysics.dirtColorWheelHoldSec    = 2.60
FieldGroundMudPhysics.dirtColorTargetLagSpeed  = 0.30
FieldGroundMudPhysics.dirtColorLimeFadeSpeed   = 0.12
FieldGroundMudPhysics.dirtColorSpeedBoostKph   = 18.0
FieldGroundMudPhysics.dirtColorUpdateIntervalMs = 150
FieldGroundMudPhysics.surfaceKindCacheMs       = 240
FieldGroundMudPhysics.surfaceKindCacheMoveM    = 0.75
FieldGroundMudPhysics.snowSurfaceProbeTempMarginC = 10.0
FieldGroundMudPhysics.dirtColorMudR            = 0.059
FieldGroundMudPhysics.dirtColorMudG            = 0.038
FieldGroundMudPhysics.dirtColorMudB            = 0.021
FieldGroundMudPhysics.dirtColorFertilizerR     = 0.36
FieldGroundMudPhysics.dirtColorFertilizerG     = 0.31
FieldGroundMudPhysics.dirtColorFertilizerB     = 0.20
FieldGroundMudPhysics.dirtColorLimeR           = 0.82
FieldGroundMudPhysics.dirtColorLimeG           = 0.90
FieldGroundMudPhysics.dirtColorLimeB           = 0.82
FieldGroundMudPhysics.dirtColorManureR         = 0.20
FieldGroundMudPhysics.dirtColorManureG         = 0.14
FieldGroundMudPhysics.dirtColorManureB         = 0.08
FieldGroundMudPhysics.dirtColorLiquidManureR   = 0.16
FieldGroundMudPhysics.dirtColorLiquidManureG   = 0.15
FieldGroundMudPhysics.dirtColorLiquidManureB   = 0.09
FieldGroundMudPhysics.dirtColorSnowR           = 0.84
FieldGroundMudPhysics.dirtColorSnowG           = 0.86
FieldGroundMudPhysics.dirtColorSnowB           = 0.88
FieldGroundMudPhysics.snowMudMeshStickTempC    = -7.0
FieldGroundMudPhysics.snowWheelCleanPerSec     = 0.22



-- =========================================================
-- НАСТРОЙКИ (ГЛОБАЛЬНЫЕ)
-- =========================================================
FieldGroundMudPhysics.enabled = true        -- включение / выключение всей системы грязи
FieldGroundMudPhysics.debug   = false       -- режим отладки (логи в консоль)

FieldGroundMudPhysics.mpRadiusSyncEnable      = false
FieldGroundMudPhysics.mpRadiusSyncIntervalMs  = 120



-- ---------------------------------------------------------
-- ВЛАЖНОСТЬ: БОЛЕЕ РЕАЛИСТИЧНАЯ СВЯЗКА (soil + surface water)
-- ---------------------------------------------------------

-- До 20% влажности эффекта почти нет
FieldGroundMudPhysics.wetnessDeadZone      = 0.20    -- 0..1 (20%)

-- Минимальный эффект в "сухой зоне" (если хочешь совсем 0, ставь 0.0)
FieldGroundMudPhysics.wetnessMinStrength   = 0.000   -- 0..1 (например 0.005 для 0.5%)

-- Насколько "резко" растёт эффект после deadZone (1.0=мягко, 1.4..2.0=реалистичнее)
FieldGroundMudPhysics.wetnessCurvePow      = 1.55

-- Дождь как "вода на поверхности": быстрый слой, который резко усиливает грязь
FieldGroundMudPhysics.rainSurfaceBoostMax  = 0.28    -- добавка к wetnessEff при сильном дожде (0..~0.35)

-- Сглаживание (секунды): влажность грунта меняется медленно, surface быстрее
FieldGroundMudPhysics.wetnessSmoothSec     = 14.0    -- медленное сглаживание влажности
FieldGroundMudPhysics.rainSmoothSec        = 3.0     -- сглаживание дождя (быстро)

-- После дождя "жидкая поверхность" держится дольше (как лужи/каша)
FieldGroundMudPhysics.surfaceHoldSec       = 22.0    -- сколько секунд surface слой не падает резко


-- ---------------------------------------------------------
-- ВЛАЖНОСТЬ И ДОЖДЬ
-- ---------------------------------------------------------
FieldGroundMudPhysics.wetnessThreshold     = 0.12  -- минимальная влажность, при которой грязь начинает работать
FieldGroundMudPhysics.rainForcesWetnessMin = 0.55  -- минимальная эффективная влажность при активном дожде
FieldGroundMudPhysics.rainScaleThreshold   = 0.04  -- порог интенсивности дождя, после которого он "принудительно" мочит землю

-- ---------------------------------------------------------
-- ПЛАВНОЕ ВКЛЮЧЕНИЕ / ВЫКЛЮЧЕНИЕ ЭФФЕКТА
-- ---------------------------------------------------------
FieldGroundMudPhysics.rampInSec  = 8.0      -- время (сек), за которое грязь полностью "набирается"
FieldGroundMudPhysics.rampOutSec = 3.2      -- время (сек), за которое эффект грязи сходит
FieldGroundMudPhysics.rampPow    = 1.55     -- степень кривой (больше = резче в конце)

-- ---------------------------------------------------------
-- МЕТР ЗАРЫВАНИЯ ТЕХНИКИ (0..1)
-- ---------------------------------------------------------
FieldGroundMudPhysics.sinkInSpeed    = 2.25 -- скорость увеличения "утопания" техники
FieldGroundMudPhysics.sinkOutSpeed   = 0.85 -- скорость выхода из утопания
FieldGroundMudPhysics.stuckThreshold = 0.82 -- порог, после которого техника считается застрявшей

-- ---------------------------------------------------------
-- ОГРАНИЧЕНИЕ СКОРОСТИ В ГРЯЗИ
-- ---------------------------------------------------------
FieldGroundMudPhysics.maxSpeedMudKph   = 14.0 -- максимальная скорость в грязи (км/ч)
FieldGroundMudPhysics.maxSpeedStuckKph = 3.5  -- максимальная скорость при застревании

-- ---------------------------------------------------------
-- НАГРУЗКА НА ДВИГАТЕЛЬ / ПОТЕРЯ МОЩНОСТИ
-- ---------------------------------------------------------
FieldGroundMudPhysics.motorLoadEnable   = true -- включить дополнительную нагрузку на двигатель
FieldGroundMudPhysics.motorLoadFromSink = 2.6  -- влияние глубины зарывания на нагрузку
FieldGroundMudPhysics.motorLoadFromSlip = 1.6  -- влияние пробуксовки на нагрузку
FieldGroundMudPhysics.motorLoadMaxMult  = 14.0 -- максимальный множитель нагрузки
FieldGroundMudPhysics.motorLoadMinEff   = 0.10 -- минимальная эффективность грязи для применения нагрузки

-- ---------------------------------------------------------
-- ВЯЗКОЕ СОПРОТИВЛЕНИЕ КАЧЕНИЮ КОЛЁС (АНАЛОГ "ТОРМОЗА")
-- ---------------------------------------------------------
FieldGroundMudPhysics.wheelBrakeEnable    = true  -- включить вязкое сопротивление колёс
FieldGroundMudPhysics.wheelBrakeBase      = 3.228 -- базовое сопротивление
FieldGroundMudPhysics.wheelBrakeFromSink  = 6.252 -- вклад глубины зарывания
FieldGroundMudPhysics.wheelBrakeFromSlip  = 3.0   -- вклад пробуксовки
FieldGroundMudPhysics.wheelBrakeRatio     = 0.034 -- коэффициент масштабирования
FieldGroundMudPhysics.wheelBrakeMaxRatio  = 2.40  -- максимальный множитель сопротивления
FieldGroundMudPhysics.wheelBrakeMinEff    = 0.08  -- минимальная эффективность грязи
FieldGroundMudPhysics.wheelBrakeSmoothIn  = 2.1   -- скорость нарастания сопротивления
FieldGroundMudPhysics.wheelBrakeSmoothOut = 6.0   -- скорость спада сопротивления

-- ---------------------------------------------------------
-- РЕАЛЬНОЕ УМЕНЬШЕНИЕ РАДИУСА КОЛЕСА (ПОГРУЖЕНИЕ В ГРУНТ)
-- ---------------------------------------------------------
FieldGroundMudPhysics.radiusSinkEnable   = true  -- включить уменьшение радиуса колеса
FieldGroundMudPhysics.radiusMinFactor    = 0.57  -- минимальный радиус (доля от оригинального)
FieldGroundMudPhysics.radiusSinkInSpeed  = 0.10 -- скорость погружения колеса
FieldGroundMudPhysics.radiusSinkOutSpeed = 0.228  -- скорость восстановления радиуса

-- ---------------------------------------------------------
-- УПРАВЛЕНИЕ СЦЕПЛЕНИЕМ (profile.slip)
-- ---------------------------------------------------------
FieldGroundMudPhysics.slipMinMul = 0.58  -- минимальный множитель сцепления (больше буксует)
FieldGroundMudPhysics.slipMaxMul = 0.98  -- максимальный множитель сцепления

-- ---------------------------------------------------------
-- "ЖИВАЯ ЗЕМЛЯ" (СЛУЧАЙНЫЕ МИКРО-ПОДЪЁМЫ КОЛЕСА В ГРЯЗИ)
-- ---------------------------------------------------------
FieldGroundMudPhysics.livingSoilEnable        = true   -- включить эффект "дышащей" земли
FieldGroundMudPhysics.livingSoilSpeedMinKph   = 2.5    -- минимальная скорость, ниже не срабатывает
FieldGroundMudPhysics.livingSoilSlipMax       = 0.06   -- при сильной буксовке эффект отключён
FieldGroundMudPhysics.livingSoilDeepFrac      = 0.80   -- считаем колесо глубоко утонувшим (>80%)
FieldGroundMudPhysics.livingSoilPulseMinFrac  = 0.004  -- минимальный импульс (доля радиуса)
FieldGroundMudPhysics.livingSoilPulseMaxFrac  = 0.015  -- максимальный импульс
FieldGroundMudPhysics.livingSoilIntervalMin   = 0.8    -- минимум секунд между импульсами
FieldGroundMudPhysics.livingSoilIntervalMax   = 2.2    -- максимум секунд между импульсами
FieldGroundMudPhysics.livingSoilChance        = 0.55   -- шанс срабатывания импульса (0..1)

-- ---------------------------------------------------------
-- ПРЕДОТВРАЩЕНИЕ "ВЫТАЛКИВАНИЯ" КОЛЕС ИЗ ГРЯЗИ
-- ---------------------------------------------------------
FieldGroundMudPhysics.radiusRecoverInMudSpeed     = 0.02 -- восстановление радиуса, пока всё ещё в грязи
FieldGroundMudPhysics.radiusRecoverOutOfMudSpeed  = 0.95 -- восстановление радиуса после выезда на твёрдое

-- ---------------------------------------------------------
-- АНТИ-ДРОЖАНИЕ ПРИ ОСТАНОВКЕ
-- ---------------------------------------------------------
FieldGroundMudPhysics.freezeRadiusWhenStopped = true   -- замораживать радиус при остановке
FieldGroundMudPhysics.freezeStopSpeedKph      = 0.60   -- скорость, ниже которой считаем остановкой
FieldGroundMudPhysics.freezeStopSlip          = 0.010  -- допустимая пробуксовка при остановке
FieldGroundMudPhysics.freezeSettleSeconds     = 0.45   -- время до полной фиксации радиуса
FieldGroundMudPhysics.freezeRadiusEps         = 0.0015 -- минимальный шаг изменения радиуса

-- Максимальное восстановление радиуса на месте (в метрах)
FieldGroundMudPhysics.stopRecoverRadiusMaxM = 0.003

-- ---------------------------------------------------------
-- ОСЕДАНИЕ НА МЕСТЕ (НО БЕЗ БЕСКОНЕЧНОГО УТОПАНИЯ)
-- ---------------------------------------------------------
FieldGroundMudPhysics.stopSettleEnable        = true   -- разрешить оседание на месте
FieldGroundMudPhysics.stopSettleMaxExtraFrac  = 0.035  -- максимум оседания (доля радиуса)
FieldGroundMudPhysics.stopSettleSlipAllow     = 0.080  -- если slip выше — считаем что техника "едет"
FieldGroundMudPhysics.stopSettleSpeedKph      = 1.80   -- скорость, ниже которой считаем стоянкой
FieldGroundMudPhysics.stopSettleInSpeed       = 0.22   -- скорость оседания на месте

-- ---------------------------------------------------------
-- РЕДКОЕ "НАМЕРТВО ЗАСТРЯТЬ" (ГЛУБОКАЯ ГРЯЗЬ)
-- ---------------------------------------------------------
FieldGroundMudPhysics.permaStuckEnable           = true   -- включить перманентное застревание
FieldGroundMudPhysics.permaStuckChanceOnStruggle = 0.020  -- шанс в секунду при борьбе
FieldGroundMudPhysics.permaStuckSlipMin          = 0.12   -- минимальный slip для активации
FieldGroundMudPhysics.permaStuckSinkMin          = 0.38   -- минимальная глубина зарывания
FieldGroundMudPhysics.permaStuckRadiusFactor     = 0.40   -- минимальный радиус колеса
FieldGroundMudPhysics.permaStuckCooldownMin      = 45.0   -- минимальный кулдаун (сек)
FieldGroundMudPhysics.permaStuckCooldownMax      = 120.0  -- максимальный кулдаун
FieldGroundMudPhysics.permaStuckRampInSec        = 3.5    -- время "затягивания"
FieldGroundMudPhysics.permaStuckRampOutSec       = 2.40   -- время выхода
FieldGroundMudPhysics.permaStuckReleaseDistanceStartM = 6.00 -- если технику протащили дальше этого расстояния, начинаем отпускать perma-stuck
FieldGroundMudPhysics.permaStuckReleaseDistanceFullM  = 8.00 -- после такого смещения perma-stuck полностью отпускает технику обратно в обычную логику

-- ---------------------------------------------------------
-- РАСПРОСТРАНЕНИЕ ЗАСТРЕВАНИЯ НА ВСЮ МАШИНУ
-- ---------------------------------------------------------
FieldGroundMudPhysics.permaStuckSpreadEnable    = true  -- включить распространение
FieldGroundMudPhysics.permaStuckSpreadInSec     = 3.1   -- за сколько секунд тянем остальные колёса
FieldGroundMudPhysics.permaStuckSpreadOnlyInMud = true  -- только если колесо в грязном профиле
FieldGroundMudPhysics.permaStuckBrakeBoost      = 0.58  -- доп. сопротивление колёсам (+55%)
FieldGroundMudPhysics.permaStuckMinWheelsInMud  = 2     -- минимум колёс в грязи для активации

-- ---------------------------------------------------------
-- ДОПОЛНИТЕЛЬНЫЕ ЧАСТИЦЫ ГРЯЗИ
-- ---------------------------------------------------------
FieldGroundMudPhysics.extraParticlesEnable      = true   -- включить дополнительные частицы
FieldGroundMudPhysics.extraParticlesPreset      = 4
FieldGroundMudPhysics.extraParticlesI3D         = "particleSystems/mudField.i3d"      -- файл частиц
FieldGroundMudPhysics.extraEmitterShapeI3D      = "particleSystems/mudEmitShape.i3d"  -- форма эмиттера
FieldGroundMudPhysics.extraParticleClipDist     = 90     -- дистанция отрисовки (не работает)
FieldGroundMudPhysics.extraParticleMaxCount     = 900    -- максимум частиц (не работает)
FieldGroundMudPhysics.extraParticleOffsetY      = -0.22  -- вертикальный сдвиг
FieldGroundMudPhysics.extraParticleOffsetMoveMax     = 0.24  -- базовое смещение эмиттера по ходу/против хода
FieldGroundMudPhysics.extraParticleOffsetMoveDeadzoneKph = 0.35
FieldGroundMudPhysics.extraParticleOffsetMoveKphFull = 12.0
-- Подъём эмиттера доп. частиц при уменьшении радиуса (чтобы не "тонул" слишком глубоко)
FieldGroundMudPhysics.extraParticleLiftFromRadiusMax = 0.10  -- м: максимум подъёма при полном sinkFrac=1 (совет: 0.06..0.12)
FieldGroundMudPhysics.extraParticleLiftPow           = 1.35  -- кривая (1=линейно, >1 более "в конце")

FieldGroundMudPhysics.extraParticleMinSpeedKph  = 1.0   -- минимальная скорость для эффекта
FieldGroundMudPhysics.extraParticleBurstEnable        = true
FieldGroundMudPhysics.extraParticleBurstSlipMin       = 0.15
FieldGroundMudPhysics.extraParticleBurstSlipFull      = 0.65
FieldGroundMudPhysics.extraParticleBurstChancePerSec  = 0.35
FieldGroundMudPhysics.extraParticleBurstSpeedFullKph  = 20.0
FieldGroundMudPhysics.extraParticleBurstChanceSlipMul = 2.10
FieldGroundMudPhysics.extraParticleBurstChanceSpeedMul= 0.75
FieldGroundMudPhysics.extraParticleBurstChanceStillMul= 2.40
FieldGroundMudPhysics.extraParticleBurstYOffset       = 0.07
FieldGroundMudPhysics.extraParticleBurstForwardMul    = 1.05
FieldGroundMudPhysics.extraParticleBurstForwardSlipMul= 1.00
FieldGroundMudPhysics.extraParticleBurstUpMul         = 0.20
FieldGroundMudPhysics.extraParticleBurstUpSlipMul     = 0.10
FieldGroundMudPhysics.extraParticleBurstTimeMin       = 0.10
FieldGroundMudPhysics.extraParticleBurstTimeMax       = 0.22
FieldGroundMudPhysics.extraParticleBurstSpeedMul      = 1.20
FieldGroundMudPhysics.extraParticleBurstEmitMul       = 1.10

-- ---------------------------------------------------------
-- КОЛЕИ (ДЕФОРМАЦИЯ ТЕРРЕЙНА)
-- ---------------------------------------------------------
FieldGroundMudPhysics.rutsEnable        = true  -- включить колеи
FieldGroundMudPhysics.rutsIntervalMs    = 180   -- интервал между "копанием" одним колесом
FieldGroundMudPhysics.rutsMaxDepthM     = 0.21  -- максимальная глубина колеи (м)
FieldGroundMudPhysics.rutsRadiusMul     = 0.71  -- радиус кисти от ширины колеса
FieldGroundMudPhysics.rutsHardness      = 0.52  -- мягкость кисти (0..1)
FieldGroundMudPhysics.rutsMinSinkFrac   = 0.16  -- минимальное утопание для рисования колеи
FieldGroundMudPhysics.rutsSlipBonusMul  = 0.79  -- усиление от пробуксовки
FieldGroundMudPhysics.rutsSpeedMinKph   = 0.8   -- если почти стоим — не копаем

-- ---------------------------------------------------------
-- НАЛИПАНИЕ ГРЯЗИ НА ТЕХНИКУ (WASHABLE)
-- ---------------------------------------------------------
FieldGroundMudPhysics.dirtEnable        = true  -- включить загрязнение
FieldGroundMudPhysics.dirtMinEffMud     = 0.05  -- минимальная эффективность грязи
FieldGroundMudPhysics.dirtWetnessMin    = 0.10  -- минимальная влажность для налипания
FieldGroundMudPhysics.dirtBodyPerSec    = 0.010 -- загрязнение кузова в сек
FieldGroundMudPhysics.dirtWheelPerSec   = 0.055 -- загрязнение колёс в сек
FieldGroundMudPhysics.dirtSpeedBoostKph = 16.0  -- ускорение загрязнения от скорости
FieldGroundMudPhysics.dirtMax           = 1.00  -- максимальная грязь

-- ---------------------------------------------------------
-- ОБЩЕЕ ЗАРЫВАНИЕ КОЛЁС (АНТИ-"ПЛАВАЮЩИЕ" КОЛЁСА)
-- ---------------------------------------------------------
FieldGroundMudPhysics.sharedSinkEnable           = true  -- включить общее зарывание
FieldGroundMudPhysics.sharedSinkMinFrac          = 0.90  -- доля от максимального зарывания
FieldGroundMudPhysics.sharedSinkNoContactBoost   = 1.00  -- усиление, если колесо без контакта

-- ---------------------------------------------------------
-- УДАЛЕНИЕ УРОЖАЯ (fruitType) В НОЛЬ ПОД КОЛЕСОМ В ГРЯЗИ
-- ---------------------------------------------------------
FieldGroundMudPhysics.eraseFruitEnable      = false
FieldGroundMudPhysics.eraseFoliageOnlyFields = true -- true = стираем foliage только на полях (groundType), false = везде
FieldGroundMudPhysics.eraseFruitMinEffMud   = 0.18   -- минимум effMud, чтобы удалять
FieldGroundMudPhysics.eraseFruitIntervalMs  = 220    -- троттлинг на wheelPhysics
FieldGroundMudPhysics.eraseFruitSpeedMinKph = 1.0
FieldGroundMudPhysics.eraseFruitDoVanillaWheelDestructionFirst = true -- сначала ваниль crushed, потом 0

-- ---------------------------------------------------------
-- ЗИМНЯЯ ЗАМОРОЗКА (отключает грязь/влажность на полях и частицы)
-- ---------------------------------------------------------
FieldGroundMudPhysics.freezeAllLayersInWinterEnable = true   -- переключатель (в меню настроек)
FieldGroundMudPhysics.freezeAllLayersTempC          = -2.0   -- порог температуры (°C). Ниже -> "грязь замёрзла"

-- =========================================================
-- бонусы ширины колеса
-- =========================================================
FieldGroundMudPhysics.widthBonusEnable   = true
FieldGroundMudPhysics.widthBonusRefWidth = 0.65
FieldGroundMudPhysics.widthBonusStrength = 1.85
FieldGroundMudPhysics.widthBonusMax      = 0.50

-- =========================================================
-- FIELD GROUND PROFILES
--
-- name              : имя типа земли (для дебага/логов)
-- mud (0..1)        : базовая вязкость земли (чем больше — тем грязнее)
-- wetMul            : насколько дождь/влажность усиливает грязь
-- sinkMul           : множитель скорости и глубины зарывания техники
-- brakeMul          : усиление вязкого сопротивления качению колёс
-- motorMul          : дополнительная нагрузка на двигатель (потеря тяги)
-- radiusMinFactor   : минимальный радиус колеса (доля от оригинального),
--                     чем меньше — тем глубже колесо может утонуть
-- fxExtra           : включает дополнительные грязевые частицы (визуал)
-- permaStuck        : разрешает редкое "намертво застрять" на этом типе земли
--
-- slip (0.60..1.15) : множитель сцепления колеса с грунтом (через tire friction)
--                     меньше 1.0 = больше пробуксовка, больше 1.0 = меньше пробуксовка

-- =========================================================

FieldGroundMudPhysics.groundProfiles = {
    [1]  = { name="StubbleTillage", mud=0.62, wetMul=1.10, sinkMul=7.05, brakeMul=6.05, motorMul=1.05, radiusMinFactor=0.62, dirtMul=1.08, slip=0.85, fxExtra=true, permaStuck=true },
    [2]  = { name="Cultivated",     mud=0.60, wetMul=1.28, sinkMul=7.10, brakeMul=6.12, motorMul=1.08, radiusMinFactor=0.64, dirtMul=1.28, slip=0.83, fxExtra=true, permaStuck=true },
    [3]  = { name="Seedbed",        mud=0.60, wetMul=1.08, sinkMul=7.05, brakeMul=6.06, motorMul=1.05, radiusMinFactor=0.63, dirtMul=1.05, slip=0.87, fxExtra=true, permaStuck=true },
    [4]  = { name="Plowed",         mud=0.70, wetMul=1.55, sinkMul=9.55, brakeMul=8.35, motorMul=1.52, radiusMinFactor=0.60, dirtMul=1.60, slip=0.88, fxExtra=true, permaStuck=true },
    [5]  = { name="RolledSeedbed",  mud=0.66, wetMul=1.00, sinkMul=6.85, brakeMul=5.90, motorMul=0.92, radiusMinFactor=0.68, dirtMul=0.80, slip=0.96, fxExtra=true, permaStuck=true },
    [6]  = { name="Ridge",          mud=0.60, wetMul=1.20, sinkMul=7.12, brakeMul=6.02, motorMul=1.10, radiusMinFactor=0.60, dirtMul=1.15, slip=0.90, fxExtra=true, permaStuck=true },
    [7]  = { name="Sown",           mud=0.64, wetMul=1.00, sinkMul=6.80, brakeMul=5.85, motorMul=0.88, radiusMinFactor=0.60, dirtMul=0.72, slip=0.98, fxExtra=true, permaStuck=true },
    [8]  = { name="DirectSown",     mud=0.66, wetMul=1.00, sinkMul=6.85, brakeMul=5.88, motorMul=0.90, radiusMinFactor=0.69, dirtMul=0.78, slip=0.95, fxExtra=true, permaStuck=true },
    [9]  = { name="Planted",        mud=0.62, wetMul=1.05, sinkMul=6.95, brakeMul=5.95, motorMul=0.96, radiusMinFactor=0.66, dirtMul=0.92, slip=0.99, fxExtra=true, permaStuck=true },
    [10] = { name="RidgeSown",      mud=0.62, wetMul=1.10, sinkMul=7.05, brakeMul=6.05, motorMul=1.04, radiusMinFactor=0.63, dirtMul=1.06, slip=0.96, fxExtra=true, permaStuck=true },
    [11] = { name="Rollerlines",    mud=0.68, wetMul=1.00, sinkMul=6.65, brakeMul=5.70, motorMul=0.80, radiusMinFactor=0.66, dirtMul=0.55, slip=0.98, fxExtra=true, permaStuck=true },
    [12] = { name="HarvestReady",   mud=0.60, wetMul=1.02, sinkMul=6.92, brakeMul=5.92, motorMul=0.94, radiusMinFactor=0.67, dirtMul=0.90, slip=0.92, fxExtra=true, permaStuck=true },
    [13] = { name="HarvestReadyO",  mud=0.68, wetMul=1.00, sinkMul=6.90, brakeMul=5.90, motorMul=0.92, radiusMinFactor=0.68, dirtMul=0.86, slip=0.93, fxExtra=true, permaStuck=true },
	[14] = { name="Grass",  		mud=0.10, wetMul=0.88, sinkMul=6.90, brakeMul=5.90, motorMul=0.92, radiusMinFactor=0.88, dirtMul=0.86, slip=0.83, fxExtra=true, permaStuck=false },
	[15] = { name="GrassCut", 		mud=0.10, wetMul=0.88, sinkMul=6.90, brakeMul=5.90, motorMul=0.92, radiusMinFactor=0.88, dirtMul=0.86, slip=0.83, fxExtra=true, permaStuck=false },
}

-- =========================================================
-- UTILS
-- =========================================================
local computeWetnessEffective

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

local function smootherstep(edge0, edge1, x)
    if edge0 == edge1 then return (x >= edge1) and 1 or 0 end
    x = clamp((x - edge0) / (edge1 - edge0), 0, 1)
    return x*x*x*(x*(x*6 - 15) + 10)
end

local function expSmoothing(prev, target, dtS, smoothSec)
    smoothSec = math.max(0.001, smoothSec or 0.001)
    local a = 1 - math.exp(-dtS / smoothSec)
    return prev + (target - prev) * a
end

local function wetnessStrength(wetnessEff)
    local w = clamp(wetnessEff or 0, 0, 1)

    local dz  = FieldGroundMudPhysics.wetnessDeadZone or 0.20
    local minS = FieldGroundMudPhysics.wetnessMinStrength or 0.0
    local powK = FieldGroundMudPhysics.wetnessCurvePow or 1.55

    if w <= dz then
        return clamp(minS, 0, 0.1)
    end

    local t = smootherstep(dz, 1.0, w)

    t = t ^ math.max(0.35, powK)

    return clamp(minS + (1.0 - minS) * t, 0, 1)
end


local function mpsToKph(v) return (v or 0) * 3.6 end
local function kphToMps(v) return (v or 0) / 3.6 end

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

local fgDirtColorLastByNode = {}

local function fgSetDirtColorParts(parts, color)
    if parts == nil or color == nil then
        return
    end

    local eps = 0.0025
    for i = #parts, 1, -1 do
        local part = parts[i]
        if part ~= nil and entityExists(part) and getHasShaderParameter(part, "dirtColor") then
            local last = fgDirtColorLastByNode[part]
            if last == nil
                or math.abs((last.r or 0) - color.r) > eps
                or math.abs((last.g or 0) - color.g) > eps
                or math.abs((last.b or 0) - color.b) > eps then

                local a = last ~= nil and last.a or nil
                if a == nil then
                    local _, _, _, shaderA = getShaderParameter(part, "dirtColor")
                    a = shaderA or 0
                end

                setShaderParameter(part, "dirtColor", color.r, color.g, color.b, a, false)
                fgDirtColorLastByNode[part] = { r = color.r, g = color.g, b = color.b, a = a }
            end
        else
            fgDirtColorLastByNode[part] = nil
            table.remove(parts, i)
        end
    end
end

local function fgIsValidShaderNode(node, shaderName)
    if type(node) ~= "number" or node == 0 then
        return false
    end

    if not entityExists(node) then
        return false
    end

    if not getHasClassId(node, ClassIds.SHAPE) then
        return false
    end

    return getHasShaderParameter(node, shaderName or "dirtColor")
end

local function fgGetDryDirtColor()
    local color = { r = 0.18, g = 0.14, b = 0.10 }
    if g_currentMission ~= nil and g_currentMission.environment ~= nil and g_currentMission.environment.getDirtColors ~= nil then
        local defaultColor = select(1, g_currentMission.environment:getDirtColors())
        if defaultColor ~= nil then
            color.r = tonumber(defaultColor[1]) or color.r
            color.g = tonumber(defaultColor[2]) or color.g
            color.b = tonumber(defaultColor[3]) or color.b
        end
    end
    return color
end

local function fgGetMudTintColor()
    return {
        r = clamp(FieldGroundMudPhysics.dirtColorMudR or 0.059, 0, 1),
        g = clamp(FieldGroundMudPhysics.dirtColorMudG or 0.038, 0, 1),
        b = clamp(FieldGroundMudPhysics.dirtColorMudB or 0.021, 0, 1)
    }
end

local function fgGetLimeTintColor()
    return {
        r = clamp(FieldGroundMudPhysics.dirtColorLimeR or 0.82, 0, 1),
        g = clamp(FieldGroundMudPhysics.dirtColorLimeG or 0.90, 0, 1),
        b = clamp(FieldGroundMudPhysics.dirtColorLimeB or 0.82, 0, 1)
    }
end

local function fgGetFertilizerTintColor()
    return {
        r = clamp(FieldGroundMudPhysics.dirtColorFertilizerR or 0.36, 0, 1),
        g = clamp(FieldGroundMudPhysics.dirtColorFertilizerG or 0.31, 0, 1),
        b = clamp(FieldGroundMudPhysics.dirtColorFertilizerB or 0.20, 0, 1)
    }
end

local function fgGetManureTintColor()
    return {
        r = clamp(FieldGroundMudPhysics.dirtColorManureR or 0.20, 0, 1),
        g = clamp(FieldGroundMudPhysics.dirtColorManureG or 0.14, 0, 1),
        b = clamp(FieldGroundMudPhysics.dirtColorManureB or 0.08, 0, 1)
    }
end

local function fgGetLiquidManureTintColor()
    return {
        r = clamp(FieldGroundMudPhysics.dirtColorLiquidManureR or 0.16, 0, 1),
        g = clamp(FieldGroundMudPhysics.dirtColorLiquidManureG or 0.15, 0, 1),
        b = clamp(FieldGroundMudPhysics.dirtColorLiquidManureB or 0.09, 0, 1)
    }
end

local function fgGetSnowTintColor()
    return { r = 1.0, g = 1.0, b = 1.0 }
end

local function fgIsSpecialSurfaceKind(kind)
    return kind == "lime" or kind == "fertilizer" or kind == "manure" or kind == "liquidManure" or kind == "snow"
end

local function fgBlendDirtColor(dryColor, mudColor, blend)
    blend = clamp(blend or 0, 0, 1)
    return {
        r = fgColorLerp(dryColor.r, mudColor.r, blend),
        g = fgColorLerp(dryColor.g, mudColor.g, blend),
        b = fgColorLerp(dryColor.b, mudColor.b, blend)
    }
end

local function fgCopyColor(color)
    if color == nil then
        return { r = 0, g = 0, b = 0 }
    end
    return {
        r = tonumber(color.r) or 0,
        g = tonumber(color.g) or 0,
        b = tonumber(color.b) or 0
    }
end

local function fgLerpColor(currentColor, targetColor, alpha)
    currentColor = fgCopyColor(currentColor)
    targetColor = fgCopyColor(targetColor)
    alpha = clamp(alpha or 0, 0, 1)
    return {
        r = fgColorLerp(currentColor.r, targetColor.r, alpha),
        g = fgColorLerp(currentColor.g, targetColor.g, alpha),
        b = fgColorLerp(currentColor.b, targetColor.b, alpha)
    }
end

local function fgGetSignedForwardSpeedKph(rootNode)
    if rootNode == nil or rootNode == 0 then
        return 0
    end

    local vx, _, vz = getLinearVelocity(rootNode)
    local lx, _, lz = worldDirectionToLocal(rootNode, vx, 0, vz)
    return mpsToKph(-lz)
end

local function fgGetBurstDirectionLocal(rootNode, vehicle, wheelRef, magnitude)
    magnitude = math.max(0, magnitude or 0)
    if rootNode == nil or rootNode == 0 or magnitude <= 0 then
        return 0, 0
    end

    local vx, _, vz = getLinearVelocity(rootNode)
    local speedSq = vx * vx + vz * vz
    if speedSq > 0.0001 then
        local invLen = 1 / math.sqrt(speedSq)
        local lx, _, lz = worldDirectionToLocal(rootNode, -vx * invLen, 0, -vz * invLen)
        return lx * magnitude, lz * magnitude
    end

    local vehicleRoot = nil
    if vehicle ~= nil and vehicle.getRootNode ~= nil then
        vehicleRoot = vehicle:getRootNode()
    end
    if vehicleRoot == nil or vehicleRoot == 0 then
        vehicleRoot = rootNode
    end

    local dirSign = -1
    if wheelRef ~= nil and wheelRef.physics ~= nil then
        local dir = wheelRef.physics.torqueDirection
        if type(dir) == "number" and dir < 0 then
            dirSign = 1
        end
    end

    local wx, _, wz = localDirectionToWorld(vehicleRoot, 0, 0, dirSign)
    local lx, _, lz = worldDirectionToLocal(rootNode, wx, 0, wz)
    return lx * magnitude, lz * magnitude
end

local function fgGetWheelWidth(wp)
    if wp == nil then return nil end

    local width = wp.width
    if (type(width) ~= "number" or width <= 0) and wp.wheel ~= nil then
        width = wp.wheel.width
    end

    if type(width) == "number" and width > 0 then
        return width
    end

    return nil
end

local function fgGetWidthBonusFromWidth(width)
    if FieldGroundMudPhysics.widthBonusEnable == false then
        return 0
    end
    if type(width) ~= "number" or width <= 0 then
        return 0
    end

    local refWidth = math.max(0.10, FieldGroundMudPhysics.widthBonusRefWidth or 0.60)
    local strength = math.max(0, FieldGroundMudPhysics.widthBonusStrength or 0)
    local maxBonus = clamp(FieldGroundMudPhysics.widthBonusMax or 0, 0, 0.95)
    local over = math.max(0, (width - refWidth) / refWidth)

    return clamp(over * strength, 0, maxBonus)
end

local function fgGetWheelWidthBonus(wp)
    return fgGetWidthBonusFromWidth(fgGetWheelWidth(wp))
end

local function fgGetVehicleWidthBonus(vehicle)
    if FieldGroundMudPhysics.widthBonusEnable == false or vehicle == nil or vehicle.spec_wheels == nil then
        return 0
    end

    local wheels = vehicle.spec_wheels.wheels
    if wheels == nil then
        return 0
    end

    local total = 0
    local count = 0
    for _, wheel in ipairs(wheels) do
        if wheel ~= nil and wheel.physics ~= nil then
            local width = fgGetWheelWidth(wheel.physics)
            if type(width) == "number" and width > 0 then
                total = total + width
                count = count + 1
            end
        end
    end

    if count <= 0 then
        return 0
    end

    return fgGetWidthBonusFromWidth(total / count)
end

function FieldGroundMudPhysics:computeExtraParticleLiftY(wp, prof)
    if wp == nil then return 0 end

    local r0 = wp.__fgOrigRadius or wp.radiusOriginal or wp.radius
    if type(r0) ~= "number" or r0 ~= r0 or r0 <= 0.05 then
        return 0
    end

    local minFactor = self.radiusMinFactor or 0.58
    if prof ~= nil and type(prof.radiusMinFactor) == "number" then
        minFactor = prof.radiusMinFactor
    end
    minFactor = minFactor + (1 - minFactor) * fgGetWheelWidthBonus(wp)
    minFactor = clamp(minFactor, 0.12, 0.98)

    local minRadius = math.max(0.12, r0 * minFactor)
    local maxExtra  = math.max(0.001, r0 - minRadius)

    local curExtra = wp.__fgMudCurExtra or 0
    local frac = clamp(curExtra / maxExtra, 0, 1)

    local powK = self.extraParticleLiftPow or 1.0
    frac = frac ^ math.max(0.10, powK)

    local liftMax = self.extraParticleLiftFromRadiusMax or 0
    if type(liftMax) ~= "number" then liftMax = 0 end
    liftMax = clamp(liftMax, 0, 0.35)

    return liftMax * frac
end


local getState

local function fgEnsureWheelIndices()
    if g_currentMission == nil or g_currentMission.vehicles == nil then return end
    for _, v in pairs(g_currentMission.vehicles) do
        if v ~= nil and v.spec_wheels ~= nil and v.spec_wheels.wheels ~= nil then
            for i, w in ipairs(v.spec_wheels.wheels) do
                if w ~= nil then
                    w.wheelIndex = i
                end
            end
        end
    end
end

-- =========================================================

function FieldGroundMudPhysics:getWheelNoContactFlag(wp)
    if wp == nil then return false end
    if wp.hasGroundContact ~= nil then
        return wp.hasGroundContact == false
    end
    if wp.hasSoilContact ~= nil then
        return wp.hasSoilContact == false
    end
    return false
end

function FieldGroundMudPhysics:hasWheelFieldGroundContact(wheel)
    if wheel == nil or wheel.physics == nil then
        return false
    end

    local wp = wheel.physics
    if getWheelShapeContactPoint ~= nil
        and wheel.node ~= nil
        and wp.wheelShapeCreated == true
        and wp.wheelShape ~= nil
        and wp.wheelShape ~= 0 then
        local contactX = getWheelShapeContactPoint(wheel.node, wp.wheelShape)
        if contactX == nil then
            return false
        end
    end

    local groundContact = false

    if wp.hasGroundContact ~= nil then
        groundContact = (wp.hasGroundContact == true)
    elseif wp.netInfo ~= nil and wp.netInfo.hasGroundContact ~= nil then
        groundContact = (wp.netInfo.hasGroundContact == true)
    end

    if not groundContact then
        return false
    end

    local contact = wp.contact
    if contact == nil and wp.netInfo ~= nil then
        contact = wp.netInfo.contact
    end

    local objectContactType = (WheelContactType ~= nil and WheelContactType.OBJECT) or 2
    if contact == objectContactType then
        return false
    end

    local densityType = wp.densityType
    if densityType == nil and wp.netInfo ~= nil then
        densityType = wp.netInfo.densityType
    end

    if densityType ~= nil and FieldGroundType ~= nil and densityType == FieldGroundType.NONE then
        return false
    end

    return true
end

function FieldGroundMudPhysics:computeVehicleSharedTargetExtra(vehicle, wetnessEff)
    if vehicle == nil or vehicle.spec_wheels == nil or vehicle.spec_wheels.wheels == nil then
        return 0, 0
    end

    local wetK = wetnessStrength(wetnessEff)
    if wetK <= 0 then
        return 0, 0
    end

    local wheels = vehicle.spec_wheels.wheels
    local maxTarget, sumTarget, cnt = 0, 0, 0

    for _, w in ipairs(wheels) do
        if w ~= nil and w.physics ~= nil then
            local wp = w.physics

            local wx, _, wz = self:getWheelContactPos(w)
            if wx ~= nil then
                local mudBase, _, prof = self:getProfileAtWorldPos(wx, wz)
                if prof ~= nil and (mudBase or 0) > 0.01 then
                    local wetMul = prof.wetMul or 1.0

                    local effMudRaw = clamp((mudBase or 0) * wetMul, 0, 1)

                    local r0 = wp.__fgOrigRadius or wp.radiusOriginal or wp.radius
                    if type(r0) == "number" and r0 > 0.05 and r0 == r0 then
                        local widthBonus = fgGetWheelWidthBonus(wp)
                        local minFactor = prof.radiusMinFactor or self.radiusMinFactor or 0.52
                        minFactor = minFactor + (1 - minFactor) * widthBonus
                        local minRadius = math.max(0.12, r0 * clamp(minFactor, 0.12, 0.98))
                        local maxExtra  = math.max(0, r0 - minRadius)

                        local stV = getState(vehicle)
                        local sinkMul = prof.sinkMul or 1.0
                        local slip = (wp.netInfo ~= nil and wp.netInfo.slip) or 0

                        local vSpeed = (vehicle.getLastSpeed and vehicle:getLastSpeed()) or vehicle.lastSpeedReal or 0
                        local speedKph = mpsToKph(vSpeed)

                        local moving01   = clamp(speedKph / 6.0, 0, 1)
                        local slip01     = clamp((slip or 0) / 0.18, 0, 1)
                        local moveFactor = math.max(moving01, slip01)

                        local kRaw = effMudRaw * sinkMul * (0.10 + moveFactor * (0.85 + (stV.sink or 0) * 0.55)) * (1 - widthBonus)
                        local k = clamp(kRaw, 0, 1) * wetK

                        local targetExtra = maxExtra * k
                        targetExtra = clamp(targetExtra, 0, maxExtra)

                        maxTarget = math.max(maxTarget, targetExtra)
                        sumTarget = sumTarget + targetExtra
                        cnt = cnt + 1
                    end
                end
            end
        end
    end

    local avgTarget = (cnt > 0) and (sumTarget / cnt) or 0
    return maxTarget, avgTarget
end

function FieldGroundMudPhysics:getSharedTargetExtraCached(vehicle, wetnessEff)
    local stV = getState(vehicle)
    local t = g_time or 0

    if stV.__fgSharedTick ~= t then
        stV.__fgSharedTick = t
        local mx, av = self:computeVehicleSharedTargetExtra(vehicle, wetnessEff)
        stV.__fgSharedTargetMax = mx or 0
        stV.__fgSharedTargetAvg = av or 0
    end

    return stV.__fgSharedTargetMax or 0, stV.__fgSharedTargetAvg or 0
end

local function mpSafeDelete(node)
    if node ~= nil and node ~= 0 and entityExists(node) then
        delete(node)
    end
end

local function getWeather()
    if g_currentMission ~= nil and g_currentMission.environment ~= nil and g_currentMission.environment.weather ~= nil then
        return g_currentMission.environment.weather
    end
    return nil
end

local function getAirTempC()
    if g_currentMission ~= nil and g_currentMission.environment ~= nil then
        local env = g_currentMission.environment

        -- FS25: основной корректный метод
        if env.weather ~= nil and env.weather.getCurrentTemperature ~= nil then
            local t = env.weather:getCurrentTemperature()
            if t ~= nil then
                return t
            end
        end

        -- fallback: temperatureUpdater
        if env.temperatureUpdater ~= nil and env.temperatureUpdater.getTemperatureAtTime ~= nil then
            local dayTime = env.dayTime or (env.weather ~= nil and env.weather.owner ~= nil and env.weather.owner.dayTime) or 0
            return env.temperatureUpdater:getTemperatureAtTime(dayTime)
        end
    end

    return nil
end

function FieldGroundMudPhysics:isHardWinterFrozen()
    if self.freezeAllLayersInWinterEnable ~= true then
        return false
    end

    local t = getAirTempC()
    if t == nil then
        return false
    end

    return t < (self.freezeAllLayersTempC or -1.0)
end

computeWetnessEffective = function(groundWetness, dt)
    local dtS = ((dt or 0) * 0.001)
    if dtS <= 0 then dtS = 0.016 end
	
	-- HARD WINTER FREEZE: зимой при морозе считаем влажность = 0 (грязь "замёрзла")
    if FieldGroundMudPhysics.isHardWinterFrozen ~= nil and FieldGroundMudPhysics:isHardWinterFrozen() then
        FieldGroundMudPhysics._wetSmooth = 0
        FieldGroundMudPhysics._rainSmooth = 0
        FieldGroundMudPhysics._surfaceHoldT = 0
        return 0
    end

    local wetRaw = clamp(groundWetness or 0, 0, 1)
    local rainRaw = 0

    local weather = getWeather()
    if weather ~= nil then
        if weather.getGroundWetness ~= nil then
            wetRaw = math.max(wetRaw, clamp(weather:getGroundWetness() or 0, 0, 1))
        end
        if weather.getRainFallScale ~= nil then
            rainRaw = clamp(weather:getRainFallScale() or 0, 0, 1)
        end
    end

    local rTh = FieldGroundMudPhysics.rainScaleThreshold or 0.04
    local rain01 = smootherstep(rTh, 1.0, rainRaw)

    FieldGroundMudPhysics._wetSmooth = FieldGroundMudPhysics._wetSmooth or wetRaw
    FieldGroundMudPhysics._rainSmooth = FieldGroundMudPhysics._rainSmooth or rain01

    FieldGroundMudPhysics._wetSmooth  = expSmoothing(FieldGroundMudPhysics._wetSmooth, wetRaw, dtS, FieldGroundMudPhysics.wetnessSmoothSec or 14.0)
    FieldGroundMudPhysics._rainSmooth = expSmoothing(FieldGroundMudPhysics._rainSmooth, rain01, dtS, FieldGroundMudPhysics.rainSmoothSec or 3.0)

    local wetSm  = clamp(FieldGroundMudPhysics._wetSmooth, 0, 1)
    local rainSm = clamp(FieldGroundMudPhysics._rainSmooth, 0, 1)

    FieldGroundMudPhysics._surfaceHoldT = FieldGroundMudPhysics._surfaceHoldT or 0
    if rainSm > 0.02 then
        FieldGroundMudPhysics._surfaceHoldT = FieldGroundMudPhysics.surfaceHoldSec or 22.0
    else
        FieldGroundMudPhysics._surfaceHoldT = math.max(0, (FieldGroundMudPhysics._surfaceHoldT or 0) - dtS)
    end

    local holdK = 0
    if (FieldGroundMudPhysics._surfaceHoldT or 0) > 0 then
        holdK = clamp((FieldGroundMudPhysics._surfaceHoldT or 0) / math.max(0.01, (FieldGroundMudPhysics.surfaceHoldSec or 22.0)), 0, 1)
    end

    local boostMax = FieldGroundMudPhysics.rainSurfaceBoostMax or 0.28
    local surfaceK = math.max(rainSm, holdK * 0.65)
    local wetBoosted = clamp(wetSm + boostMax * surfaceK, 0, 1)

    FieldGroundMudPhysics._lastRain01 = surfaceK

    if surfaceK > 0.01 then
        local minWet = FieldGroundMudPhysics.rainForcesWetnessMin or 0.55
        wetBoosted = math.max(wetBoosted, minWet * (surfaceK ^ 0.85))
    end

    return wetBoosted
end


function FieldGroundMudPhysics:isInShopPreview(vehicle)
    if vehicle == nil then return false end

    if vehicle.getIsInShowroom ~= nil then
        return vehicle:getIsInShowroom()
    end

    local ps = nil
    if vehicle.getPropertyState ~= nil then
        ps = vehicle:getPropertyState()
    else
        ps = vehicle.propertyState
    end

    return ps ~= nil and VehiclePropertyState ~= nil and ps == VehiclePropertyState.SHOP_CONFIG
end

function FieldGroundMudPhysics:getWheelContactPos(wheel)
    if wheel == nil then return nil end

    local node =
        wheel.driveNode or
        wheel.wheelNode or
        wheel.node or
        wheel.repr

    if node == nil or node == 0 then
        return nil
    end

    local x, y, z = getWorldTranslation(node)

    local r = 0.45
    if wheel.physics ~= nil then
        r = wheel.physics.radiusOriginal or wheel.physics.radius or r
    end
    r = wheel.radiusOriginal or wheel.radius or r

    return x, y - r, z
end

function FieldGroundMudPhysics:getWheelDestructionParallelogram(wheel)
    if wheel == nil then return nil end

    local repr = wheel.repr
    if repr == nil or repr == 0 then return nil end

    local driveNode = wheel.driveNode or wheel.node or wheel.wheelNode or repr
    if driveNode == nil or driveNode == 0 then return nil end

    local width = wheel.width or 0.5
    if wheel.physics ~= nil and type(wheel.physics.width) == "number" then
        width = wheel.physics.width
    end

    local halfW = math.max(0.10, (width * 0.5))

    local halfL = math.max(0.10, math.min(0.45, halfW))

    local lx, ly, lz = localToLocal(driveNode, repr, 0, 0, 0)

    local x0, _, z0 = localToWorld(repr, lx + halfW, ly, lz - halfL)
    local x1, _, z1 = localToWorld(repr, lx - halfW, ly, lz - halfL)
    local x2, _, z2 = localToWorld(repr, lx + halfW, ly, lz + halfL)

    return x0, z0, x1, z1, x2, z2
end

function FieldGroundMudPhysics:eraseFruitToZero(x0, z0, x1, z1, x2, z2)
    if g_server == nil then return end
    if g_currentMission == nil then return end
    if g_fruitTypeManager == nil or g_fruitTypeManager.getFruitTypes == nil then return end
    if g_terrainNode == nil or g_terrainNode == 0 then return end

    self._eraseFruitMM = self._eraseFruitMM or DensityMapMultiModifier.new()
    self._eraseFruitCache = self._eraseFruitCache or { mods = {}, filts = {}, fieldFilter = nil }

    if self._eraseFruitCache.fieldFilter == nil then
        if g_fieldGroundSystem ~= nil and g_fieldGroundSystem.getFieldFilter ~= nil then
            self._eraseFruitCache.fieldFilter = g_fieldGroundSystem:getFieldFilter()
        end
    end

    local mm = self._eraseFruitMM
    local cache = self._eraseFruitCache
    mm:reset()

    local fieldFilter = cache.fieldFilter

    local function keyOf(plane, start, chans)
        return tostring(plane) .. ":" .. tostring(start) .. ":" .. tostring(chans)
    end

    local function getOrCreate(plane, start, chans)
        if plane == nil or plane == 0 then return nil, nil end
        if type(start) ~= "number" or type(chans) ~= "number" or chans <= 0 then return nil, nil end

        local k = keyOf(plane, start, chans)
        local m = cache.mods[k]
        local f = cache.filts[k]

        if m == nil then
            m = DensityMapModifier.new(plane, start, chans, g_terrainNode)
            cache.mods[k] = m
        end
        if f == nil then
            f = DensityMapFilter.new(plane, start, chans)
            cache.filts[k] = f
        end
        return m, f
    end

    for _, ft in ipairs(g_fruitTypeManager:getFruitTypes()) do
        local m, f = getOrCreate(ft.terrainDataPlaneId, ft.startStateChannel, ft.numStateChannels)
        if m ~= nil and f ~= nil then
            m:setParallelogramWorldCoords(x0, z0, x1, z1, x2, z2, DensityCoordType.POINT_POINT_POINT)
            f:setValueCompareParams(DensityValueCompareType.GREATER, 0)

            if fieldFilter ~= nil then
                mm:addExecuteSet(0, m, f, fieldFilter)
            else
                mm:addExecuteSet(0, m, f)
            end
        end
    end

    mm:execute()
end

-- =========================================================
-- ERASE FOLIAGE CACHE
-- =========================================================
function FieldGroundMudPhysics:initEraseFoliageCache()
    if g_server == nil then return end
    if g_currentMission == nil then return end
    if g_fruitTypeManager == nil or g_fruitTypeManager.getFruitTypes == nil then return end
    if g_terrainNode == nil or g_terrainNode == 0 then return end

    self._eraseFoliageMM = self._eraseFoliageMM or DensityMapMultiModifier.new()

    self._eraseFoliageCache = self._eraseFoliageCache or {
        fruitMods   = {},
        fruitFilts  = {},
        haulmMods   = {},
        haulmFilts  = {},
        dynMods     = {},
        fieldFilter = nil
    }

    if self._eraseFoliageCache.fieldFilter == nil then
        if g_fieldGroundSystem ~= nil and g_fieldGroundSystem.getFieldFilter ~= nil then
            self._eraseFoliageCache.fieldFilter = g_fieldGroundSystem:getFieldFilter()
        elseif g_currentMission.fieldGroundSystem ~= nil and g_currentMission.fieldGroundSystem.getFieldFilter ~= nil then
            self._eraseFoliageCache.fieldFilter = g_currentMission.fieldGroundSystem:getFieldFilter()
        end
    end
end

-- =========================================================
-- ERASE FOLIAGE TO ZERO
-- =========================================================

function FieldGroundMudPhysics:isWorldPosOnFieldByGroundType(worldX, worldZ)
    if g_currentMission == nil or g_currentMission.fieldGroundSystem == nil then
        return true
    end
    if FieldDensityMap == nil or FieldDensityMap.GROUND_TYPE == nil then
        return true
    end

    local value = g_currentMission.fieldGroundSystem:getValueAtWorldPos(FieldDensityMap.GROUND_TYPE, worldX, 0, worldZ)
    if value == nil then
        return true
    end

    local groundType = g_currentMission.fieldGroundSystem:getFieldGroundTypeByValue(value)
    return groundType ~= nil and groundType ~= FieldGroundType.NONE
end

function FieldGroundMudPhysics:_canUseUpdateMulcherArea()
    if self._mulcherAreaSupport ~= nil then
        return self._mulcherAreaSupport
    end

    local mission = g_currentMission
    if mission == nil then
        self._mulcherAreaSupport = false
        return false
    end

    local fgs = mission.fieldGroundSystem
    if fgs == nil or fgs.getDensityMapData == nil then
        self._mulcherAreaSupport = false
        return false
    end

    -- GROUND_TYPE is mandatory inside updateMulcherArea (vanilla doesn't nil-check it)
    local groundMapId = nil
    if FieldDensityMap ~= nil and FieldDensityMap.GROUND_TYPE ~= nil then
        groundMapId = select(1, fgs:getDensityMapData(FieldDensityMap.GROUND_TYPE))
    end
    if groundMapId == nil or groundMapId == 0 then
        self._mulcherAreaSupport = false
        return false
    end

    -- STUBBLE_SHRED_LEVEL is used when Platform.gameplay.useStubbleShred is enabled
    if Platform ~= nil and Platform.gameplay ~= nil and Platform.gameplay.useStubbleShred then
        local stubbleMapId = nil
        if FieldDensityMap ~= nil and FieldDensityMap.STUBBLE_SHRED_LEVEL ~= nil then
            stubbleMapId = select(1, fgs:getDensityMapData(FieldDensityMap.STUBBLE_SHRED_LEVEL))
        end
        if stubbleMapId == nil or stubbleMapId == 0 then
            self._mulcherAreaSupport = false
            return false
        end
    end

    -- Weed system may exist even if map has no weed density -> vanilla crashes
    local weedSystem = mission.weedSystem
    if weedSystem ~= nil and weedSystem.getDensityMapData ~= nil then
        local weedMapId = select(1, weedSystem:getDensityMapData())
        if weedMapId == nil or weedMapId == 0 then
            self._mulcherAreaSupport = false
            return false
        end
    end

    self._mulcherAreaSupport = true
    return true
end

function FieldGroundMudPhysics:eraseFoliageToZero(x0, z0, x1, z1, x2, z2)
    if g_server == nil then return end
    if g_currentMission == nil then return end
    if g_fruitTypeManager == nil or g_fruitTypeManager.getFruitTypes == nil then return end
    if g_terrainNode == nil or g_terrainNode == 0 then return end
	
    if self.eraseFoliageOnlyFields then
        local cx = (x0 + x1 + x2) / 3
        local cz = (z0 + z1 + z2) / 3
        if not self:isWorldPosOnFieldByGroundType(cx, cz) then
            return
        end
    end

    self:initEraseFoliageCache()

    local mm = self._eraseFoliageMM
    local cache = self._eraseFoliageCache
    if mm == nil or cache == nil then return end

    mm:reset()

    local fieldFilter = cache.fieldFilter

    local function keyOf(plane, start, chans)
        return tostring(plane) .. ":" .. tostring(start) .. ":" .. tostring(chans)
    end

    local function getOrCreate(modTable, filtTable, plane, start, chans)
        if plane == nil or plane == 0 then return nil, nil end
        if type(start) ~= "number" or type(chans) ~= "number" or chans <= 0 then return nil, nil end

        local k = keyOf(plane, start, chans)

        local m = modTable[k]
        local f = (filtTable ~= nil) and filtTable[k] or nil

        if m == nil then
            m = DensityMapModifier.new(plane, start, chans, g_terrainNode)
            modTable[k] = m
        end
        if filtTable ~= nil and f == nil then
            f = DensityMapFilter.new(plane, start, chans)
            filtTable[k] = f
        end

        return m, f
    end

	local function addZeroSet(modifier, filter)
		if modifier == nil then return end

		modifier:setParallelogramWorldCoords(x0, z0, x1, z1, x2, z2, DensityCoordType.POINT_POINT_POINT)

		if filter ~= nil then
			filter:setValueCompareParams(DensityValueCompareType.GREATER, 0)
			mm:addExecuteSet(0, modifier, filter)
		else
			mm:addExecuteSet(0, modifier)
		end
	end

    local gs = g_currentMission.growthSystem
    if gs ~= nil and gs.setIgnoreDensityChanges ~= nil then
        gs:setIgnoreDensityChanges(true)
    end

    -- 1) FRUIT -> 0
    for _, ft in ipairs(g_fruitTypeManager:getFruitTypes()) do
        local m, f = getOrCreate(cache.fruitMods, cache.fruitFilts, ft.terrainDataPlaneId, ft.startStateChannel, ft.numStateChannels)
        addZeroSet(m, f)
    end

    -- 2) HAULM -> 0
    for _, ft in ipairs(g_fruitTypeManager:getFruitTypes()) do
        local m, f = getOrCreate(cache.haulmMods, cache.haulmFilts, ft.terrainDataPlaneIdHaulm, ft.startStateChannelHaulm, ft.numStateChannelsHaulm)
        addZeroSet(m, f)
    end

    -- 3) DYNAMIC FOLIAGE LAYERS -> 0 
    if g_currentMission.dynamicFoliageLayers ~= nil then
        for i = 1, #g_currentMission.dynamicFoliageLayers do
            local layerId = g_currentMission.dynamicFoliageLayers[i]
            if layerId ~= nil and layerId ~= 0 then
                local numCh = getTerrainDetailNumChannels(layerId)
                if type(numCh) == "number" and numCh > 0 then
                    local k = "dyn:" .. tostring(layerId) .. ":" .. tostring(numCh)
                    local m = cache.dynMods[k]
                    if m == nil then
                        m = DensityMapModifier.new(layerId, 0, numCh, g_terrainNode)
                        cache.dynMods[k] = m
                    else
                        m:resetDensityMapAndChannels(layerId, 0, numCh)
                    end

                    m:setParallelogramWorldCoords(x0, z0, x1, z1, x2, z2, DensityCoordType.POINT_POINT_POINT)
                    mm:addExecuteSet(0, m)
                end
            end
        end
    end

    mm:execute()

    if gs ~= nil and gs.setIgnoreDensityChanges ~= nil then
        gs:setIgnoreDensityChanges(false)
    end

    -- 4) Weed/Stones отдельными системами (как ваниль)
    if FSDensityMapUtil ~= nil then
        if FSDensityMapUtil.removeWeedArea ~= nil then
            FSDensityMapUtil.removeWeedArea(x0, z0, x1, z1, x2, z2)
        end
        if FSDensityMapUtil.removeStoneArea ~= nil then
            FSDensityMapUtil.removeStoneArea(x0, z0, x1, z1, x2, z2)
        end
    end

    -- 5) Доп. проход “mulcher”)
    if FSDensityMapUtil ~= nil and FSDensityMapUtil.updateMulcherArea ~= nil and self:_canUseUpdateMulcherArea() then
		-- Call only on maps that have required density maps.
		-- No pcall here: we prevent the call entirely when prerequisites are missing.
		FSDensityMapUtil.updateMulcherArea(x0, z0, x1, z1, x2, z2, true)
	end
	
	-- 6) УБИВАЕМ "пеньки / cut" (стерню) => ставим stubbleShred в MAX (БЕЗ fieldFilter)
	if FSDensityMapUtil ~= nil and FSDensityMapUtil.setStubbleShredLevelArea ~= nil then
		local fgs = g_currentMission.fieldGroundSystem
		if fgs ~= nil and fgs.getDensityMapData ~= nil then
			local _, _, numCh = fgs:getDensityMapData(FieldDensityMap.STUBBLE_SHRED_LEVEL)
			if type(numCh) == "number" and numCh > 0 then
				local maxVal = (2 ^ numCh) - 1
				FSDensityMapUtil.setStubbleShredLevelArea(x0, z0, x1, z1, x2, z2, maxVal)
			end
		end
	end
end

function FieldGroundMudPhysics:applyRutFromRadius(wp, wx, wz, r0, desiredRadius, minRadius, slip, speedKph)
    if not self.rutsEnable then return end
    if g_currentMission == nil or TerrainDeformation == nil then return end
    if g_currentMission.terrainRootNode == nil then return end

    if speedKph ~= nil and speedKph < (self.rutsSpeedMinKph or 0) then
        return
    end

    local denom = math.max(0.001, (r0 - minRadius))
    local sinkFrac = clamp((r0 - (desiredRadius or r0)) / denom, 0, 1)

    if sinkFrac < (self.rutsMinSinkFrac or 0.05) then
        return
    end

    local nowMs = g_time or 0
    wp.__fgRutLastMs = wp.__fgRutLastMs or 0
    if (nowMs - wp.__fgRutLastMs) < (self.rutsIntervalMs or 150) then
        return
    end
    wp.__fgRutLastMs = nowMs

    local slip01 = clamp((slip or 0) * 1.6, 0, 1) -- нормализуем slip примерно в 0..1
    local bonus  = 1.0 + (self.rutsSlipBonusMul or 0) * slip01

    local depth = (self.rutsMaxDepthM or 0.06) * sinkFrac * bonus
    if depth <= 0.001 then return end

    local w = 0.5
    if wp.wheel ~= nil then
        w = wp.wheel.width or w
    end
    local brushRadius = math.max(0.18, w * (self.rutsRadiusMul or 0.55))

    local deformer = TerrainDeformation.new(g_currentMission.terrainRootNode)
    if deformer == nil then return end

    deformer:enableAdditiveDeformationMode()
    deformer:setAdditiveHeightChangeAmount(-depth)

    local hardness = clamp(self.rutsHardness or 0.55, 0.05, 0.98)
    deformer:addSoftCircleBrush(wx, wz, brushRadius, hardness, 1.0, TerrainDeformation.NO_TERRAIN_BRUSH)

    local q = g_currentMission.terrainDeformationQueue
    if q ~= nil and q.queueJob ~= nil then
        local ok = pcall(function()
            q:queueJob(deformer, false, "onRutDeformDone", self, nil)
        end)

        if not ok then
            ok = pcall(function()
                q:queueJob(deformer, "onRutDeformDone", self, nil, false)
            end)
        end

        if not ok then
            deformer:apply(false, "onRutDeformDone", self, nil)
            deformer:delete()
        end
    else
        deformer:apply(false, "onRutDeformDone", self, nil)
        deformer:delete()
    end

end

function FieldGroundMudPhysics:onRutDeformDone(state, numDeformations, msg)
end

-- =========================================================
-- MP RADIUS SYNC (server -> clients)
-- =========================================================
FieldGroundMudPhysics.mpRadiusSyncIntervalMs = 80

function FieldGroundMudPhysics:collectWheelDesiredRadii(vehicle)
    if vehicle == nil or vehicle.spec_wheels == nil or vehicle.spec_wheels.wheels == nil then
        return nil
    end
    local wheels = vehicle.spec_wheels.wheels
    local radii = {}
    for i = 1, #wheels do
        local w = wheels[i]
        if w ~= nil and w.physics ~= nil then
            local wp = w.physics
            local r = wp.__fgDesiredRadius
            if type(r) == "number" and r == r and r > 0.01 then
                radii[i] = r
            else
                radii[i] = nil
            end
        end
    end
    return radii
end

function FieldGroundMudPhysics:syncWheelRadiiServer(vehicle)
    if g_server == nil then return end
    if self.mpRadiusSyncEnable ~= true then return end
    if vehicle == nil or vehicle.isServer ~= true then return end
    if self:isInShopPreview(vehicle) then return end
    if self:isVehicleResetting(vehicle) then return end

    local now = g_time or 0
    vehicle.__fgLastRadiusSyncMs = vehicle.__fgLastRadiusSyncMs or 0
    if (now - vehicle.__fgLastRadiusSyncMs) < (self.mpRadiusSyncIntervalMs or 80) then
        return
    end
    vehicle.__fgLastRadiusSyncMs = now

    local radii = self:collectWheelDesiredRadii(vehicle)
    if radii == nil then return end

    g_server:broadcastEvent(FieldGroundWheelRadiusSyncEvent.new(vehicle, radii), false, nil, vehicle)
end

-- =========================================================
-- FIELD GROUND TYPE QUERY
-- =========================================================
function FieldGroundMudPhysics:getGroundTypeValueAtWorldPos(x, z)
    if g_currentMission == nil or g_currentMission.fieldGroundSystem == nil then
        return nil
    end

    local fgs = g_currentMission.fieldGroundSystem
    if fgs.getValueAtWorldPos == nil then
        return nil
    end

    local levelType = (FieldDensityMap ~= nil and FieldDensityMap.GROUND_TYPE) or 1
    local v = fgs:getValueAtWorldPos(levelType, x, 0, z)
    return v
end

function FieldGroundMudPhysics:getSprayTypeValueAtWorldPos(x, z)
    if g_currentMission == nil or g_currentMission.fieldGroundSystem == nil then
        return nil
    end

    local fgs = g_currentMission.fieldGroundSystem
    if fgs.getValueAtWorldPos == nil then
        return nil
    end

    if FieldDensityMap == nil or FieldDensityMap.SPRAY_TYPE == nil then
        return nil
    end

    return fgs:getValueAtWorldPos(FieldDensityMap.SPRAY_TYPE, x, 0, z)
end

function FieldGroundMudPhysics:getFillTypeAtWorldPos(x, y, z, radius)
    if x == nil or z == nil or DensityMapHeightUtil == nil or DensityMapHeightUtil.getFillTypeAtLine == nil then
        return nil
    end

    local probeY = y
    if probeY == nil and g_currentMission ~= nil and g_currentMission.terrainRootNode ~= nil then
        probeY = getTerrainHeightAtWorldPos(g_currentMission.terrainRootNode, x, 0, z)
    end
    if probeY == nil then
        probeY = 0
    end

    local probeRadius = math.max(0.10, radius or 0.18)
    local startY = probeY + 0.35
    local endY = probeY - 0.45
    local fillType = DensityMapHeightUtil.getFillTypeAtLine(x, startY, z, x, endY, z, probeRadius)
    if fillType ~= nil and fillType ~= FillType.UNKNOWN then
        return fillType
    end

    return nil
end

function FieldGroundMudPhysics:getDirtColorSurfaceKindAtWorldPos(x, z, y, allowSnow)
    if allowSnow ~= false and FillType ~= nil and FillType.SNOW ~= nil then
        local fillType = self:getFillTypeAtWorldPos(x, y, z)
        if fillType == FillType.SNOW then
            return "snow"
        end
    end

    local sprayValue = self:getSprayTypeValueAtWorldPos(x, z)
    if sprayValue == nil then
        return nil
    end

    self._dirtColorSprayLookup = self._dirtColorSprayLookup or {}
    if self._dirtColorSprayLookup._initialized ~= true then
        local lookupDefs = {
            fertilizer = { "FERTILIZER", "LIQUIDFERTILIZER" },
            lime = { "LIME" },
            manure = { "MANURE" },
            liquidManure = { "LIQUIDMANURE", "DIGESTATE" }
        }

        for surfaceKind, fillTypeNames in pairs(lookupDefs) do
            local sprayGroundType = nil
            if g_sprayTypeManager ~= nil and g_sprayTypeManager.getSprayTypeByFillTypeIndex ~= nil and FillType ~= nil then
                for _, fillTypeName in ipairs(fillTypeNames) do
                    local fillTypeIndex = FillType[fillTypeName]
                    if fillTypeIndex ~= nil then
                        local sprayDesc = g_sprayTypeManager:getSprayTypeByFillTypeIndex(fillTypeIndex)
                        if sprayDesc ~= nil and sprayDesc.sprayGroundType ~= nil then
                            sprayGroundType = sprayDesc.sprayGroundType
                            break
                        end
                    end
                end
            end
            self._dirtColorSprayLookup[surfaceKind] = sprayGroundType or false
        end
        self._dirtColorSprayLookup._initialized = true
    end

    for _, surfaceKind in ipairs({ "liquidManure", "manure", "lime", "fertilizer" }) do
        local sprayGroundType = self._dirtColorSprayLookup[surfaceKind]
        if type(sprayGroundType) == "number" and sprayValue == sprayGroundType then
            return surfaceKind
        end
    end

    return nil
end

function FieldGroundMudPhysics:getCachedDirtColorSurfaceKind(vehicle, cacheKey, x, z, y, allowSnow, allowSpray)
    if x == nil or z == nil then
        return nil
    end

    local st = getState(vehicle)
    st.__fgSurfaceKindCache = st.__fgSurfaceKindCache or {}

    local nowMs = g_time or 0
    local ttlMs = math.max(40, self.surfaceKindCacheMs or 240)
    local moveM = math.max(0.10, self.surfaceKindCacheMoveM or 0.75)
    local cache = st.__fgSurfaceKindCache[cacheKey]
    local cacheTtlMs = ttlMs
    local cacheMoveM = moveM
    if cache ~= nil and cache.kind == "snow" then
        cacheTtlMs = math.min(cacheTtlMs, 80)
        cacheMoveM = math.min(cacheMoveM, 0.20)
    end
    if cache ~= nil
        and (nowMs - (cache.timeMs or 0)) < cacheTtlMs
        and math.abs((cache.x or x) - x) <= cacheMoveM
        and math.abs((cache.z or z) - z) <= cacheMoveM then
        return cache.kind
    end

    local kind = nil
    local canProbeSnow = allowSnow ~= false and FillType ~= nil and FillType.SNOW ~= nil
    if canProbeSnow then
        local airTemp = getAirTempC()
        local minProbeTemp = (self.snowMudMeshStickTempC or -7.0) - (self.snowSurfaceProbeTempMarginC or 2.0)
        canProbeSnow = (airTemp == nil) or (airTemp > minProbeTemp)
    end

    if canProbeSnow then
        local fillType = self:getFillTypeAtWorldPos(x, y, z)
        if fillType == FillType.SNOW then
            kind = "snow"
        end
    end

    if kind == nil and allowSpray ~= false and self.dirtColorUseSprayLayers ~= false then
        local sprayValue = self:getSprayTypeValueAtWorldPos(x, z)
        if sprayValue ~= nil then
            self._dirtColorSprayLookup = self._dirtColorSprayLookup or {}
            if self._dirtColorSprayLookup._initialized ~= true then
                local lookupDefs = {
                    fertilizer = { "FERTILIZER", "LIQUIDFERTILIZER" },
                    lime = { "LIME" },
                    manure = { "MANURE" },
                    liquidManure = { "LIQUIDMANURE", "DIGESTATE" }
                }
                for surfaceKind, fillTypeNames in pairs(lookupDefs) do
                    local sprayGroundType = nil
                    if g_sprayTypeManager ~= nil and g_sprayTypeManager.getSprayTypeByFillTypeIndex ~= nil and FillType ~= nil then
                        for _, fillTypeName in ipairs(fillTypeNames) do
                            local fillTypeIndex = FillType[fillTypeName]
                            if fillTypeIndex ~= nil then
                                local sprayDesc = g_sprayTypeManager:getSprayTypeByFillTypeIndex(fillTypeIndex)
                                if sprayDesc ~= nil and sprayDesc.sprayGroundType ~= nil then
                                    sprayGroundType = sprayDesc.sprayGroundType
                                    break
                                end
                            end
                        end
                    end
                    self._dirtColorSprayLookup[surfaceKind] = sprayGroundType or false
                end
                self._dirtColorSprayLookup._initialized = true
            end

            for _, surfaceKind in ipairs({ "liquidManure", "manure", "lime", "fertilizer" }) do
                local sprayGroundType = self._dirtColorSprayLookup[surfaceKind]
                if type(sprayGroundType) == "number" and sprayValue == sprayGroundType then
                    kind = surfaceKind
                    break
                end
            end
        end
    end

    st.__fgSurfaceKindCache[cacheKey] = { timeMs = nowMs, x = x, z = z, kind = kind }
    return kind
end

function FieldGroundMudPhysics:getProfileAtWorldPos(x, z)
    local gt = self:getGroundTypeValueAtWorldPos(x, z)
    if gt == nil then
        return 0, nil, nil
    end

    local prof = self.groundProfiles[gt]
    if prof == nil then
        return 0, gt, nil
    end

    return clamp(prof.mud or 0, 0, 1), gt, prof
end

-- =========================================================
-- VEHICLE STATE
-- =========================================================
FieldGroundMudPhysics.vehicleState = setmetatable({}, { __mode="k" })

getState = function(vehicle)
    local st = FieldGroundMudPhysics.vehicleState[vehicle]
    if st == nil then
        st = {
            sink=0,
            stuck=false,
            permaActive=false,
            permaTargetExtra=0,
            permaCd=0,
            permaStuckToken=0,
            permaNotifyToken=0,
            permaReleaseDist=0,
            permaLastPosX=nil,
            permaLastPosZ=nil,
            permaMoveTickMs=-1,
            lastTick=-1,
            ramp=0,
            origExtTorqueMult=nil,
            lastAppliedMotorLoad=1,
        }
        FieldGroundMudPhysics.vehicleState[vehicle] = st
    end
    return st
end

local function fgCollectWashableDirtColorParts(vehicle)
    local bodyParts = {}
    local seen = {}
    if vehicle == nil or vehicle.spec_washable == nil or vehicle.spec_washable.washableNodes == nil then
        return bodyParts, seen
    end

    local function addNode(node)
        if node ~= nil and not seen[node] and fgIsValidShaderNode(node, "dirtColor") then
            seen[node] = true
            bodyParts[#bodyParts + 1] = node
        end
    end

    for _, washableNodes in pairs(vehicle.spec_washable.washableNodes) do
        if washableNodes.nodes ~= nil then
            for node, _ in pairs(washableNodes.nodes) do
                addNode(node)
            end
        end

        if washableNodes.mudNodes ~= nil then
            for node, _ in pairs(washableNodes.mudNodes) do
                addNode(node)
            end
        end
    end

    return bodyParts, seen
end

local function fgCollectWheelDirtColorParts(vehicle, bodySeen)
    local wheelParts = {}
    if vehicle == nil or vehicle.spec_wheels == nil or vehicle.spec_wheels.wheels == nil then
        return wheelParts
    end

    for i, wheel in ipairs(vehicle.spec_wheels.wheels) do
        local parts = {}
        local localSeen = {}

        local function addDirectNode(node)
            if node ~= nil and not localSeen[node] and fgIsValidShaderNode(node, "dirtColor") then
                localSeen[node] = true
                parts[#parts + 1] = node
            end
        end

        local function addNodesFrom(rootNode)
            if rootNode == nil or I3DUtil == nil or I3DUtil.getNodesByShaderParam == nil then
                return
            end

            addDirectNode(rootNode)
            local nodes = I3DUtil.getNodesByShaderParam(rootNode, "dirtColor", wheel)
            for _, node in pairs(nodes or {}) do
                addDirectNode(node)
            end
        end

        addNodesFrom(wheel.repr)
        if wheel.driveNode ~= nil and wheel.driveNode ~= wheel.repr then
            addNodesFrom(wheel.driveNode)
        end
        if wheel.wheelMudMeshes ~= nil and vehicle.getWashableNodeByCustomIndex ~= nil then
            local mudWashableNode = vehicle:getWashableNodeByCustomIndex(wheel.wheelMudMeshes)
            if mudWashableNode ~= nil then
                if mudWashableNode.node ~= nil then
                    addNodesFrom(mudWashableNode.node)
                end
                if mudWashableNode.nodes ~= nil then
                    for node, _ in pairs(mudWashableNode.nodes) do
                        addNodesFrom(node)
                    end
                end
                if mudWashableNode.mudNodes ~= nil then
                    for node, _ in pairs(mudWashableNode.mudNodes) do
                        addNodesFrom(node)
                    end
                end
            end
        end

        if #parts > 0 then
            wheelParts[i] = parts
            for _, node in ipairs(parts) do
                if bodySeen ~= nil then
                    bodySeen[node] = true
                end
            end
        end
    end

    return wheelParts
end

function FieldGroundMudPhysics:ensureVehicleDirtColorState(vehicle)
    if not self.dirtColorEnable or vehicle == nil then
        return nil
    end

    local st = getState(vehicle)
    local colorState = st.__fgDirtColor
    if colorState ~= nil and colorState.initialized == true then
        return colorState
    end

    local bodyParts, bodySeen = fgCollectWashableDirtColorParts(vehicle)
    local wheelParts = fgCollectWheelDirtColorParts(vehicle, bodySeen)
    local wheelNodes = {}
    for _, parts in pairs(wheelParts) do
        if parts ~= nil then
            for _, node in ipairs(parts) do
                wheelNodes[node] = true
            end
        end
    end
    for i = #bodyParts, 1, -1 do
        if wheelNodes[bodyParts[i]] == true then
            table.remove(bodyParts, i)
        end
    end
    local hasWheelParts = false
    for _, parts in pairs(wheelParts) do
        if parts ~= nil and #parts > 0 then
            hasWheelParts = true
            break
        end
    end

    colorState = {
        initialized = true,
        supported = (#bodyParts > 0) or hasWheelParts,
        bodyParts = bodyParts,
        wheelParts = wheelParts,
        bodyBlend = 0,
        wheelBlend = {},
        dryColor = fgGetDryDirtColor(),
        mudColor = fgGetMudTintColor(),
        bodyTintColor = fgGetMudTintColor(),
        wheelTintColor = {},
        bodyTargetBlend = 0,
        wheelTargetBlend = {},
        bodySurfaceHoldT = 0,
        bodyLatchedSurfaceKind = nil,
        wheelSurfaceHoldT = {},
        wheelLatchedSurfaceKind = {}
    }

    st.__fgDirtColor = colorState
    return colorState
end

function FieldGroundMudPhysics:updateVehicleDirtColor(vehicle, dtS, speedKph, bodyMud, wheelMudMap, bodySurfaceKind, wheelSurfaceKindMap)
    local colorState = self:ensureVehicleDirtColorState(vehicle)
    if colorState == nil or colorState.supported ~= true then
        return
    end

    local useSprayLayers = self.dirtColorUseSprayLayers ~= false

    colorState.dryColor = fgGetDryDirtColor()
    colorState.mudColor = fgGetMudTintColor()
    colorState.fertilizerColor = fgGetFertilizerTintColor()
    colorState.limeColor = fgGetLimeTintColor()
    colorState.manureColor = fgGetManureTintColor()
    colorState.liquidManureColor = fgGetLiquidManureTintColor()
    colorState.snowColor = fgGetSnowTintColor()

    local speedBoostDiv = math.max(1.0, self.dirtColorSpeedBoostKph or 18.0)
    local speedBoost = 1.0 + math.min((speedKph or 0) / speedBoostDiv, 1.0)
    local bodyRise = math.max(0.03, self.dirtColorBodyRiseSpeed or 0.85) * speedBoost
    local wheelRise = math.max(0.03, self.dirtColorWheelRiseSpeed or 1.35) * speedBoost
    local dryReturn = math.max(0.02, self.dirtColorDryReturnSpeed or 0.40)
    local tintBlend = math.max(0.02, self.dirtColorTintBlendSpeed or 0.55)
    local bodyHoldSec = math.max(0, self.dirtColorBodyHoldSec or 1.20)
    local wheelHoldSec = math.max(0, self.dirtColorWheelHoldSec or 1.80)
    local targetLag = math.max(0.02, self.dirtColorTargetLagSpeed or 0.30)
    local limeFade = math.max(0.02, self.dirtColorLimeFadeSpeed or 0.12)
    local minMud = clamp(self.dirtColorMinEffMud or 0.03, 0, 1)

    local targetBody = clamp(bodyMud or 0, 0, 1)
    if targetBody < minMud then
        targetBody = 0
    end
    if not useSprayLayers and bodySurfaceKind ~= "snow" then
        bodySurfaceKind = nil
        if colorState.bodyLatchedSurfaceKind ~= "snow" then
            colorState.bodyLatchedSurfaceKind = nil
            colorState.bodySurfaceHoldT = 0
        end
    end
    if bodySurfaceKind ~= nil then
        colorState.bodyLatchedSurfaceKind = bodySurfaceKind
        colorState.bodySurfaceHoldT = bodyHoldSec
    else
        colorState.bodySurfaceHoldT = math.max(0, (colorState.bodySurfaceHoldT or 0) - dtS)
        if (colorState.bodySurfaceHoldT or 0) > 0 and colorState.bodyLatchedSurfaceKind ~= nil then
            bodySurfaceKind = colorState.bodyLatchedSurfaceKind
        else
            colorState.bodyLatchedSurfaceKind = nil
        end
    end
    local bodyTargetColor = colorState.mudColor
    if bodySurfaceKind == "fertilizer" then
        targetBody = math.max(targetBody, 0.10)
        bodyTargetColor = colorState.fertilizerColor
    elseif bodySurfaceKind == "lime" then
        targetBody = math.max(targetBody, 0.12)
        bodyTargetColor = colorState.limeColor
    elseif bodySurfaceKind == "manure" then
        targetBody = math.max(targetBody, 0.16)
        bodyTargetColor = colorState.manureColor
    elseif bodySurfaceKind == "liquidManure" then
        targetBody = math.max(targetBody, 0.18)
        bodyTargetColor = colorState.liquidManureColor
    end

    local bodyTargetBlend = colorState.bodyTargetBlend or 0
    local bodyTargetAlpha = clamp(dtS * ((targetBody > bodyTargetBlend) and targetLag or dryReturn), 0, 0.018)
    bodyTargetBlend = fgColorLerp(bodyTargetBlend, targetBody, bodyTargetAlpha)
    colorState.bodyTargetBlend = clamp(bodyTargetBlend, 0, 1)

    local bodyBlend = colorState.bodyBlend or 0
    local bodyAlpha = clamp(dtS * ((bodyTargetBlend > bodyBlend) and bodyRise or dryReturn), 0, 0.020)
    bodyBlend = fgColorLerp(bodyBlend, bodyTargetBlend, bodyAlpha)
    colorState.bodyBlend = clamp(bodyBlend, 0, 1)
    local bodyTintSpeed = tintBlend
    if not fgIsSpecialSurfaceKind(bodySurfaceKind) and not fgIsSpecialSurfaceKind(colorState.bodyLatchedSurfaceKind) then
        bodyTintSpeed = limeFade
    end
    colorState.bodyTintColor = fgLerpColor(colorState.bodyTintColor or colorState.mudColor, bodyTargetColor, clamp(dtS * bodyTintSpeed, 0, 0.015))

    if colorState.bodyParts ~= nil and #colorState.bodyParts > 0 then
        fgSetDirtColorParts(colorState.bodyParts, fgBlendDirtColor(colorState.dryColor, colorState.bodyTintColor, colorState.bodyBlend))
    end

    if colorState.wheelParts == nil then
        return
    end

    for index, parts in pairs(colorState.wheelParts) do
        local targetWheel = 0
        if wheelMudMap ~= nil then
            targetWheel = clamp(wheelMudMap[index] or 0, 0, 1)
        end
        if targetWheel < minMud then
            targetWheel = 0
        end
        local wheelSurfaceKind = wheelSurfaceKindMap ~= nil and wheelSurfaceKindMap[index] or nil
        if not useSprayLayers and wheelSurfaceKind ~= "snow" then
            wheelSurfaceKind = nil
        end
        if wheelSurfaceKind ~= nil then
            colorState.wheelLatchedSurfaceKind[index] = wheelSurfaceKind
            colorState.wheelSurfaceHoldT[index] = wheelHoldSec
        else
            local holdT = math.max(0, (colorState.wheelSurfaceHoldT[index] or 0) - dtS)
            colorState.wheelSurfaceHoldT[index] = holdT
            if holdT > 0 and colorState.wheelLatchedSurfaceKind[index] ~= nil then
                wheelSurfaceKind = colorState.wheelLatchedSurfaceKind[index]
            else
                colorState.wheelLatchedSurfaceKind[index] = nil
            end
        end
        local wheelTargetColor = colorState.mudColor
        if wheelSurfaceKind == "fertilizer" then
            targetWheel = math.max(targetWheel, 0.13)
            wheelTargetColor = colorState.fertilizerColor
        elseif wheelSurfaceKind == "lime" then
            targetWheel = math.max(targetWheel, 0.16)
            wheelTargetColor = colorState.limeColor
        elseif wheelSurfaceKind == "manure" then
            targetWheel = math.max(targetWheel, 0.20)
            wheelTargetColor = colorState.manureColor
        elseif wheelSurfaceKind == "liquidManure" then
            targetWheel = math.max(targetWheel, 0.22)
            wheelTargetColor = colorState.liquidManureColor
        elseif wheelSurfaceKind == "snow" then
            targetWheel = 1.0
            wheelTargetColor = colorState.snowColor
        end

        local wheelTargetBlend = colorState.wheelTargetBlend[index] or 0
        local wheelTargetAlpha = clamp(dtS * ((targetWheel > wheelTargetBlend) and targetLag or dryReturn), 0, 0.022)
        wheelTargetBlend = fgColorLerp(wheelTargetBlend, targetWheel, wheelTargetAlpha)
        colorState.wheelTargetBlend[index] = clamp(wheelTargetBlend, 0, 1)

        local wheelBlend = colorState.wheelBlend[index] or 0
        local wheelAlpha = clamp(dtS * ((wheelTargetBlend > wheelBlend) and wheelRise or dryReturn), 0, 0.026)
        wheelBlend = fgColorLerp(wheelBlend, wheelTargetBlend, wheelAlpha)
        colorState.wheelBlend[index] = clamp(wheelBlend, 0, 1)
        if wheelSurfaceKind == "snow" then
            colorState.wheelTintColor[index] = fgCopyColor(wheelTargetColor)
        else
            local wheelTintSpeed = tintBlend
            if not fgIsSpecialSurfaceKind(wheelSurfaceKind) and not fgIsSpecialSurfaceKind(colorState.wheelLatchedSurfaceKind[index]) then
                wheelTintSpeed = limeFade
            end
            colorState.wheelTintColor[index] = fgLerpColor(colorState.wheelTintColor[index] or colorState.mudColor, wheelTargetColor, clamp(dtS * wheelTintSpeed, 0, 0.018))
        end

        if parts ~= nil and #parts > 0 then
            fgSetDirtColorParts(parts, fgBlendDirtColor(colorState.dryColor, colorState.wheelTintColor[index], colorState.wheelBlend[index]))
        end
    end
end

local function fgGetVehicleRootNode(vehicle)
    if vehicle == nil then
        return nil
    end

    if vehicle.getRootNode ~= nil then
        local node = vehicle:getRootNode()
        if node ~= nil and node ~= 0 then
            return node
        end
    end

    if vehicle.rootNode ~= nil and vehicle.rootNode ~= 0 then
        return vehicle.rootNode
    end

    if vehicle.components ~= nil and vehicle.components[1] ~= nil then
        local node = vehicle.components[1].node
        if node ~= nil and node ~= 0 then
            return node
        end
    end

    return nil
end

local function fgGetVehicleXZ(vehicle)
    local node = fgGetVehicleRootNode(vehicle)
    if node == nil then
        return nil, nil
    end

    local x, _, z = getWorldTranslation(node)
    return x, z
end

local function fgResetPermaReleaseState(st, vehicle)
    if st == nil then
        return
    end

    st.permaReleaseDist = 0
    st.permaMoveTickMs = -1
    st.permaLastPosX, st.permaLastPosZ = fgGetVehicleXZ(vehicle)
end

local function fgUpdatePermaReleaseState(st, vehicle, timeMs)
    if st == nil then
        return 0
    end

    if st.permaMoveTickMs == timeMs then
        return st.permaReleaseDist or 0
    end

    st.permaMoveTickMs = timeMs

    local x, z = fgGetVehicleXZ(vehicle)
    if st.permaActive == true then
        if x ~= nil and z ~= nil and st.permaLastPosX ~= nil and st.permaLastPosZ ~= nil then
            local dx = x - st.permaLastPosX
            local dz = z - st.permaLastPosZ
            local dist = math.sqrt(dx * dx + dz * dz)

            if dist > 0.001 then
                local fullDist = math.max(0.01, FieldGroundMudPhysics.permaStuckReleaseDistanceFullM or 8.0)
                st.permaReleaseDist = math.min(fullDist, (st.permaReleaseDist or 0) + dist)
            end
        end
    else
        st.permaReleaseDist = 0
    end

    st.permaLastPosX = x
    st.permaLastPosZ = z
    return st.permaReleaseDist or 0
end

local function fgCountMudWheels(vehicle)
    if vehicle == nil or vehicle.spec_wheels == nil or vehicle.spec_wheels.wheels == nil then
        return 0
    end

    local count = 0
    for _, w in ipairs(vehicle.spec_wheels.wheels) do
        local x, _, z = FieldGroundMudPhysics:getWheelContactPos(w)
        if x ~= nil then
            local mudBase, _, prof = FieldGroundMudPhysics:getProfileAtWorldPos(x, z)
            if prof ~= nil and (mudBase or 0) > 0.01 then
                count = count + 1
            end
        end
    end

    return count
end

-- =========================================================
-- COMPUTE AVERAGE FIELD MUD + SLIP + BEST PROFILE
-- =========================================================
function FieldGroundMudPhysics:computeVehicleFieldMud(vehicle)
    if vehicle.spec_wheels == nil or vehicle.spec_wheels.wheels == nil then
        return 0, 0, nil, nil
    end

    local mudSum, slipSum, count = 0, 0, 0
    local bestMud, bestGT, bestProf = 0, nil, nil

    for _, w in ipairs(vehicle.spec_wheels.wheels) do
        local x, _, z = self:getWheelContactPos(w)
        if x ~= nil then
            local mudBase, gt, prof = self:getProfileAtWorldPos(x, z)
            local slip = 0
            if w.physics ~= nil and w.physics.netInfo ~= nil then
                slip = w.physics.netInfo.slip or 0
            end

            mudSum  = mudSum + mudBase
            slipSum = slipSum + slip
            count   = count + 1

            if mudBase > bestMud and prof ~= nil then
                bestMud = mudBase
                bestGT = gt
                bestProf = prof
            end
        end
    end

    if count == 0 then
        return 0, 0, nil, nil
    end

    return mudSum / count, slipSum / count, bestGT, bestProf
end

function FieldGroundMudPhysics:computeVehicleFieldMudCached(vehicle)
    if vehicle == nil then
        return 0, 0, nil, nil
    end

    local st = getState(vehicle)
    local nowMs = g_time or 0
    if st.__fgFieldMudCacheTimeMs == nowMs then
        return st.__fgFieldMudCacheMud or 0,
            st.__fgFieldMudCacheSlip or 0,
            st.__fgFieldMudCacheGT,
            st.__fgFieldMudCacheProf
    end

    local mudAvg, slipAvg, bestGT, bestProf = self:computeVehicleFieldMud(vehicle)
    st.__fgFieldMudCacheTimeMs = nowMs
    st.__fgFieldMudCacheMud = mudAvg or 0
    st.__fgFieldMudCacheSlip = slipAvg or 0
    st.__fgFieldMudCacheGT = bestGT
    st.__fgFieldMudCacheProf = bestProf

    return mudAvg, slipAvg, bestGT, bestProf
end

-- =========================================================
-- VEHICLE RESET/RELOAD GUARD
-- =========================================================

function FieldGroundMudPhysics:isVehicleResetting(vehicle)
    if vehicle == nil then return true end
    if vehicle.isDeleted == true then return true end
    if vehicle.isDeleting == true then return true end
    if vehicle.isResetInProgress == true then return true end
    if vehicle.__fgResetGuard == true then return true end
    return false
end

function FieldGroundMudPhysics:cleanupVehicleState(vehicle, reason)
    if vehicle == nil then return end
    vehicle.__fgResetGuard = true

    local st = (FieldGroundMudPhysics.vehicleState ~= nil) and FieldGroundMudPhysics.vehicleState[vehicle] or nil
    if st ~= nil then
        st.sink = 0
        st.stuck = false
        st.ramp = 0
        st.lastTick = -1
        st.permaActive = false
        st.permaTargetExtra = 0
        st.permaCd = 0
        st.permaStuckToken = 0
        st.permaNotifyToken = 0
        st.permaReleaseDist = 0
        st.permaLastPosX = nil
        st.permaLastPosZ = nil
        st.permaMoveTickMs = -1
        st.__fgAppliedSpeedLimit = false
        st.__fgAppliedMotorLoad = false
        st.__fgDirtColor = nil

        st.__fgOrigMotorSpeedLimit = nil
        st.__fgSpeedLimitCaptured = false

        if st.extraPS ~= nil then
            for _, entry in pairs(st.extraPS) do
                if entry ~= nil and entry.ps ~= nil then
                    entry.ps.isActive = false
                    ParticleUtil.setEmittingState(entry.ps, false)
                    if entry.ps.shape ~= nil then
                        mpSafeDelete(entry.ps.shape)
                    end
                end
                if entry ~= nil and entry.shape ~= nil then
                    mpSafeDelete(entry.shape)
                end
            end
            st.extraPS = nil
        end
    end

    local motor = nil
    if vehicle.getMotor ~= nil then motor = vehicle:getMotor() end
    if motor == nil and vehicle.spec_motorized ~= nil then motor = vehicle.spec_motorized.motor end

    if motor ~= nil then
		if motor.setSpeedLimit ~= nil then
			motor:setSpeedLimit(math.huge)
		end


        if motor.setExternalTorqueVirtualMultiplicator ~= nil then
            motor:setExternalTorqueVirtualMultiplicator(1)
        end
    end

    if vehicle.spec_wheels ~= nil and vehicle.spec_wheels.wheels ~= nil then
        for _, w in ipairs(vehicle.spec_wheels.wheels) do
            if w ~= nil and w.physics ~= nil then
                local wp = w.physics
                wp.__fgDesiredRadius = nil
                wp.__fgMudCurExtra = 0
                wp.__fgMudBrakeExtra = 0
                wp.__fgBrakeCur = 0
                wp.__fgPerma = false
                wp.__fgWasMud = false
                wp.__fgStillT = 0
                wp.__fgGripMul = nil
                wp.isFrictionDirty = true

                if _G.__MudRadiusCombiner ~= nil and _G.__MudRadiusCombiner.apply ~= nil then
                    _G.__MudRadiusCombiner.apply(wp, FieldGroundMudPhysics.freezeRadiusEps or 0.0015)
                end
            end
        end
    end

    if FieldGroundMudPhysics.debug then
        print(string.format("[FieldGroundMudPhysics] cleanupVehicleState(%s) reason=%s",
            tostring(vehicle.configFileName or vehicle.typeName or "vehicle"), tostring(reason)))
    end
end

-- =========================================================
-- APPLY SETTINGS IMMEDIATELY
-- =========================================================

function FieldGroundMudPhysics:resetVehicleRuntime(vehicle, reason)
    if vehicle == nil then return end

    local st = (FieldGroundMudPhysics.vehicleState ~= nil) and FieldGroundMudPhysics.vehicleState[vehicle] or nil
    if st ~= nil then
        st.sink = 0
        st.stuck = false
        st.ramp = 0
        st.lastTick = -1
        st.permaActive = false
        st.permaTargetExtra = 0
        st.permaCd = 0
        st.permaStuckToken = 0
        st.permaNotifyToken = 0
        st.permaReleaseDist = 0
        st.permaLastPosX = nil
        st.permaLastPosZ = nil
        st.permaMoveTickMs = -1

        st.__fgAppliedSpeedLimit = false
        st.__fgAppliedMotorLoad  = false
        st.lastAppliedMotorLoad  = nil
        st.__fgDirtColor         = nil

        st.__fgOrigMotorSpeedLimit = nil
        st.__fgSpeedLimitCaptured = false

        if st.extraPS ~= nil then
            for _, entry in pairs(st.extraPS) do
                if entry ~= nil and entry.ps ~= nil then
                    entry.ps.isActive = false
                    ParticleUtil.setEmittingState(entry.ps, false)
                    if entry.ps.shape ~= nil then
                        mpSafeDelete(entry.ps.shape)
                    end
                end
                if entry ~= nil and entry.shape ~= nil then
                    mpSafeDelete(entry.shape)
                end
            end
            st.extraPS = nil
        end
    end

    local motor = nil
    if vehicle.getMotor ~= nil then motor = vehicle:getMotor() end
    if motor == nil and vehicle.spec_motorized ~= nil then motor = vehicle.spec_motorized.motor end

    if motor ~= nil then
        if motor.setExternalTorqueVirtualMultiplicator ~= nil then
            motor:setExternalTorqueVirtualMultiplicator(1)
        end
		if motor.setSpeedLimit ~= nil then
			motor:setSpeedLimit(math.huge)
		end

    end

    if vehicle.spec_wheels ~= nil and vehicle.spec_wheels.wheels ~= nil then
        for _, w in ipairs(vehicle.spec_wheels.wheels) do
            if w ~= nil and w.physics ~= nil then
                local wp = w.physics
                wp.__fgDesiredRadius  = nil
                wp.__fgMudCurExtra    = 0
                wp.__fgMudBrakeExtra  = 0
                wp.__fgBrakeCur       = 0
                wp.__fgPerma          = false
                wp.__fgWasMud         = false
                wp.__fgStillT         = 0
                wp.__fgGripMul        = nil
                wp.isFrictionDirty    = true

                if _G.__MudRadiusCombiner ~= nil and _G.__MudRadiusCombiner.apply ~= nil then
                    _G.__MudRadiusCombiner.apply(wp, FieldGroundMudPhysics.freezeRadiusEps or 0.0015)
                end
            end
        end
    end

    if FieldGroundMudPhysics.debug then
        print(string.format("[FieldGroundMudPhysics] resetVehicleRuntime(%s) reason=%s",
            tostring(vehicle.configFileName or vehicle.typeName or "vehicle"), tostring(reason)))
    end
end

function FieldGroundMudPhysics:onSettingsChanged()
    self.radiusMinFactor    = clamp(tonumber(self.radiusMinFactor) or 0, 0, 1)
    self.widthBonusRefWidth = clamp(tonumber(self.widthBonusRefWidth) or 0, 0.10, 5.0)
    self.widthBonusStrength = clamp(tonumber(self.widthBonusStrength) or 0, 0, 5.0)
    self.widthBonusMax      = clamp(tonumber(self.widthBonusMax) or 0, 0, 0.95)
    self.wheelBrakeBase     = clamp(tonumber(self.wheelBrakeBase) or 0, 0, 15)
    self.wheelBrakeFromSink = clamp(tonumber(self.wheelBrakeFromSink) or 0, 0, 15)
    self.wheelBrakeFromSlip = clamp(tonumber(self.wheelBrakeFromSlip) or 0, 0, 15)

    if g_currentMission ~= nil and g_currentMission.vehicles ~= nil then
        for _, v in pairs(g_currentMission.vehicles) do
            if v ~= nil then
                self:resetVehicleRuntime(v, "onSettingsChanged")
            end
        end
    end

    FieldGroundMudPhysics.vehicleState = setmetatable({}, { __mode="k" })

    if MudExtraParticlePresets ~= nil then
        MudExtraParticlePresets.applyFieldGroundMudPhysics(self)
        MudExtraParticlePresets.clearCache(self)
        if self.extraParticlesEnable and self.loadExtraParticleReferences ~= nil then
            self:loadExtraParticleReferences()
        end
    end

    if FieldGroundMudPhysics.debug then
        print(string.format("[FieldGroundMudPhysics] settings changed -> enabled=%s", tostring(self.enabled)))
    end
	
    if g_currentMission ~= nil and g_currentMission.vehicles ~= nil then
        for _, v in pairs(g_currentMission.vehicles) do
            local st = self.vehicleState ~= nil and self.vehicleState[v] or nil
            if st ~= nil then
                st.__fgSurfaceKindCache = nil
                st.__fgDirtColorLastMs = nil
            end
            if v ~= nil and v.spec_wheels ~= nil and v.spec_wheels.wheels ~= nil then
                for _, w in ipairs(v.spec_wheels.wheels) do
                    if w ~= nil and w.physics ~= nil then
                        w.physics.__fgEraseLastMs = 0
                    end
                end
            end
        end
    end
end

function FieldGroundMudPhysics:onVehicleReset(oldVehicle, newVehicle)
    if self.vehicleState ~= nil and oldVehicle ~= nil then
        local st = self.vehicleState[oldVehicle]
        if st ~= nil and st.extraPS ~= nil then
            for _, entry in pairs(st.extraPS) do
                if entry ~= nil and entry.ps ~= nil then
                    entry.ps.isActive = false
                    ParticleUtil.setEmittingState(entry.ps, false)
                end
            end
        end
        self.vehicleState[oldVehicle] = nil
    end

    if oldVehicle ~= nil and oldVehicle.spec_wheels ~= nil and oldVehicle.spec_wheels.wheels ~= nil then
        for _, w in ipairs(oldVehicle.spec_wheels.wheels) do
            if w ~= nil and w.physics ~= nil then
                local wp = w.physics
                wp.__fgDesiredRadius = nil
                wp.__fgMudCurExtra = 0
                wp.__fgMudBrakeExtra = 0
            end
        end
    end
end

function FieldGroundMudPhysics.installResetHooks()
    if Utils == nil or Utils.overwrittenFunction == nil then
        print("[FieldGroundMudPhysics] Utils not found (installResetHooks)"); return
    end

    if Vehicle ~= nil and Vehicle.reset ~= nil then
        Vehicle.reset = Utils.overwrittenFunction(
            Vehicle.reset,
            function(vehicle, superFunc, forceDelete, callback, resetPlayer)
                if FieldGroundMudPhysics ~= nil then
                    FieldGroundMudPhysics:cleanupVehicleState(vehicle, "Vehicle.reset")
                end
                if superFunc ~= nil then
                    return superFunc(vehicle, forceDelete, callback, resetPlayer)
                end
            end
        )
    end

    if Vehicle ~= nil and Vehicle.delete ~= nil then
        Vehicle.delete = Utils.overwrittenFunction(
            Vehicle.delete,
            function(vehicle, superFunc, ...)
                if FieldGroundMudPhysics ~= nil then
                    FieldGroundMudPhysics:cleanupVehicleState(vehicle, "Vehicle.delete")
                end
                if superFunc ~= nil then
                    return superFunc(vehicle, ...)
                end
            end
        )
    end

    if g_messageCenter ~= nil and MessageType ~= nil and MessageType.VEHICLE_RESET ~= nil then
        g_messageCenter:subscribe(MessageType.VEHICLE_RESET, FieldGroundMudPhysics.onVehicleReset, FieldGroundMudPhysics)
    end

    print("[FieldGroundMudPhysics] reset hooks installed (Vehicle.reset/delete + MessageType.VEHICLE_RESET)")
end

function FieldGroundMudPhysics.deleteResetHooks()
    if g_messageCenter ~= nil and MessageType ~= nil and MessageType.VEHICLE_RESET ~= nil then
        g_messageCenter:unsubscribe(MessageType.VEHICLE_RESET, FieldGroundMudPhysics)
    end
end

-- =========================================================
-- DIRT / MUD BUILDUP (Washable) - FIELD GROUND TYPES
-- =========================================================
function FieldGroundMudPhysics:applyFieldDirt(vehicle, dt, wetnessEff, speedKph, rampPowVal, mudAvgIn, bestProfIn)
    if vehicle == nil then
        return
    end

    if self.isInShopPreview ~= nil and self:isInShopPreview(vehicle) then
        return
    end

    local dtS = (dt or 0) * 0.001
    if dtS <= 0 then
        return
    end

    local sKph = speedKph or 0
    local spDiv = math.max(1.0, self.dirtSpeedBoostKph or 18.0)
    local speedFactor = 1.0 + math.min(sKph / spDiv, 1.0)
    local dirtMax = self.dirtMax or 1.0
    local rampK = rampPowVal or 1.0

    local mudAvg = mudAvgIn or 0
    local bestProf = bestProfIn
    local wetFactor = wetnessStrength(wetnessEff)
    local wetMul = (bestProf ~= nil and bestProf.wetMul) or 1.0
    local effMudBody = clamp((mudAvg or 0) * wetFactor * wetMul, 0, 1) * rampK

    local wheelMudMap = {}
    local wheelProfMap = {}
    local wheelSurfaceKindMap = {}
    local wheels = (vehicle.spec_wheels ~= nil and vehicle.spec_wheels.wheels) or nil
    local useSprayLayers = self.dirtColorUseSprayLayers ~= false
    local needSurfaceKind = (self.dirtColorEnable == true) or (self.dirtEnable == true)
    if wheels ~= nil then
        for wheelIndex, w in ipairs(wheels) do
            if w ~= nil then
                local x, y, z = self:getWheelContactPos(w)
                if x ~= nil then
                    if needSurfaceKind then
                        wheelSurfaceKindMap[wheelIndex] = self:getCachedDirtColorSurfaceKind(vehicle, wheelIndex, x, z, y, true, useSprayLayers)
                    end
                    local mudBase, _, prof = self:getProfileAtWorldPos(x, z)
                    wheelProfMap[wheelIndex] = prof
                    if prof ~= nil and (mudBase or 0) > 0.01 then
                        local wetMulW = prof.wetMul or 1.0
                        local effMudWheel = clamp((mudBase or 0) * wetFactor * wetMulW, 0, 1) * rampK
                        effMudWheel = math.max(effMudWheel, (mudBase or 0) * 0.20 * rampK)
                        wheelMudMap[wheelIndex] = clamp(effMudWheel, 0, 1)
                    else
                        wheelMudMap[wheelIndex] = 0
                    end
                    if wheelSurfaceKindMap[wheelIndex] == "snow" then
                        wheelMudMap[wheelIndex] = math.max(wheelMudMap[wheelIndex] or 0, 0.24)
                    end
                else
                    wheelMudMap[wheelIndex] = 0
                end
            end
        end
    end

    local bodySurfaceKind = nil
    local rootNode = fgGetVehicleRootNode(vehicle)
    if rootNode ~= nil and rootNode ~= 0 and self.dirtColorEnable == true then
        local vx, _, vz = getWorldTranslation(rootNode)
        bodySurfaceKind = self:getCachedDirtColorSurfaceKind(vehicle, "body", vx, vz, nil, false, useSprayLayers)
    end
    if bodySurfaceKind == nil then
        for _, kind in pairs(wheelSurfaceKindMap) do
            if kind ~= nil and kind ~= "snow" then
                bodySurfaceKind = kind
                break
            end
        end
    end

    local hasSnowWheel = false
    for _, kind in pairs(wheelSurfaceKindMap) do
        if kind == "snow" then
            hasSnowWheel = true
            break
        end
    end

    local st = getState(vehicle)
    st.__fgSnowWheelActive = st.__fgSnowWheelActive or {}
    st.__fgSnowWheelCleanLeft = st.__fgSnowWheelCleanLeft or {}
    local hasSnowWheelActive = false
    for _, isActive in pairs(st.__fgSnowWheelActive) do
        if isActive == true then
            hasSnowWheelActive = true
            break
        end
    end

    if self.dirtEnable and vehicle.getDirtAmount ~= nil and vehicle.setDirtAmount ~= nil and (((wetnessEff or 0) >= (self.dirtWetnessMin or 0.18)) or hasSnowWheel or hasSnowWheelActive) then
        local profDirtMulBody = (bestProf ~= nil and bestProf.dirtMul) or 1.0
        if effMudBody >= (self.dirtMinEffMud or 0.06) then
            local bodyAdd = (self.dirtBodyPerSec or 0.020) * effMudBody * profDirtMulBody * speedFactor * dtS
            if bodyAdd > 0 then
                local cur = vehicle:getDirtAmount() or 0
                vehicle:setDirtAmount(clamp(cur + bodyAdd, 0, dirtMax))
            end
        end

        if wheels ~= nil and vehicle.getWashableNodeByCustomIndex ~= nil and vehicle.setNodeDirtAmount ~= nil then
            local minMud = (self.dirtMinEffMud or 0.06)
            local airTemp = getAirTempC()
            local snowMudMeshCanStick = (airTemp == nil) or (airTemp > (self.snowMudMeshStickTempC or -7.0))
            local snowMudMeshMeltPerSec = math.max(0.02, (self.dirtWheelPerSec or 0.085) * 1.35)
            local snowWheelCleanPerSec = math.max(snowMudMeshMeltPerSec, self.snowWheelCleanPerSec or 0.22)
            for wheelIndex, w in ipairs(wheels) do
                local effMudWheel = wheelMudMap[wheelIndex] or 0
                local wheelSurfaceKind = wheelSurfaceKindMap[wheelIndex]
                local isSnowWheel = wheelSurfaceKind == "snow"
                local hadSnowMesh = st.__fgSnowWheelActive[wheelIndex] == true
                local snowCleanLeft = st.__fgSnowWheelCleanLeft[wheelIndex] or 0
                if w ~= nil then
                    local nd = vehicle:getWashableNodeByCustomIndex(w)
                    local ndMud = nil
                    if w.wheelMudMeshes ~= nil then
                        ndMud = vehicle:getWashableNodeByCustomIndex(w.wheelMudMeshes)
                    end

                    if (not isSnowWheel) and hadSnowMesh then
                        local anySnowDirtLeft = false
                        local cleanStep = snowWheelCleanPerSec * dtS

                        if nd ~= nil and nd.dirtAmount ~= nil and (nd.dirtAmount or 0) > 0 then
                            local nextAmount = clamp((nd.dirtAmount or 0) - cleanStep, 0, dirtMax)
                            vehicle:setNodeDirtAmount(nd, nextAmount, true)
                            w.forceWheelDirtUpdate = true
                            anySnowDirtLeft = anySnowDirtLeft or nextAmount > 0
                        end

                        if ndMud ~= nil and ndMud.dirtAmount ~= nil and (ndMud.dirtAmount or 0) > 0 then
                            local nextMudAmount = clamp((ndMud.dirtAmount or 0) - cleanStep, 0, dirtMax)
                            vehicle:setNodeDirtAmount(ndMud, nextMudAmount, true)
                            w.forceWheelDirtUpdate = true
                            anySnowDirtLeft = anySnowDirtLeft or nextMudAmount > 0
                        end

                        snowCleanLeft = math.max(0, snowCleanLeft - cleanStep)
                        st.__fgSnowWheelCleanLeft[wheelIndex] = snowCleanLeft
                        anySnowDirtLeft = anySnowDirtLeft or snowCleanLeft > 0.005

                        if anySnowDirtLeft then
                            wheelSurfaceKindMap[wheelIndex] = "snow"
                            wheelMudMap[wheelIndex] = math.max(wheelMudMap[wheelIndex] or 0, snowCleanLeft, 0.24)
                            st.__fgSnowWheelActive[wheelIndex] = true
                        else
                            st.__fgSnowWheelActive[wheelIndex] = false
                            st.__fgSnowWheelCleanLeft[wheelIndex] = nil
                        end
                    elseif effMudWheel >= minMud or isSnowWheel then
                    local prof = wheelProfMap[wheelIndex]
                    local profDirtMul = (prof ~= nil and prof.dirtMul) or 1.0
                    local baseEff = effMudWheel
                    local dirtMul = profDirtMul
                    if isSnowWheel then
                        baseEff = math.max(baseEff, 0.36)
                        dirtMul = 1.0
                    end
                    local wheelAdd = (self.dirtWheelPerSec or 0.085) * baseEff * dirtMul * speedFactor * dtS

                    if wheelAdd > 0 then
                        local wheelNodeAdd = wheelAdd
                        local wheelMudMeshAdd = wheelAdd
                        if isSnowWheel then
                            wheelMudMeshAdd = snowMudMeshCanStick and wheelAdd or 0
                        end

                        if nd ~= nil and nd.dirtAmount ~= nil and wheelNodeAdd > 0 then
                            vehicle:setNodeDirtAmount(nd, clamp((nd.dirtAmount or 0) + wheelNodeAdd, 0, dirtMax), true)
                            w.forceWheelDirtUpdate = true
                        end

                        if ndMud ~= nil and ndMud.dirtAmount ~= nil and wheelMudMeshAdd > 0 then
                            vehicle:setNodeDirtAmount(ndMud, clamp((ndMud.dirtAmount or 0) + wheelMudMeshAdd, 0, dirtMax), true)
                            w.forceWheelDirtUpdate = true
                        end
                    end

                    if isSnowWheel then
                        local wheelDirtAmount = (nd ~= nil and nd.dirtAmount ~= nil) and (nd.dirtAmount or 0) or 0
                        local mudDirtAmount = (ndMud ~= nil and ndMud.dirtAmount ~= nil) and (ndMud.dirtAmount or 0) or 0
                        st.__fgSnowWheelActive[wheelIndex] = true
                        st.__fgSnowWheelCleanLeft[wheelIndex] = math.max(st.__fgSnowWheelCleanLeft[wheelIndex] or 0, wheelDirtAmount, mudDirtAmount, wheelMudMap[wheelIndex] or 0, 0.24)
                    else
                        st.__fgSnowWheelActive[wheelIndex] = false
                        st.__fgSnowWheelCleanLeft[wheelIndex] = nil
                    end

                    if isSnowWheel and (not snowMudMeshCanStick) and w.wheelMudMeshes ~= nil then
                        if ndMud ~= nil and ndMud.dirtAmount ~= nil and (ndMud.dirtAmount or 0) > 0 then
                            local removeStep = snowMudMeshMeltPerSec * dtS
                            vehicle:setNodeDirtAmount(ndMud, clamp((ndMud.dirtAmount or 0) - removeStep, 0, dirtMax), true)
                            w.forceWheelDirtUpdate = true
                        end
                    end
                    end
                end
            end
        end

    end

    if self.dirtColorEnable then
        local st = getState(vehicle)
        local nowMs = g_time or 0
        local intervalMs = math.max(40, self.dirtColorUpdateIntervalMs or 150)
        st.__fgDirtColorLastMs = st.__fgDirtColorLastMs or -1000000
        if (nowMs - st.__fgDirtColorLastMs) >= intervalMs then
            local visualDtS = math.max(dtS, (nowMs - st.__fgDirtColorLastMs) * 0.001)
            if st.__fgDirtColorLastMs < 0 then
                visualDtS = dtS
            end
            st.__fgDirtColorLastMs = nowMs
            self:updateVehicleDirtColor(vehicle, visualDtS, sKph, effMudBody, wheelMudMap, bodySurfaceKind, wheelSurfaceKindMap)
        end
    end
end

-- =========================================================
-- MOTOR LOAD
-- =========================================================
function FieldGroundMudPhysics:applyMotorLoad(vehicle, effMud, slip, prof)
    if not self.motorLoadEnable then return end

    local spec = vehicle.spec_motorized
    if spec == nil or spec.motor == nil or spec.motor.setExternalTorqueVirtualMultiplicator == nil then
        return
    end

    local st = getState(vehicle)
    st.__fgAppliedMotorLoad = st.__fgAppliedMotorLoad or false

    if st.origExtTorqueMult == nil then
        st.origExtTorqueMult = 1
    end

    local mulProf = (prof ~= nil and prof.motorMul) or 1.0

    if effMud <= (self.motorLoadMinEff or 0) then
        if st.__fgAppliedMotorLoad then
            spec.motor:setExternalTorqueVirtualMultiplicator(st.origExtTorqueMult)
            st.lastAppliedMotorLoad = st.origExtTorqueMult
            st.__fgAppliedMotorLoad = false
        end
        return
    end

    local mult = 1
        + (effMud * mulProf) * (st.sink * (self.motorLoadFromSink or 0))
        + (effMud * mulProf) * (slip * (self.motorLoadFromSlip or 0))

    if st.stuck then
        mult = self.motorLoadMaxMult or mult
    end

    mult = clamp(mult, 1, self.motorLoadMaxMult or 5)

    if math.abs((st.lastAppliedMotorLoad or 1) - mult) > 0.01 then
        spec.motor:setExternalTorqueVirtualMultiplicator(mult)
        st.lastAppliedMotorLoad = mult
    end

    st.__fgAppliedMotorLoad = true
end

-- =========================================================
-- EXTRA PARTICLES
-- =========================================================
function FieldGroundMudPhysics:loadExtraParticleReferences()
    if not self.extraParticlesEnable then return end

    if MudExtraParticlePresets ~= nil then
        MudExtraParticlePresets.applyFieldGroundMudPhysics(self)
    end

    self._extraPS = self._extraPS or {}

    if self._extraPS.referencePS ~= nil and self._extraPS.referenceShape ~= nil then
        return
    end

    local psFile = Utils.getFilename(self.extraParticlesI3D, self.path)
    local root = loadI3DFile(psFile, false, false, false)
    if root == nil or root == 0 then
        print(string.format("[FieldGroundMudPhysics] ExtraPS: failed to load '%s'", tostring(psFile)))
        return
    end

    local psNode = I3DUtil.indexToObject(root, "0")
    self._extraPS.sourcePsRoot = root
    if psNode == nil then
        print(string.format("[FieldGroundMudPhysics] ExtraPS: node '0' not found in '%s'", tostring(psFile)))
        mpSafeDelete(root)
        return
    end

    local ps = {}
    ParticleUtil.loadParticleSystemFromNode(psNode, ps, false, true, false)
    link(getRootNode(), ps.shape)
    setClipDistance(ps.shape, self.extraParticleClipDist or 90)
    if ps.geometry ~= nil then
        setMaxNumOfParticles(ps.geometry, self.extraParticleMaxCount or 900)
    end
    self._extraPS.referencePS = ps

    local shapePath = Utils.getFilename(self.extraEmitterShapeI3D, self.path)
    local shapeRoot = loadSharedI3DFile(shapePath, false, false)
    if shapeRoot == nil or shapeRoot == 0 then
        print(string.format("[FieldGroundMudPhysics] ExtraPS: failed to load emitterShape '%s'", tostring(shapePath)))
        return
    end

    local refShape = getChildAt(shapeRoot, 0)
    if refShape == nil then
        print(string.format("[FieldGroundMudPhysics] ExtraPS: emitterShape child 0 missing '%s'", tostring(shapePath)))
        mpSafeDelete(shapeRoot)
        return
    end

    link(getRootNode(), refShape)
    setClipDistance(refShape, self.extraParticleClipDist or 90)
    self._extraPS.sourceShapeRoot = shapeRoot
    self._extraPS.referenceShape = refShape
end

local function fgGetRootNodeForWheel(vehicle, wheel)
    if vehicle == nil then
        return nil
    end

    if wheel ~= nil and wheel.node ~= nil and vehicle.vehicleNodes ~= nil then
        local vNode = vehicle.vehicleNodes[wheel.node]
        if vNode ~= nil and vNode.component ~= nil and vNode.component.node ~= nil and vNode.component.node ~= 0 then
            return vNode.component.node
        end
    end

    if vehicle.getRootNode ~= nil then
        local rn = vehicle:getRootNode()
        if rn ~= nil and rn ~= 0 then
            return rn
        end
    end

    if vehicle.components ~= nil and vehicle.components[1] ~= nil then
        local n = vehicle.components[1].node
        if n ~= nil and n ~= 0 then
            return n
        end
    end

    return vehicle.rootNode
end

local function fgWheelKey(wheel)
    if wheel ~= nil then
        return wheel
    end
    return "nilWheel"
end

local function ensureWheelExtraPS(vehicle, wheelRef)
    FieldGroundMudPhysics.vehicleState = FieldGroundMudPhysics.vehicleState or setmetatable({}, { __mode="k" })
    local st = FieldGroundMudPhysics.vehicleState[vehicle]
    if st == nil then
        st = { extraPS = {} }
        FieldGroundMudPhysics.vehicleState[vehicle] = st
    end
    st.extraPS = st.extraPS or {}

    local key = fgWheelKey(wheelRef)
    local entry = st.extraPS[key]
    if entry ~= nil and entry.ps ~= nil and entry.emitter ~= nil then
        return entry
    end

    if FieldGroundMudPhysics._extraPS == nil
        or FieldGroundMudPhysics._extraPS.referencePS == nil
        or FieldGroundMudPhysics._extraPS.referenceShape == nil then
        return nil
    end

    local rootNode = fgGetRootNodeForWheel(vehicle, wheelRef)
    if rootNode == nil or rootNode == 0 then
        return nil
    end

    local emitter = createTransformGroup("fgExtraMudEmitter")
    link(rootNode, emitter)

    local emitterShape = clone(FieldGroundMudPhysics._extraPS.referenceShape, true, false, true)
    link(emitter, emitterShape)

    local psClone = clone(FieldGroundMudPhysics._extraPS.referencePS.shape, true, false, true)
    link(emitter, psClone)

    local psTbl = {}
    ParticleUtil.loadParticleSystemFromNode(psClone, psTbl, false, true, false)
    ParticleUtil.setEmitterShape(psTbl, emitterShape)
    ParticleUtil.setEmittingState(psTbl, false)
    psTbl.isActive = false

    entry = { emitter=emitter, ps=psTbl, __fgRootNode=rootNode }
    st.extraPS[key] = entry
    return entry
end

local function updateWheelExtraPS(vehicle, wheelRef, isActive, wx, wy, wz, liftY)
    local entry = ensureWheelExtraPS(vehicle, wheelRef)
    if entry == nil then return end

    local rootNode = fgGetRootNodeForWheel(vehicle, wheelRef)
    if rootNode ~= nil and rootNode ~= 0 then
        if entry.__fgRootNode ~= rootNode then
            link(rootNode, entry.emitter)
            entry.__fgRootNode = rootNode
        end

        if wx ~= nil then
            local signedKph = fgGetSignedForwardSpeedKph(rootNode)
            local deadKph = FieldGroundMudPhysics.extraParticleOffsetMoveDeadzoneKph or 0.35
            local speed01 = 0
            local moveX = 0
            local moveZ = 0
            if math.abs(signedKph) > deadKph then
                speed01 = clamp(math.abs(signedKph) / math.max(0.1, FieldGroundMudPhysics.extraParticleOffsetMoveKphFull or 12.0), 0, 1)
                moveX, moveZ = fgGetBurstDirectionLocal(rootNode, vehicle, wheelRef, (FieldGroundMudPhysics.extraParticleOffsetMoveMax or 0.24) * speed01)
            end

            local burstY = 0
            local burstX = 0
            local burstZ = 0
            local burstSlip01 = 0
            local wp = (wheelRef ~= nil and wheelRef.physics ~= nil) and wheelRef.physics or nil
            if FieldGroundMudPhysics.extraParticleBurstEnable and wp ~= nil then
                wp.__fgExtraBurstT = wp.__fgExtraBurstT or 0
                if wp.__fgExtraBurstT > 0 then
                    local slip = (wp.netInfo ~= nil and wp.netInfo.slip) or wp.slip or 0
                    local slipMin = FieldGroundMudPhysics.extraParticleBurstSlipMin or 0.15
                    local slipFull = math.max(slipMin + 0.01, FieldGroundMudPhysics.extraParticleBurstSlipFull or 0.65)
                    burstSlip01 = clamp((slip - slipMin) / (slipFull - slipMin), 0, 1)

                    local upMul = (FieldGroundMudPhysics.extraParticleBurstUpMul or 0.75) * (1 + burstSlip01 * (FieldGroundMudPhysics.extraParticleBurstUpSlipMul or 0.55))
                    local fMul  = (FieldGroundMudPhysics.extraParticleBurstForwardMul or 0.42) * (1 + burstSlip01 * (FieldGroundMudPhysics.extraParticleBurstForwardSlipMul or 1.00))
                    local burstSpeed01 = clamp(math.abs(signedKph) / math.max(0.1, FieldGroundMudPhysics.extraParticleBurstSpeedFullKph or 20.0), 0, 1)
                    local baseMag = math.sqrt(moveX * moveX + moveZ * moveZ)
                    if baseMag < 0.001 then
                        moveX, moveZ = fgGetBurstDirectionLocal(rootNode, vehicle, wheelRef, (FieldGroundMudPhysics.extraParticleOffsetMoveMax or 0.24) * (0.55 + burstSlip01 * 0.75))
                        baseMag = math.sqrt(moveX * moveX + moveZ * moveZ)
                    end

                    burstY = (FieldGroundMudPhysics.extraParticleBurstYOffset or 0.24) * upMul
                    if baseMag > 0.0001 then
                        local mul = fMul * math.max(0.65, burstSpeed01)
                        burstX = moveX * mul
                        burstZ = moveZ * mul
                    end
                end
            end

            local lx, ly, lz = worldToLocal(rootNode, wx, wy, wz)
            lx = lx + moveX + burstX
            ly = ly + (FieldGroundMudPhysics.extraParticleOffsetY or -0.24) + (liftY or 0)
            lz = lz + moveZ + burstZ
            ly = ly + burstY
            setTranslation(entry.emitter, lx, ly, lz)
        end
    end

    local ps = entry.ps
    if ps ~= nil then
        ps.__fgBurstOrigEmit = ps.__fgBurstOrigEmit or 1
        ps.__fgBurstOrigParticleSpeed = ps.__fgBurstOrigParticleSpeed or ps.particleSpeed
        ps.__fgBurstOrigParticleRandomSpeed = ps.__fgBurstOrigParticleRandomSpeed or ps.particleRandomSpeed

        local burstMul = 1
        if isActive and FieldGroundMudPhysics.extraParticleBurstEnable and wheelRef ~= nil and wheelRef.physics ~= nil then
            local wp = wheelRef.physics
            if (wp.__fgExtraBurstT or 0) > 0 then
                local slip = (wp.netInfo ~= nil and wp.netInfo.slip) or wp.slip or 0
                local slipMin = FieldGroundMudPhysics.extraParticleBurstSlipMin or 0.15
                local slipFull = math.max(slipMin + 0.01, FieldGroundMudPhysics.extraParticleBurstSlipFull or 0.65)
                local slip01 = clamp((slip - slipMin) / (slipFull - slipMin), 0, 1)
                burstMul = 1 + slip01 * (FieldGroundMudPhysics.extraParticleBurstSpeedMul or 0.85)

                if ParticleUtil.setEmitCountScale then
                    local emitMul = 1 + slip01 * (FieldGroundMudPhysics.extraParticleBurstEmitMul or 0.75)
                    ParticleUtil.setEmitCountScale(ps, (ps.__fgBurstOrigEmit or 1) * emitMul)
                end
            elseif ParticleUtil.setEmitCountScale then
                ParticleUtil.setEmitCountScale(ps, ps.__fgBurstOrigEmit or 1)
            end
        elseif ParticleUtil.setEmitCountScale then
            ParticleUtil.setEmitCountScale(ps, ps.__fgBurstOrigEmit or 1)
        end

        if ps.__fgBurstOrigParticleSpeed ~= nil then
            ps.particleSpeed = ps.__fgBurstOrigParticleSpeed * burstMul
        end
        if ps.__fgBurstOrigParticleRandomSpeed ~= nil then
            ps.particleRandomSpeed = ps.__fgBurstOrigParticleRandomSpeed * burstMul
        end

        if ps.isActive ~= isActive then
            ps.isActive = isActive
            ParticleUtil.setEmittingState(ps, isActive)
        end
    end
end

-- =========================================================
-- PHYSICS APPLY
-- =========================================================
function FieldGroundMudPhysics:applyFieldPhysics(vehicle, dt, mudBaseAvg, slipAvg, wetnessEff, bestGT, bestProf)
    local st = getState(vehicle)

    if self:isInShopPreview(vehicle) then
        if vehicle.spec_motorized ~= nil and vehicle.spec_motorized.motor ~= nil
            and vehicle.spec_motorized.motor.setExternalTorqueVirtualMultiplicator ~= nil then
            vehicle.spec_motorized.motor:setExternalTorqueVirtualMultiplicator(1)
        end
        st.sink = 0
        st.stuck = false
        st.ramp = 0
        return
    end

    local dtS = (dt or 0) * 0.001
    if dtS <= 0 then return end

    local wetK   = wetnessStrength(wetnessEff)
    local wetMul = (bestProf ~= nil and bestProf.wetMul) or 1.0

    local effMudRaw = clamp((mudBaseAvg or 0) * wetMul, 0, 1)
    local effMud    = effMudRaw * wetK

	local inSec  = math.max(0.05, self.rampInSec or 8.0)
	local outSec = math.max(0.05, self.rampOutSec or 3.0)
	local powK   = math.max(0.25, self.rampPow or 1.5)

	local rain01 = clamp(self._lastRain01 or 0, 0, 1)

	inSec  = inSec  * (1.0 - 0.45 * rain01)
	outSec = outSec * (1.0 + 0.80 * rain01)

	powK = powK * (1.0 - 0.18 * rain01)

	inSec  = math.max(0.35, inSec)
	outSec = math.max(0.55, outSec)
	powK   = math.max(0.80, powK)


    local inMud = effMud > 0.001
    if inMud then
        st.ramp = math.min(1, (st.ramp or 0) + dtS / inSec)
    else
        st.ramp = math.max(0, (st.ramp or 0) - dtS / outSec)
    end

    local ramp = (st.ramp or 0) ^ powK
    local effMudRamp = effMud * ramp

    local vSpeed = (vehicle.getLastSpeed and vehicle:getLastSpeed()) or vehicle.lastSpeedReal or 0
    local speedKph = mpsToKph(vSpeed)

    if self.applyFieldDirt ~= nil and st.__fgDirtTickMs ~= (g_time or 0) then
        st.__fgDirtTickMs = g_time or 0
        self:applyFieldDirt(vehicle, dt, wetnessEff, speedKph, ramp, mudBaseAvg, bestProf)
    end

    local sinkMul = (bestProf ~= nil and bestProf.sinkMul) or 1.0
    local widthBonus = fgGetVehicleWidthBonus(vehicle)

    local sinkIn  = (effMudRamp * sinkMul) * (0.30 + (slipAvg or 0)) * (self.sinkInSpeed or 1.0) * (1 - widthBonus)
    local sinkOut = (1 - effMudRamp) * (speedKph / 25) * (self.sinkOutSpeed or 0.35)

    st.sink = clamp((st.sink or 0) + dtS * (sinkIn - sinkOut), 0, 1)
    st.stuck = (st.sink or 0) >= (self.stuckThreshold or 0.82)

    local motor = nil
    if vehicle.getMotor ~= nil then motor = vehicle:getMotor() end
    if motor == nil and vehicle.spec_motorized ~= nil then motor = vehicle.spec_motorized.motor end

    st.__fgAppliedSpeedLimit = st.__fgAppliedSpeedLimit or false
    st.__fgSpeedLimitCaptured = st.__fgSpeedLimitCaptured or false

    if motor ~= nil and motor.setSpeedLimit ~= nil then
        local wantMudLogic = (wetK > 0) and (effMudRamp > 0.001)

        local profMudLimitKph = (bestProf ~= nil) and bestProf.maxSpeedMudKph or nil

        if wantMudLogic and not st.__fgSpeedLimitCaptured then
            local orig = motor.speedLimit
            if type(orig) ~= "number" then
                orig = math.huge
            end
            st.__fgOrigMotorSpeedLimit = orig
            st.__fgSpeedLimitCaptured = true
        end

        if wantMudLogic then
            local wantLimitKph = nil

            if st.stuck then
                wantLimitKph = (self.maxSpeedStuckKph or 3.5)

            elseif type(profMudLimitKph) == "number" and profMudLimitKph > 0 then
                wantLimitKph = profMudLimitKph * (1 - (st.sink or 0) * 0.75)

            else
                local base = self.maxSpeedMudKph or 14.0
                wantLimitKph = base * (1 - (st.sink or 0) * 0.60)
            end

            if type(wantLimitKph) == "number" and wantLimitKph > 0 then
                local wantLimitMps = kphToMps(wantLimitKph)

                local curLimitMps = motor.speedLimit
                if type(curLimitMps) ~= "number" then curLimitMps = math.huge end

                if wantLimitMps < (curLimitMps - 0.05) then
                    motor:setSpeedLimit(wantLimitMps)
                end

                st.__fgAppliedSpeedLimit = true
            else
                if st.__fgAppliedSpeedLimit then
                    motor:setSpeedLimit((type(st.__fgOrigMotorSpeedLimit) == "number") and st.__fgOrigMotorSpeedLimit or math.huge)
                    st.__fgAppliedSpeedLimit = false
                    st.__fgSpeedLimitCaptured = false
                    st.__fgOrigMotorSpeedLimit = nil
                end
            end
        else
            if st.__fgAppliedSpeedLimit then
                motor:setSpeedLimit((type(st.__fgOrigMotorSpeedLimit) == "number") and st.__fgOrigMotorSpeedLimit or math.huge)
                st.__fgAppliedSpeedLimit = false
            end
            st.__fgSpeedLimitCaptured = false
            st.__fgOrigMotorSpeedLimit = nil
        end
    end

    self:applyMotorLoad(vehicle, effMudRamp, slipAvg or 0, bestProf)

    if self.debug then
        local gtStr = (bestGT ~= nil) and tostring(bestGT) or "nil"
        local pn = (bestProf ~= nil and bestProf.name) or "none"
        print(string.format("[FieldGroundMudPhysics] gt=%s(%s) wet=%.2f wetK=%.2f mud=%.2f effRaw=%.2f eff=%.2f ramp=%.2f sink=%.2f slip=%.2f stuck=%s",
            gtStr, pn, wetnessEff or 0, wetK, mudBaseAvg or 0, effMudRaw or 0, effMudRamp or 0, ramp, st.sink or 0, slipAvg or 0, tostring(st.stuck)))
    end
end

-- =========================================================
-- HOOKS
-- =========================================================
function FieldGroundMudPhysics.installHooks()
    if Utils == nil or Utils.overwrittenFunction == nil then
        print("[FieldGroundMudPhysics] Utils not found"); return
    end

    if WheelEffects ~= nil and WheelEffects.updateTick ~= nil then
    WheelEffects.updateTick = Utils.overwrittenFunction(
        WheelEffects.updateTick,
        function(self, superFunc, dt, groundWetness, currentUpdateDistance)
			if self ~= nil and self.vehicle ~= nil and FieldGroundMudPhysics:isVehicleResetting(self.vehicle) then
				if superFunc ~= nil then superFunc(self, dt, groundWetness, currentUpdateDistance) end
				return
			end
			
            if not FieldGroundMudPhysics.enabled or self == nil or self.vehicle == nil or self.wheel == nil then
                if superFunc ~= nil then superFunc(self, dt, groundWetness, currentUpdateDistance) end
                return
            end

            -- shop preview: vanilla only
            if FieldGroundMudPhysics:isInShopPreview(self.vehicle) then
                if superFunc ~= nil then superFunc(self, dt, groundWetness, currentUpdateDistance) end
                return
            end

			local wetnessEff = computeWetnessEffective(groundWetness, dt)

			-- HARD WINTER FREEZE: в мороз имитируем "влажность = 0" ВЕЗДЕ (и для нашей логики, и для ванили)
			local hardWinterFrozen = (FieldGroundMudPhysics.isHardWinterFrozen ~= nil and FieldGroundMudPhysics:isHardWinterFrozen())
			if hardWinterFrozen then
				wetnessEff = 0
			end

			do
				if g_client ~= nil then
					local stV = getState(self.vehicle)
					local timeMs = g_time or 0
					if stV.__fgVehTick ~= timeMs then
						stV.__fgVehTick = timeMs

						local mudAvg, slipAvg, bestGT2, bestProf2 = FieldGroundMudPhysics:computeVehicleFieldMudCached(self.vehicle)
						FieldGroundMudPhysics:applyFieldPhysics(self.vehicle, dt, mudAvg, slipAvg, wetnessEff, bestGT2, bestProf2)
					end
				end
			end

			local wetnessFinal = math.max(groundWetness or 0, wetnessEff or 0)
			if hardWinterFrozen then
				wetnessFinal = 0
			end

			if superFunc ~= nil then
				superFunc(self, dt, wetnessFinal, currentUpdateDistance)
			end

            -- extraPS только когда колесо реально касается земли
            local hasRealContact = FieldGroundMudPhysics:hasWheelFieldGroundContact(self.wheel)

            local x, y, z = FieldGroundMudPhysics:getWheelContactPos(self.wheel)
            local mudBase, gt, prof = 0, nil, nil
            if x ~= nil then
                mudBase, gt, prof = FieldGroundMudPhysics:getProfileAtWorldPos(x, z)
            end

            local wetMud = hasRealContact
                and (prof ~= nil)
                and ((mudBase or 0) > 0.01)
                and (wetnessEff > (FieldGroundMudPhysics.wetnessThreshold or 0.12))

            -- extra particles (fxExtra=true)
            local hardWinterFrozen = (FieldGroundMudPhysics.isHardWinterFrozen ~= nil and FieldGroundMudPhysics:isHardWinterFrozen())

            if (not hardWinterFrozen) and FieldGroundMudPhysics.extraParticlesEnable and prof ~= nil and prof.fxExtra == true then
                if FieldGroundMudPhysics._extraPS == nil
                    or FieldGroundMudPhysics._extraPS.referencePS == nil
                    or FieldGroundMudPhysics._extraPS.referenceShape == nil then
                    FieldGroundMudPhysics:loadExtraParticleReferences()
                end

                local active = wetMud

                if active then
                    local vSpeed = (self.vehicle.getLastSpeed and self.vehicle:getLastSpeed()) or self.vehicle.lastSpeedReal or 0
                    local speedKph = mpsToKph(vSpeed)
                    local slip = (self.netInfo ~= nil and self.netInfo.slip) or self.slip or 0
                    if speedKph < (FieldGroundMudPhysics.extraParticleMinSpeedKph or 18.0) and slip < (FieldGroundMudPhysics.extraParticleBurstSlipMin or 0.15) then
                        active = false
                    end
                end

                if x ~= nil and updateWheelExtraPS ~= nil then
                    if active and FieldGroundMudPhysics.extraParticleBurstEnable and self.wheel ~= nil and self.wheel.physics ~= nil then
                        local wp = self.wheel.physics
                        local slip = (wp.netInfo ~= nil and wp.netInfo.slip) or wp.slip or 0
                        wp.__fgExtraBurstT = math.max(0, (wp.__fgExtraBurstT or 0) - (dt or 0) * 0.001)

                        if slip >= (FieldGroundMudPhysics.extraParticleBurstSlipMin or 0.15) then
                            local slipMin = FieldGroundMudPhysics.extraParticleBurstSlipMin or 0.15
                            local slipFull = math.max(slipMin + 0.01, FieldGroundMudPhysics.extraParticleBurstSlipFull or 0.65)
                            local slip01 = clamp((slip - slipMin) / (slipFull - slipMin), 0, 1)
                            local vSpeed = (self.vehicle.getLastSpeed and self.vehicle:getLastSpeed()) or self.vehicle.lastSpeedReal or 0
                            local speedKph = mpsToKph(vSpeed)
                            local speed01 = clamp(speedKph / math.max(0.1, FieldGroundMudPhysics.extraParticleBurstSpeedFullKph or 20.0), 0, 1)
                            local still01 = 1 - speed01
                            local chanceMul = 0.30
                                + slip01 * (FieldGroundMudPhysics.extraParticleBurstChanceSlipMul or 2.10)
                                + speed01 * (FieldGroundMudPhysics.extraParticleBurstChanceSpeedMul or 0.75)
                                + still01 * slip01 * (FieldGroundMudPhysics.extraParticleBurstChanceStillMul or 1.45)
                            local p = clamp((FieldGroundMudPhysics.extraParticleBurstChancePerSec or 0.25) * chanceMul * (dt or 0) * 0.001, 0, 0.85)

                            if (wp.__fgExtraBurstT or 0) <= 0 and math.random() < p then
                                local tMin = FieldGroundMudPhysics.extraParticleBurstTimeMin or 0.10
                                local tMax = FieldGroundMudPhysics.extraParticleBurstTimeMax or 0.18
                                wp.__fgExtraBurstT = tMin + (tMax - tMin) * math.random()
                            end
                        end
                    end

                    local liftY = 0
                    if self.wheel ~= nil and self.wheel.physics ~= nil then
                        liftY = FieldGroundMudPhysics:computeExtraParticleLiftY(self.wheel.physics, prof)
                    end
                    updateWheelExtraPS(self.vehicle, self.wheel, active, x, y or 0, z, liftY)
                end

            else
                -- если зима/мороз или профиль не подходит -> гарантированно гасим эмиттер
                local st = FieldGroundMudPhysics.vehicleState ~= nil and FieldGroundMudPhysics.vehicleState[self.vehicle] or nil
                if st ~= nil and st.extraPS ~= nil then
                    local entry = st.extraPS[self.wheel]
                    if entry ~= nil and entry.ps ~= nil then
                        entry.ps.isActive = false
                        ParticleUtil.setEmittingState(entry.ps, false)
                    end
                end
            end
        end
    )
    else
        print("[FieldGroundMudPhysics] WheelEffects not found (dedicated/server) - skipping particle hooks")
    end

	if WheelPhysics ~= nil and WheelPhysics.updateTireFriction ~= nil then
		WheelPhysics.updateTireFriction = Utils.overwrittenFunction(
			WheelPhysics.updateTireFriction,
			function(self, superFunc)

				if self ~= nil and self.vehicle ~= nil and FieldGroundMudPhysics:isVehicleResetting(self.vehicle) then
					self.__fgGripMul = nil
					if superFunc ~= nil then superFunc(self) end
					return
				end

				if self ~= nil and self.vehicle ~= nil and FieldGroundMudPhysics:isInShopPreview(self.vehicle) then
					self.__fgGripMul = nil
					if superFunc ~= nil then superFunc(self) end
					return
				end

				local hadScale = (self ~= nil and self.frictionScale ~= nil)
				local oldScale = self.frictionScale

				if self ~= nil and self.vehicle ~= nil and self.vehicle.isAddedToPhysics then
					local mul = self.__fgGripMul
					if type(mul) ~= "number" then mul = 1.0 end
					mul = clamp(mul, 0.05, 5.0)

					local base = oldScale
					if type(base) ~= "number" then base = 1.0 end

					self.frictionScale = base * mul
				end

				if superFunc ~= nil then
					superFunc(self)
				end

				if hadScale then
					self.frictionScale = oldScale
				else
					self.frictionScale = nil
				end
			end
		)
	end

    if WheelPhysics ~= nil and WheelPhysics.updatePhysics ~= nil then
        WheelPhysics.updatePhysics = Utils.overwrittenFunction(
            WheelPhysics.updatePhysics,
            function(self, superFunc, brakeForce, torque)
				if self ~= nil and self.vehicle ~= nil and FieldGroundMudPhysics:isVehicleResetting(self.vehicle) then
					self.__fgMudBrakeExtra = 0
					if superFunc ~= nil then superFunc(self, brakeForce, torque) end
					return
				end

                if self ~= nil and self.vehicle ~= nil and FieldGroundMudPhysics:isInShopPreview(self.vehicle) then
                    if superFunc ~= nil then superFunc(self, brakeForce, torque) end
                    return
                end

                local extra = 0
                if FieldGroundMudPhysics.enabled and FieldGroundMudPhysics.wheelBrakeEnable and self ~= nil then
                    extra = self.__fgMudBrakeExtra or 0
                end

                if superFunc ~= nil then
                    superFunc(self, (brakeForce or 0) + extra, torque)
                end
            end
        )
    end

	if WheelPhysics ~= nil and WheelPhysics.serverUpdate ~= nil then
		WheelPhysics.serverUpdate = Utils.overwrittenFunction(
			WheelPhysics.serverUpdate,
			function(self, superFunc, dt, currentUpdateIndex, groundWetness)

				if superFunc ~= nil then
					superFunc(self, dt, currentUpdateIndex, groundWetness)
				end

				if self == nil or self.vehicle == nil or self.wheel == nil then return end
				if g_server == nil then return end
				if not FieldGroundMudPhysics.enabled then

					self.__fgDesiredRadius = nil
					self.__fgMudCurExtra = 0
					self.__fgMudBrakeExtra = 0
					self.__fgBrakeCur = 0
					self.__fgGripMul = nil
					self.isFrictionDirty = true
					return
				end

				if FieldGroundMudPhysics:isInShopPreview(self.vehicle) or FieldGroundMudPhysics:isVehicleResetting(self.vehicle) then
					self.__fgDesiredRadius = nil
					self.__fgMudCurExtra = 0
					self.__fgMudBrakeExtra = 0
					self.__fgBrakeCur = 0
					self.__fgGripMul = nil
					self.isFrictionDirty = true
					return
				end

				local dtS = (dt or 0) * 0.001
				if dtS <= 0 then return end

				do
					local stV = getState(self.vehicle)
					local timeMs = g_time or 0
					if stV.__fgVehTickS ~= timeMs then
						stV.__fgVehTickS = timeMs
						local wetnessEff = computeWetnessEffective(groundWetness, dt)

						local mudAvg, slipAvg, bestGT2, bestProf2 = FieldGroundMudPhysics:computeVehicleFieldMudCached(self.vehicle)
						FieldGroundMudPhysics:applyFieldPhysics(self.vehicle, dt, mudAvg, slipAvg, wetnessEff, bestGT2, bestProf2)
					end
				end

				local eps = FieldGroundMudPhysics.freezeRadiusEps or 0.0015

				do
					local ro = self.radiusOriginal

					if (type(ro) ~= "number" or ro ~= ro or ro <= 0.05) and self.wheel ~= nil and self.wheel.physics ~= nil then
						ro = self.wheel.physics.radiusOriginal or self.wheel.physics.radius
					end

					if type(ro) == "number" and ro == ro and ro > 0.05 then
						if self.__fgOrigRadius == nil or ro > self.__fgOrigRadius then
							self.__fgOrigRadius = ro
						end
					else
						local cur = self.radius
						if type(cur) == "number" and cur == cur and cur > 0.05 then
							if self.__fgOrigRadius == nil or cur > self.__fgOrigRadius then
								self.__fgOrigRadius = cur
							end
						end
					end
				end

				local r0 = self.__fgOrigRadius
				if type(r0) ~= "number" or r0 ~= r0 or r0 <= 0.05 then return end

				local wx, _, wz = FieldGroundMudPhysics:getWheelContactPos(self.wheel)
				if wx == nil then
					-- НЕ обнуляем резко: делаем плавное восстановление по outSpeed
					local dtS = (dt or 0) * 0.001
					local cur = self.__fgMudCurExtra or 0

					if cur > 0 and dtS > 0 then
						local outSpd =
							FieldGroundMudPhysics.radiusSinkOutSpeed
							or FieldGroundMudPhysics.radiusRecoverOutOfMudSpeed
							or FieldGroundMudPhysics.sinkOutSpeed
							or 0.85


						cur = math.max(0, cur - outSpd * dtS)
						self.__fgMudCurExtra = cur

						if cur > 0 then
							self.__fgDesiredRadius = clamp(r0 - cur, 0.12, r0)
						else
							self.__fgDesiredRadius = nil
						end

						if _G.__MudRadiusCombiner ~= nil and _G.__MudRadiusCombiner.apply ~= nil then
							_G.__MudRadiusCombiner.apply(self, eps)
						end
					else
						self.__fgDesiredRadius = nil
					end
					return
				end


				local wetnessEff = computeWetnessEffective(groundWetness, dt)

				local hardWinterFrozen = (FieldGroundMudPhysics.isHardWinterFrozen ~= nil and FieldGroundMudPhysics:isHardWinterFrozen())
				if hardWinterFrozen then
					wetnessEff = 0
				end
				
				local mudBase, gt, prof = FieldGroundMudPhysics:getProfileAtWorldPos(wx, wz)
				mudBase = mudBase or 0

				local onMudProfile = (prof ~= nil) and (mudBase > 0.01)
				local stV = getState(self.vehicle)
				local timeMs = g_time or 0
				fgUpdatePermaReleaseState(stV, self.vehicle, timeMs)


				local slip = 0
				if self.netInfo ~= nil then
					slip = self.netInfo.slip or 0
				end
				local vSpeed = (self.vehicle.getLastSpeed and self.vehicle:getLastSpeed()) or self.vehicle.lastSpeedReal or 0
				local speedKph = mpsToKph(vSpeed)

				do
					local gripMul = nil
					if onMudProfile and prof ~= nil and prof.slip ~= nil then
						local mn = FieldGroundMudPhysics.slipMinMul or 0.45
						local mx = FieldGroundMudPhysics.slipMaxMul or 0.95
						gripMul = clamp(prof.slip, mn, mx)
					end
					if self.__fgGripMul ~= gripMul then
						self.__fgGripMul = gripMul
						self.isFrictionDirty = true
					end
				end

				if not FieldGroundMudPhysics.radiusSinkEnable then
					self.__fgDesiredRadius = nil
					self.__fgMudCurExtra = 0
					if _G.__MudRadiusCombiner ~= nil and _G.__MudRadiusCombiner.apply ~= nil then
						_G.__MudRadiusCombiner.apply(self, eps)
					end
					return
				end

				local widthBonus = fgGetWheelWidthBonus(self)
				local minFactor = FieldGroundMudPhysics.radiusMinFactor or 0.58
				if onMudProfile and prof ~= nil and prof.radiusMinFactor ~= nil then
					minFactor = prof.radiusMinFactor
				end
				minFactor = minFactor + (1 - minFactor) * widthBonus
				minFactor = clamp(minFactor, 0.12, 0.98)

				local minRadius = math.max(0.12, r0 * minFactor)
				local maxExtra  = math.max(0, r0 - minRadius)

				local wetK = wetnessStrength(wetnessEff)

				local effMudRaw = 0
				if onMudProfile and prof ~= nil and wetK > 0 then
					local wetMul = (prof.wetMul or 1.0)
					effMudRaw = clamp(mudBase * wetMul, 0, 1)
				end

				local targetExtraMove = 0
				if onMudProfile and prof ~= nil and wetK > 0 then
					local sinkMul = prof.sinkMul or 1.0

					local moving01   = clamp(speedKph / 6.0, 0, 1)
					local slip01     = clamp((slip or 0) / 0.18, 0, 1)
					local moveFactor = math.max(moving01, slip01)

					-- ВАЖНО: насыщение до 1 делаем по "грязности/профилю",
					-- а wetK применяется ПОСЛЕ, чтобы 20% влажности = 20% силы даже при sinkMul=10
					local kRaw = effMudRaw * sinkMul * (0.10 + moveFactor * (0.85 + (stV.sink or 0) * 0.55)) * (1 - widthBonus)
					local k = clamp(kRaw, 0, 1) * wetK

					targetExtraMove = maxExtra * k
					targetExtraMove = clamp(targetExtraMove, 0, maxExtra)
				end

				if (stV.permaCd or 0) > 0 then
					stV.permaCd = math.max(0, (stV.permaCd or 0) - dtS)
				end

				
				do
					if FieldGroundMudPhysics.eraseFruitEnable
						and (wetnessEff or 0) >= (FieldGroundMudPhysics.wetnessThreshold or 0.12)
						and (speedKph or 0) >= (FieldGroundMudPhysics.eraseFruitSpeedMinKph or 1.0) then


						-- только если есть РЕАЛЬНЫЙ контакт
						local hasRealContact =
							(self.hasSoilContact == true)
							or (self.hasGroundContact == true)
							or (self.wheel ~= nil and self.wheel.physics ~= nil and self.wheel.physics.hasSoilContact == true)
							or (self.wheel ~= nil and self.wheel.physics ~= nil and self.wheel.physics.hasGroundContact == true)

						if hasRealContact then
							local nowMs = g_time or 0
							self.__fgEraseLastMs = self.__fgEraseLastMs or 0

							if (nowMs - self.__fgEraseLastMs) >= (FieldGroundMudPhysics.eraseFruitIntervalMs or 220) then
								self.__fgEraseLastMs = nowMs

								local x0, z0, x1, z1, x2, z2 = FieldGroundMudPhysics:getWheelDestructionParallelogram(self.wheel)
								if x0 ~= nil then
									-- 1) ваниль crushed
									if FieldGroundMudPhysics.eraseFruitDoVanillaWheelDestructionFirst
										and FSDensityMapUtil ~= nil
										and FSDensityMapUtil.updateWheelDestructionArea ~= nil then
										FSDensityMapUtil.updateWheelDestructionArea(x0, z0, x1, z1, x2, z2)
									end

									-- 2) жёсткий ноль (полное исчезновение урожая)
									FieldGroundMudPhysics:eraseFoliageToZero(x0, z0, x1, z1, x2, z2)
								end
							end
						end
					end
				end
				
				-- =========================================================
                -- STOP RECOVER LIMIT (anti "pushing out" when standing still)
                -- =========================================================
                local stopped = false
                do
                    if FieldGroundMudPhysics.freezeRadiusWhenStopped then
                        local spdStop = FieldGroundMudPhysics.freezeStopSpeedKph or 0.60
                        local slipStop = FieldGroundMudPhysics.freezeStopSlip or 0.010
                        if (speedKph or 0) <= spdStop and (slip or 0) <= slipStop then
                            stopped = true
                        end
                    end
                end

                if stopped and onMudProfile then
                    self.__fgStillT = (self.__fgStillT or 0) + dtS
                    if self.__fgStopAnchorExtra == nil then
                        self.__fgStopAnchorExtra = self.__fgMudCurExtra or 0
                    end
                else
                    self.__fgStillT = 0
                    self.__fgStopAnchorExtra = nil
                end

                if stopped and onMudProfile and self.__fgStopAnchorExtra ~= nil then
                    local maxRec = FieldGroundMudPhysics.stopRecoverRadiusMaxM or 0.002
                    if maxRec > 0.002 then maxRec = 0.002 end
                    if maxRec < 0 then maxRec = 0 end

                    local minExtra = math.max(0, (self.__fgStopAnchorExtra or 0) - maxRec)

                    if targetExtraMove < minExtra then
                        targetExtraMove = minExtra
                    end
                end

				local permaForcedTarget = nil
				local permaReleaseBlend = 0
				if stV.permaActive == true then
					local mudWheelCount = fgCountMudWheels(self.vehicle)
					if mudWheelCount <= 0 then
						stV.permaActive = false
						stV.permaTargetExtra = 0
						stV.permaStuckToken = 0
						stV.permaNotifyToken = 0
						fgResetPermaReleaseState(stV, self.vehicle)
					else
                        local releaseStart = math.max(0, FieldGroundMudPhysics.permaStuckReleaseDistanceStartM or 6.0)
                        local releaseFull = math.max(releaseStart + 0.01, FieldGroundMudPhysics.permaStuckReleaseDistanceFullM or 8.0)
						local releaseDist = stV.permaReleaseDist or 0
						permaReleaseBlend = clamp((releaseDist - releaseStart) / (releaseFull - releaseStart), 0, 1)

						if permaReleaseBlend >= 0.999 then
							stV.permaActive = false
							stV.permaTargetExtra = 0
							stV.permaStuckToken = 0
							stV.permaNotifyToken = 0
							fgResetPermaReleaseState(stV, self.vehicle)
						elseif ((FieldGroundMudPhysics.permaStuckSpreadOnlyInMud ~= true) or onMudProfile) then
							local factor = clamp(FieldGroundMudPhysics.permaStuckRadiusFactor or 0.40, 0.05, 0.95)
							local forcedRadius = math.max(0.12, r0 * factor)
							permaForcedTarget = math.max(0, r0 - forcedRadius)
						end
					end
				end

				if FieldGroundMudPhysics.permaStuckEnable
					and stV.permaActive ~= true
					and onMudProfile
					and prof ~= nil
					and prof.permaStuck == true
				then
					local mudWheelCount = fgCountMudWheels(self.vehicle)
					local struggling =
						((slip or 0) >= (FieldGroundMudPhysics.permaStuckSlipMin or 0.12))
						and ((stV.sink or 0) >= (FieldGroundMudPhysics.permaStuckSinkMin or 0.38))
						and (mudWheelCount >= (FieldGroundMudPhysics.permaStuckMinWheelsInMud or 2))

					if not stopped and struggling and (stV.permaCd or 0) <= 0 and dtS > 0 then
						local chancePerSec = FieldGroundMudPhysics.permaStuckChanceOnStruggle or 0
						if chancePerSec > 0 then
							local p = clamp(chancePerSec * dtS, 0, 0.25)
							if math.random() < p then
								local cMin = FieldGroundMudPhysics.permaStuckCooldownMin or 45.0
								local cMax = FieldGroundMudPhysics.permaStuckCooldownMax or 120.0
								stV.permaActive = true
								stV.permaCd = cMin + (cMax - cMin) * math.random()
								stV.permaTargetExtra = 0
								stV.permaStuckToken = (stV.permaStuckToken or 0) + 1
								fgResetPermaReleaseState(stV, self.vehicle)

								if MudPhysics ~= nil and MudPhysics.showPermaStuckNotification ~= nil then
									MudPhysics:showPermaStuckNotification(self.vehicle, stV)
								end

								permaForcedTarget = math.max(0, r0 - math.max(0.12, r0 * clamp(FieldGroundMudPhysics.permaStuckRadiusFactor or 0.40, 0.05, 0.95)))
								permaReleaseBlend = 0
							end
						end
					end
				end

				if permaReleaseBlend > 0 then
					local releaseSinkOut = (0.030 + 0.100 * permaReleaseBlend) * dtS
					stV.sink = math.max(0, (stV.sink or 0) - releaseSinkOut)
				end

				
				local cur = self.__fgMudCurExtra or 0
				local inSpd  = FieldGroundMudPhysics.radiusSinkInSpeed or 0.095
				local outSpd =
					(onMudProfile and (FieldGroundMudPhysics.radiusRecoverInMudSpeed or 0.02))
					or (FieldGroundMudPhysics.radiusSinkOutSpeed
						or FieldGroundMudPhysics.radiusRecoverOutOfMudSpeed
						or FieldGroundMudPhysics.sinkOutSpeed
						or 0.85)

				if permaForcedTarget ~= nil then
					targetExtraMove = permaForcedTarget + (targetExtraMove - permaForcedTarget) * permaReleaseBlend
					targetExtraMove = clamp(targetExtraMove, 0, math.max(maxExtra, permaForcedTarget))
					local outMul = math.max(1.0, (MudPhysics ~= nil and MudPhysics.permaStuckReleaseOutSpeedMul) or 1.85)
					local extraReleaseSpd = math.max(0.005, (permaForcedTarget or 0) * (0.045 + 0.11 * permaReleaseBlend))
					outSpd = outSpd * outMul + extraReleaseSpd
				end

				if stV.permaActive == true and permaForcedTarget ~= nil and permaReleaseBlend <= 0.0001 then
					local permaRampSec = math.max(0.05, FieldGroundMudPhysics.permaStuckRampInSec or 3.5)
					local permaStep = permaForcedTarget * (dtS / permaRampSec)
					if cur < permaForcedTarget then
						cur = math.min(permaForcedTarget, cur + permaStep)
					else
						cur = math.max(permaForcedTarget, cur)
					end
					self.__fgMudCurExtra = cur
					self.__fgDesiredRadius = clamp(r0 - cur, math.max(0.12, r0 - permaForcedTarget), r0)
					if _G.__MudRadiusCombiner ~= nil and _G.__MudRadiusCombiner.apply ~= nil then
						_G.__MudRadiusCombiner.apply(self, eps)
					end
					return
				end



				if targetExtraMove > cur then
					cur = math.min(targetExtraMove, cur + inSpd * dtS)
				else
					cur = math.max(targetExtraMove, cur - outSpd * dtS)
				end

				self.__fgMudCurExtra = cur

				if cur <= 0.00001 then
					self.__fgDesiredRadius = nil
				else
					self.__fgDesiredRadius = clamp(r0 - cur, minRadius, r0)
				end

				if _G.__MudRadiusCombiner ~= nil and _G.__MudRadiusCombiner.apply ~= nil then
					_G.__MudRadiusCombiner.apply(self, eps)
				end

			end
		)
	end

print("[FieldGroundMudPhysics] hooks installed (WheelEffects + WheelPhysics)")
end

-- =========================================================
-- MOD EVENTS
-- =========================================================
function FieldGroundMudPhysics:loadMap()
    if MudExtraParticlePresets ~= nil then
        MudExtraParticlePresets.applyFieldGroundMudPhysics(self)
    end

    self:loadExtraParticleReferences()
    self.installHooks()
	FieldGroundMudPhysics.installResetHooks()
	
	self:initEraseFoliageCache()
	
	if g_messageCenter ~= nil and MessageType ~= nil and MessageType.VEHICLE_RESET ~= nil then
		self._onVehicleReset = function(oldVehicle, newVehicle)
			if self.onVehicleReset ~= nil then
				self:onVehicleReset(oldVehicle, newVehicle)
			end
		end
		g_messageCenter:subscribe(MessageType.VEHICLE_RESET, self._onVehicleReset)
	end
	
	if g_server ~= nil or g_client ~= nil then
		if FieldGroundWheelRadiusSyncEvent ~= nil then
			if self.debug then
				print("[FieldGroundMudPhysics] FieldGroundWheelRadiusSyncEvent ready")
			end
		end
	end

	fgEnsureWheelIndices()
    addConsoleCommand("gsFieldMud", "FieldGroundMudPhysics on/off/debug", "consoleCommand", self)
end

function FieldGroundMudPhysics:deleteMap()
    removeConsoleCommand("gsFieldMud")

    if MudExtraParticlePresets ~= nil then
        MudExtraParticlePresets.clearCache(self)
    elseif self._extraPS ~= nil then
        if self._extraPS.referencePS ~= nil then
            mpSafeDelete(self._extraPS.referencePS.shape)
            self._extraPS.referencePS = nil
        end
        if self._extraPS.referenceShape ~= nil then
            mpSafeDelete(self._extraPS.referenceShape)
            self._extraPS.referenceShape = nil
        end
    end
	
	FieldGroundMudPhysics.deleteResetHooks()
	
	if g_messageCenter ~= nil and self._onVehicleReset ~= nil and MessageType ~= nil and MessageType.VEHICLE_RESET ~= nil then
		g_messageCenter:unsubscribe(MessageType.VEHICLE_RESET, self._onVehicleReset)
		self._onVehicleReset = nil
	end

end

function FieldGroundMudPhysics:consoleCommand(arg)
    if arg == "on" then
        self.enabled = true
    elseif arg == "off" then
        self.enabled = false
    elseif arg == "debug" then
        self.debug = not self.debug
    end

    return string.format("FieldGroundMudPhysics enabled=%s debug=%s",
        tostring(self.enabled), tostring(self.debug))
end
