-- 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.fieldBoundaryBlendInSec  = 1.25
FieldGroundMudPhysics.fieldBoundaryBlendOutSec = 1.75
FieldGroundMudPhysics.speedLimitRampFloor      = 0.0
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       -- режим отладки (логи в консоль)

-- ---------------------------------------------------------
-- ВЛАЖНОСТЬ: БОЛЕЕ РЕАЛИСТИЧНАЯ СВЯЗКА (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.legacySpeedResponseScale = 3.6 -- сохраняет прежнюю динамику sink при корректных единицах скорости
FieldGroundMudPhysics.stuckThreshold = 0.82 -- порог, после которого техника считается застрявшей

-- ---------------------------------------------------------
-- ОГРАНИЧЕНИЕ СКОРОСТИ В ГРЯЗИ
-- ---------------------------------------------------------
FieldGroundMudPhysics.maxSpeedMudKph   = 23.0 -- мягкая страховочная скорость на очень слабом поле
FieldGroundMudPhysics.maxSpeedStuckKph = 14.5 -- мягкий предел при глубоком зарывании
FieldGroundMudPhysics.terramechanicsEnterSec = 1.45
FieldGroundMudPhysics.terramechanicsExitSec  = 2.40
FieldGroundMudPhysics.motorLoadEnterSec      = 1.15
FieldGroundMudPhysics.motorLoadExitSec       = 1.95

-- ---------------------------------------------------------
-- НАГРУЗКА НА ДВИГАТЕЛЬ / ПОТЕРЯ МОЩНОСТИ
-- ---------------------------------------------------------
FieldGroundMudPhysics.motorLoadEnable   = true -- включить дополнительную нагрузку на двигатель
FieldGroundMudPhysics.motorLoadFromSink = 2.6  -- влияние глубины зарывания на нагрузку
FieldGroundMudPhysics.motorLoadFromSlip = 1.6  -- влияние пробуксовки на нагрузку
FieldGroundMudPhysics.motorLoadMaxMult  = 4.0 -- предел эквивалентной потери мощности (до 72%)
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 = 0.40  -- плавное снятие сопротивления после выезда с мягкого грунта

-- ---------------------------------------------------------
-- РЕАЛЬНОЕ УМЕНЬШЕНИЕ РАДИУСА КОЛЕСА (ПОГРУЖЕНИЕ В ГРУНТ)
-- ---------------------------------------------------------
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  -- масштаб накопления риска (0 отключает активацию)
FieldGroundMudPhysics.permaStuckSlipMin          = 0.22   -- заметная пробуксовка, ниже грунт ещё не срезается
FieldGroundMudPhysics.permaStuckSinkMin          = 0.68   -- сначала техника должна действительно глубоко сесть
FieldGroundMudPhysics.permaStuckWetnessMin       = 0.58   -- перманентная посадка возможна только в мокром грунте
FieldGroundMudPhysics.permaStuckContactShareMin  = 0.60   -- доля опорных колёс на слабом поле
FieldGroundMudPhysics.permaStuckMaxSpeedKph      = 9.0    -- на ходу риск быстро исчезает
FieldGroundMudPhysics.permaStuckBuildSec         = 22.0   -- длительная борьба даже в предельных условиях
FieldGroundMudPhysics.permaStuckDecaySec         = 10.0   -- память уже вырытой колеи
FieldGroundMudPhysics.permaStuckRestReleaseStartSec = 8.0 -- прекращение буксовки снимает искусственный захват
FieldGroundMudPhysics.permaStuckRestReleaseFullSec  = 16.0
FieldGroundMudPhysics.permaLuckyEscapeChance        = 0.12 -- иногда грунт уплотняется и колесо снова находит опору
FieldGroundMudPhysics.permaLuckyHoldMinSec          = 1.8  -- сначала застревание должно быть заметно
FieldGroundMudPhysics.permaLuckyHoldMaxSec          = 4.2
FieldGroundMudPhysics.permaLuckyReleaseSec          = 2.8  -- плавный возврат к состоянию перед срабатыванием
FieldGroundMudPhysics.permaStuckRadiusFactor     = 0.62   -- глубокая колея без провала колеса на 60%
FieldGroundMudPhysics.permaStuckCooldownMin      = 45.0   -- минимальный кулдаун (сек)
FieldGroundMudPhysics.permaStuckCooldownMax      = 120.0  -- максимальный кулдаун
FieldGroundMudPhysics.permaStuckRampInSec        = 6.0    -- время "затягивания"
FieldGroundMudPhysics.permaStuckRampOutSec       = 2.40   -- время выхода
FieldGroundMudPhysics.permaStuckReleaseDistanceStartM = 3.00 -- буксировка или самостоятельное движение начинают освобождение
FieldGroundMudPhysics.permaStuckReleaseDistanceFullM  = 5.00 -- после смещения возвращаем обычную физику глубокой колеи

-- ---------------------------------------------------------
-- РАСПРОСТРАНЕНИЕ ЗАСТРЕВАНИЯ НА ВСЮ МАШИНУ
-- ---------------------------------------------------------
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

-- ---------------------------------------------------------
-- НАЛИПАНИЕ ГРЯЗИ НА ТЕХНИКУ (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  -- усиление, если колесо без контакта

-- ---------------------------------------------------------
-- УДАЛЕНИЕ УРОЖАЯ ПОД КОЛЕСОМ В ГРЯЗИ
-- ---------------------------------------------------------
FieldGroundMudPhysics.eraseFruitEnable      = false
FieldGroundMudPhysics.eraseFoliageOnlyFields = true -- true = повреждаем урожай только на полях, false = везде
FieldGroundMudPhysics.eraseFruitMinWetness  = 0.12   -- минимум влажности, чтобы повреждать урожай
FieldGroundMudPhysics.eraseFruitIntervalMs  = 220    -- троттлинг на wheelPhysics
FieldGroundMudPhysics.eraseFruitSpeedMinKph = 1.0

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

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

-- =========================================================
-- CRAWLER / TRACKED VEHICLES
-- =========================================================
FieldGroundMudPhysics.crawlerMudSupportEnable       = true
FieldGroundMudPhysics.crawlerMudSinkMul             = 0.66
FieldGroundMudPhysics.crawlerMudWheelSinkMul        = 0.70
FieldGroundMudPhysics.crawlerMudRadiusMinBoost      = 0.12
FieldGroundMudPhysics.crawlerMudBrakeMul            = 0.36
FieldGroundMudPhysics.crawlerMudMotorLoadMul        = 0.50
FieldGroundMudPhysics.crawlerMudSpeedLimitMul       = 1.55
FieldGroundMudPhysics.crawlerMudGripMul             = 1.18
FieldGroundMudPhysics.crawlerMudPermaStuckChanceMul = 0.12

-- =========================================================
-- 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.56, wetMul=1.02, sinkMul=5.60, brakeMul=4.25, motorMul=0.95, radiusMinFactor=0.67, dirtMul=0.96, slip=0.91, fxExtra=true, permaStuck=true },
    [2]  = { name="Cultivated",     mud=0.55, wetMul=1.15, sinkMul=5.65, brakeMul=4.30, motorMul=0.98, radiusMinFactor=0.68, dirtMul=1.10, slip=0.90, fxExtra=true, permaStuck=true },
    [3]  = { name="Seedbed",        mud=0.55, wetMul=1.00, sinkMul=5.55, brakeMul=4.20, motorMul=0.95, radiusMinFactor=0.67, dirtMul=0.94, slip=0.92, fxExtra=true, permaStuck=true },
    [4]  = { name="Plowed",         mud=0.64, wetMul=1.35, sinkMul=7.15, brakeMul=5.20, motorMul=1.24, radiusMinFactor=0.66, dirtMul=1.32, slip=0.94, fxExtra=true, permaStuck=true },
    [5]  = { name="RolledSeedbed",  mud=0.60, wetMul=0.96, sinkMul=5.35, brakeMul=4.05, motorMul=0.84, radiusMinFactor=0.72, dirtMul=0.72, slip=1.00, fxExtra=true, permaStuck=true },
    [6]  = { name="Ridge",          mud=0.55, wetMul=1.10, sinkMul=5.70, brakeMul=4.25, motorMul=0.99, radiusMinFactor=0.66, dirtMul=1.02, slip=0.95, fxExtra=true, permaStuck=true },
    [7]  = { name="Sown",           mud=0.58, wetMul=0.96, sinkMul=5.25, brakeMul=3.95, motorMul=0.80, radiusMinFactor=0.66, dirtMul=0.64, slip=1.02, fxExtra=true, permaStuck=true },
    [8]  = { name="DirectSown",     mud=0.60, wetMul=0.96, sinkMul=5.30, brakeMul=4.00, motorMul=0.82, radiusMinFactor=0.73, dirtMul=0.70, slip=1.00, fxExtra=true, permaStuck=true },
    [9]  = { name="Planted",        mud=0.56, wetMul=0.98, sinkMul=5.45, brakeMul=4.10, motorMul=0.88, radiusMinFactor=0.70, dirtMul=0.82, slip=1.02, fxExtra=true, permaStuck=true },
    [10] = { name="RidgeSown",      mud=0.56, wetMul=1.02, sinkMul=5.55, brakeMul=4.20, motorMul=0.94, radiusMinFactor=0.67, dirtMul=0.94, slip=1.00, fxExtra=true, permaStuck=true },
    [11] = { name="Rollerlines",    mud=0.61, wetMul=0.96, sinkMul=5.10, brakeMul=3.85, motorMul=0.74, radiusMinFactor=0.70, dirtMul=0.50, slip=1.02, fxExtra=true, permaStuck=true },
    [12] = { name="HarvestReady",   mud=0.55, wetMul=0.96, sinkMul=5.35, brakeMul=4.00, motorMul=0.86, radiusMinFactor=0.71, dirtMul=0.80, slip=0.97, fxExtra=true, permaStuck=true },
    [13] = { name="HarvestReadyO",  mud=0.61, wetMul=0.96, sinkMul=5.35, brakeMul=4.00, motorMul=0.84, radiusMinFactor=0.72, dirtMul=0.78, slip=0.98, fxExtra=true, permaStuck=true },
    [14] = { name="Grass",          mud=0.08, wetMul=0.82, sinkMul=4.90, brakeMul=3.60, motorMul=0.80, radiusMinFactor=0.90, dirtMul=0.74, slip=0.90, fxExtra=true, permaStuck=false },
    [15] = { name="GrassCut",       mud=0.08, wetMul=0.82, sinkMul=4.90, brakeMul=3.60, motorMul=0.80, radiusMinFactor=0.90, dirtMul=0.74, slip=0.90, fxExtra=true, permaStuck=false },
}

FieldGroundMudPhysics.terrainGroundValueToProfileIndex = {
    [1] = 1,   -- Stubble Tillage
    [2] = 2,   -- Cultivated
    [3] = 3,   -- Seedbed
    [4] = 4,   -- Plowed
    [5] = 5,   -- RolledSeedbed
    [6] = 7,   -- Sown
    [7] = 8,   -- Direct Sown
    [8] = 9,   -- Planted
    [9] = 6,   -- Ridge
    [10] = 11, -- Rollerlines
    [11] = 12, -- Harvest-Ready
    [12] = 13, -- Harvest-Ready Other
    [13] = 14, -- Grass
    [14] = 15  -- Grass (Cut)
}

FieldGroundMudPhysics.fieldGroundTypeProfileIndexByName = {
    STUBBLETILLAGE = 1,
    CULTIVATED = 2,
    SEEDBED = 3,
    PLOWED = 4,
    ROLLEDSEEDBED = 5,
    RIDGE = 6,
    SOWN = 7,
    DIRECTSOWN = 8,
    PLANTED = 9,
    RIDGESOWN = 10,
    ROLLERLINES = 11,
    HARVESTREADY = 12,
    HARVESTREADYO = 13,
    HARVESTREADYOTHER = 13,
    HARVESTREADYOTHERWINDROW = 13,
    GRASS = 14,
    GRASSCUT = 15
}

FieldGroundMudPhysics.fieldGroundTypeValueToProfileIndex = {
    [2] = 1,  -- STUBBLE_TILLAGE
    [3] = 2,  -- CULTIVATED
    [4] = 3,  -- SEEDBED
    [5] = 4,  -- PLOWED
    [6] = 5,  -- ROLLED_SEEDBED
    [7] = 6,  -- RIDGE
    [8] = 7,  -- SOWN
    [9] = 8,  -- DIRECT_SOWN
    [10] = 9, -- PLANTED
    [11] = 10, -- RIDGE_SOWN
    [12] = 11, -- ROLLER_LINES
    [13] = 12, -- HARVEST_READY
    [14] = 13, -- HARVEST_READY_OTHER
    [15] = 14, -- GRASS
    [16] = 15  -- GRASS_CUT
}

function FieldGroundMudPhysics:getGroundTypeNameByValue(value)
    if value == nil or FieldGroundType == nil then
        return nil
    end

    for name, v in pairs(FieldGroundType) do
        if type(name) == "string" and v == value then
            return name
        end
    end

    return nil
end

function FieldGroundMudPhysics:getProfileIndexForGroundTypeValue(value)
    if value == nil then
        return nil, nil
    end

    local name = self:getGroundTypeNameByValue(value)
    if name ~= nil then
        local normalized = string.upper(tostring(name)):gsub("[^A-Z0-9]", "")
        local mapped = self.fieldGroundTypeProfileIndexByName[normalized]
        if mapped ~= nil then
            return mapped, name
        end
    end

    return self.fieldGroundTypeValueToProfileIndex[value], name
end

function FieldGroundMudPhysics:getProfileByFieldGroundTypeValue(value)
    local idx, name = self:getProfileIndexForGroundTypeValue(value)
    local prof = idx ~= nil and self.groundProfiles[idx] or nil

    return prof, idx, name
end

function FieldGroundMudPhysics:getProfileByTerrainGroundValue(value)
    local idx = value ~= nil and self.terrainGroundValueToProfileIndex[value] or nil
    local prof = idx ~= nil and self.groundProfiles[idx] or nil
    if prof == nil then
        prof = self.groundProfiles[value]
        idx = value
    end

    return prof, idx, nil
end

-- =========================================================
-- 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 fgGetVehicleSpeedKph(vehicle)
    if vehicle == nil then return 0 end

    -- FS25 Vehicle:getLastSpeed() already returns km/h.
    if vehicle.getLastSpeed ~= nil then
        local ok, value = pcall(vehicle.getLastSpeed, vehicle)
        if ok and type(value) == "number" then
            return math.abs(value)
        end
    end

    return math.abs((tonumber(vehicle.lastSpeedReal) or 0) * 3600)
end

local function fgRequestSpeedLimit(vehicle, limitKph)
    local coordinator = _G.__MudDriveCoordinator
    if coordinator ~= nil and coordinator.requestSpeedLimit ~= nil then
        coordinator:requestSpeedLimit(vehicle, "field", limitKph)
        return
    end

    local motor = vehicle ~= nil and vehicle.spec_motorized ~= nil and vehicle.spec_motorized.motor or nil
    if motor ~= nil and motor.setSpeedLimit ~= nil then
        motor:setSpeedLimit(limitKph or math.huge)
    end
end

local function fgGetNaturalSpeedLimitKph(vehicle, motor, minimum)
    local coordinator = _G.__MudDriveCoordinator
    if coordinator ~= nil and coordinator.getSpeedLimitBase ~= nil then
        local value = coordinator:getSpeedLimitBase(vehicle)
        if type(value) == "number" and value > 0 and value < math.huge then
            return math.max(minimum or 0, value)
        end
    end

    local value = tonumber(motor ~= nil and motor.speedLimit)
    if value ~= nil and value > 0 and value < math.huge then
        return math.max(minimum or 0, value)
    end
    if motor ~= nil and motor.getMaximumForwardSpeed ~= nil then
        local ok, maximum = pcall(motor.getMaximumForwardSpeed, motor)
        if ok and type(maximum) == "number" and maximum > 0 then
            return math.max(minimum or 0, maximum * 3.6)
        end
    end
    return math.max(minimum or 0, fgGetVehicleSpeedKph(vehicle) + 5)
end

local function fgRequestMotorLoad(vehicle, multiplier)
    local coordinator = _G.__MudDriveCoordinator
    if coordinator ~= nil and coordinator.requestMotorLoad ~= nil then
        coordinator:requestMotorLoad(vehicle, "field", multiplier)
        return
    end

    local motor = vehicle ~= nil and vehicle.spec_motorized ~= nil and vehicle.spec_motorized.motor or nil
    if motor ~= nil and motor.setExternalTorqueVirtualMultiplicator ~= nil then
        motor:setExternalTorqueVirtualMultiplicator(multiplier or 1)
    end
end

local function fgTTDHandlesDynamics(vehicle)
    local compatibility = rawget(_G, "TractorTerrainDynamicsCompatibility")
    return compatibility ~= nil and compatibility.isHandlingVehicleDynamics ~= nil
        and compatibility:isHandlingVehicleDynamics(vehicle)
end

local function fgSubmitTerrainDynamics(vehicle, intensity, wetness, slip, sink)
    local coordinator = _G.__MudDriveCoordinator
    if coordinator == nil or coordinator.requestTerrainDynamics == nil then return end
    if (tonumber(intensity) or 0) > 0.001 then
        coordinator:requestTerrainDynamics(vehicle, "field", {
            active=true,
            intensity=intensity,
            wetness=wetness,
            slip=slip,
            sink=sink
        })
    else
        coordinator:requestTerrainDynamics(vehicle, "field", nil)
    end
end

local function fgClearDriveRequests(vehicle)
    fgRequestSpeedLimit(vehicle, nil)
    fgRequestMotorLoad(vehicle, nil)
    local coordinator = _G.__MudDriveCoordinator
    if coordinator ~= nil and coordinator.requestTerrainDynamics ~= nil then
        coordinator:requestTerrainDynamics(vehicle, "field", nil)
    end
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

local function fgMarkCrawlerWheels(vehicle)
    if vehicle == nil or vehicle.__fgCrawlerWheelsScanned == true then
        return
    end

    local spec = vehicle.spec_crawlers
    if spec == nil or spec.crawlers == nil then
        return
    end

    local foundCrawler = false

    for _, crawler in pairs(spec.crawlers) do
        if crawler ~= nil and crawler.wheels ~= nil then
            foundCrawler = true
            for _, wheelData in ipairs(crawler.wheels) do
                local wheel = wheelData ~= nil and wheelData.wheel or nil
                if wheel ~= nil then
                    wheel.__fgIsCrawlerTrackWheel = true
                    if wheel.physics ~= nil then
                        wheel.physics.__fgIsCrawlerTrackWheel = true
                    end
                end
            end
        end
    end

    if foundCrawler then
        if vehicle.spec_wheels ~= nil and vehicle.spec_wheels.wheels ~= nil and WheelsUtil ~= nil and WheelsUtil.getTireTypeName ~= nil then
            for _, wheel in ipairs(vehicle.spec_wheels.wheels) do
                if wheel ~= nil and wheel.physics ~= nil and WheelsUtil.getTireTypeName(wheel.physics.tireType) == "crawler" then
                    wheel.__fgIsCrawlerTrackWheel = true
                    wheel.physics.__fgIsCrawlerTrackWheel = true
                end
            end
        end
        vehicle.__fgCrawlerWheelsScanned = true
    end
end

local function fgIsCrawlerWheel(wheelOrPhysics)
    if FieldGroundMudPhysics.crawlerMudSupportEnable == false or wheelOrPhysics == nil then
        return false
    end

    if wheelOrPhysics.__fgIsCrawlerTrackWheel == true then
        return true
    end

    local vehicle = wheelOrPhysics.vehicle
    local wheel = wheelOrPhysics.wheel or wheelOrPhysics
    if vehicle == nil and wheel ~= nil and wheel.physics ~= nil then
        vehicle = wheel.physics.vehicle
    end

    fgMarkCrawlerWheels(vehicle)

    return (wheel ~= nil and wheel.__fgIsCrawlerTrackWheel == true)
        or (wheel ~= nil and wheel.physics ~= nil and wheel.physics.__fgIsCrawlerTrackWheel == true)
        or wheelOrPhysics.__fgIsCrawlerTrackWheel == true
end

local function fgGetVehicleCrawlerShare(vehicle)
    if FieldGroundMudPhysics.crawlerMudSupportEnable == false or vehicle == nil or vehicle.spec_wheels == nil or vehicle.spec_wheels.wheels == nil then
        return 0
    end

    fgMarkCrawlerWheels(vehicle)

    local total, crawler = 0, 0
    for _, wheel in ipairs(vehicle.spec_wheels.wheels) do
        if wheel ~= nil and wheel.physics ~= nil then
            total = total + 1
            if fgIsCrawlerWheel(wheel) then
                crawler = crawler + 1
            end
        end
    end

    if total <= 0 then
        return 0
    end

    return clamp(crawler / total, 0, 1)
end

local function fgCrawlerLerp(base, crawlerValue, share)
    return base + (crawlerValue - base) * clamp(share or 0, 0, 1)
end

local function fgLocalWetnessAt(x, z, wetnessEff, dt, prof)
    if FieldGroundMudPhysics ~= nil
        and FieldGroundMudPhysics.isHardWinterFrozen ~= nil
        and FieldGroundMudPhysics:isHardWinterFrozen() then
        return wetnessEff or 0
    end

    if FieldLocalWetness ~= nil and FieldLocalWetness.getEffectiveWetnessAt ~= nil then
        return FieldLocalWetness:getEffectiveWetnessAt(x, z, wetnessEff or 0, dt, prof)
    end
    return wetnessEff or 0
end

local function fgVehicleWetness(vehicle, wetnessEff, dt)
    if FieldGroundMudPhysics ~= nil
        and FieldGroundMudPhysics.isHardWinterFrozen ~= nil
        and FieldGroundMudPhysics:isHardWinterFrozen() then
        return wetnessEff or 0
    end

    if FieldLocalWetness ~= nil and FieldLocalWetness.getVehicleWetness ~= nil then
        return FieldLocalWetness:getVehicleWetness(vehicle, wetnessEff or 0, dt)
    end
    return wetnessEff or 0
end

local function fgPermaChanceMulAt(x, z, wetnessEff, dt, prof)
    if FieldLocalWetness ~= nil and FieldLocalWetness.getPermaChanceMultiplierAt ~= nil then
        return FieldLocalWetness:getPermaChanceMultiplierAt(x, z, wetnessEff or 0, dt, prof)
    end
    return 1.0
end

local function fgRadiusEffectMulAt(x, z, wetnessEff, dt, prof)
    if FieldLocalWetness ~= nil and FieldLocalWetness.getRadiusEffectMultiplierAt ~= nil then
        return FieldLocalWetness:getRadiusEffectMultiplierAt(x, z, wetnessEff or 0, dt, prof)
    end
    return 1.0
end

local function fgRadiusWetnessStrength(x, z, wetnessEff, dt, prof)
    local baseK = wetnessStrength(wetnessEff)
    if FieldLocalWetness == nil or FieldLocalWetness.enabled ~= true then
        return baseK
    end

    local wet = wetnessEff or 0
    if FieldLocalWetness.getEffectiveWetnessAt ~= nil and x ~= nil and z ~= nil then
        wet = FieldLocalWetness:getEffectiveWetnessAt(x, z, wet, dt, prof)
    end

    local localK = clamp((wet - 0.08) / 0.42, 0, 1) ^ 1.12
    return math.max(baseK, localK)
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)
    if fgIsCrawlerWheel(wp) then
        minFactor = minFactor + (1 - minFactor) * (self.crawlerMudRadiusMinBoost or 0.28)
    end
    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: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 = nil

    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 groundContact == false 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

    -- Some dedicated-server wheel states do not expose a synchronized boolean
    -- contact flag.  If no explicit false was supplied, keep the density/
    -- wheel-shape fallback above instead of dropping the wheel completely.
    return true
end

local function fgHasWheelTerrainContact(wheel)
    if wheel == nil or wheel.physics == nil then return false end
    local wp = wheel.physics

    local hasGroundContact = wp.hasGroundContact
    if hasGroundContact == nil and wp.netInfo ~= nil then
        hasGroundContact = wp.netInfo.hasGroundContact
    end
    if hasGroundContact == false 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
    return contact ~= objectContactType
end

local function fgGetWheelIndex(vehicle, wheel)
    if vehicle == nil or wheel == nil or vehicle.spec_wheels == nil
        or vehicle.spec_wheels.wheels == nil then
        return nil
    end
    if type(wheel.wheelIndex) == "number"
        and vehicle.spec_wheels.wheels[wheel.wheelIndex] == wheel then
        return wheel.wheelIndex
    end
    for i, candidate in ipairs(vehicle.spec_wheels.wheels) do
        if candidate == wheel then
            wheel.wheelIndex = i
            return i
        end
    end
    return nil
end


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

local function fgDeleteExtraPSEntry(entry)
    if entry == nil then return end

    local particleSystem = entry.ps
    local emitterShape = entry.emitterShape
    local emitterShapeRetired = false

    if emitterShape ~= nil and emitterShape ~= 0
        and entityExists(emitterShape)
        and MudExtraParticlePresets ~= nil
        and MudExtraParticlePresets.retireEmitterShape ~= nil then
        MudExtraParticlePresets.retireEmitterShape(emitterShape)
        emitterShapeRetired = true
    end

    if particleSystem ~= nil then
        particleSystem.isActive = false
        if ParticleUtil ~= nil and ParticleUtil.setEmittingState ~= nil then
            ParticleUtil.setEmittingState(particleSystem, false)
        end
        if ParticleUtil ~= nil and ParticleUtil.resetNumOfEmittedParticles ~= nil then
            ParticleUtil.resetNumOfEmittedParticles(particleSystem)
        end
        if ParticleUtil ~= nil and ParticleUtil.deleteParticleSystem ~= nil then
            ParticleUtil.deleteParticleSystem(particleSystem)
        end
        particleSystem.isValid = false
        particleSystem.isEmitting = false
        particleSystem.geometry = nil
        particleSystem.emitterShape = nil
    end

    mpSafeDelete(entry.psClone)
    if not emitterShapeRetired then
        mpSafeDelete(emitterShape)
    end
    if entry.emitter ~= nil and entry.emitter ~= 0 then
        mpSafeDelete(entry.emitter)
    else
        if particleSystem ~= nil and particleSystem.shape ~= nil then
            mpSafeDelete(particleSystem.shape)
        end
        if entry.shape ~= nil then
            mpSafeDelete(entry.shape)
        end
    end

    entry.emitter = nil
    entry.ps = nil
    entry.psClone = nil
    entry.emitterShape = nil
    entry.shape = nil
end

local function fgDeleteAllVehicleExtraPS()
    for _, state in pairs(FieldGroundMudPhysics.vehicleState or {}) do
        if state ~= nil and state.extraPS ~= nil then
            for _, entry in pairs(state.extraPS) do
                fgDeleteExtraPSEntry(entry)
            end
            state.extraPS = nil
        end
    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

local function fgGetWetnessEffectiveCached(groundWetness, dt)
    local timeMs = g_time or (g_currentMission ~= nil and g_currentMission.time or 0)
    if FieldGroundMudPhysics._wetnessCacheTimeMs == timeMs then
        return FieldGroundMudPhysics._wetnessCacheValue or 0
    end

    local value = computeWetnessEffective(groundWetness, dt)
    FieldGroundMudPhysics._wetnessCacheTimeMs = timeMs
    FieldGroundMudPhysics._wetnessCacheValue = value
    return value
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 p = wheel.physics
    if p ~= nil and p.lastContactX ~= nil and p.lastContactY ~= nil and p.lastContactZ ~= nil then
        return p.lastContactX, p.lastContactY, p.lastContactZ
    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:getWheelGroundProfile(wheel)
    if wheel == nil then
        return 0, nil, nil
    end

    local p = wheel.physics
    local densityType = p ~= nil and p.densityType or nil
    if densityType == nil and p ~= nil and p.netInfo ~= nil then
        densityType = p.netInfo.densityType
    end

    if densityType ~= nil then
        local prof = self:getProfileByFieldGroundTypeValue(densityType)
        if prof ~= nil then
            return clamp(prof.mud or 0, 0, 1), densityType, prof
        end

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

    local x, _, z = self:getWheelContactPos(wheel)
    if x ~= nil then
        return self:getProfileAtWorldPos(x, z)
    end

    return 0, densityType, nil
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

-- =========================================================
-- CROP DESTRUCTION
-- =========================================================
local function fgGetAreaCenter(x0, z0, x1, z1, x2, z2)
    return (x0 + x1 + x2) / 3, (z0 + z1 + z2) / 3
end

function FieldGroundMudPhysics:canDamageCropsAt(x, z)
    if x == nil or z == nil then
        return false
    end

    if self.eraseFoliageOnlyFields ~= true then
        return true
    end

    if FSDensityMapUtil ~= nil and FSDensityMapUtil.getIsFieldAtWorldPos ~= nil then
        return FSDensityMapUtil.getIsFieldAtWorldPos(x, z) == true
    end

    return false
end

function FieldGroundMudPhysics:getCropDestroyModifier(onlyOnFields)
    if g_currentMission == nil
        or g_currentMission.fieldGroundSystem == nil
        or g_fruitTypeManager == nil
        or DensityMapMultiModifier == nil
        or DensityMapModifier == nil
        or DensityMapFilter == nil then
        return nil
    end

    self.__fgCropDestroyModifiers = self.__fgCropDestroyModifiers or {}

    local cacheKey = onlyOnFields and "fields" or "all"
    local cached = self.__fgCropDestroyModifiers[cacheKey]
    if cached ~= nil then
        return cached
    end

    local modifier = DensityMapMultiModifier.new()
    local fieldFilter = nil

    if onlyOnFields then
        local groundTypeMapId, groundTypeFirstChannel, groundTypeNumChannels = g_currentMission.fieldGroundSystem:getDensityMapData(FieldDensityMap.GROUND_TYPE)
        fieldFilter = DensityMapFilter.new(groundTypeMapId, groundTypeFirstChannel, groundTypeNumChannels)
        fieldFilter:setValueCompareParams(DensityValueCompareType.GREATER, 0)
    end

    local grassIndex = FruitType ~= nil and FruitType.GRASS or nil
    local meadowIndex = FruitType ~= nil and FruitType.MEADOW or nil

    for index, desc in pairs(g_fruitTypeManager:getFruitTypes()) do
        if desc.terrainDataPlaneId ~= nil and index ~= grassIndex and index ~= meadowIndex then
            local fruitModifier = DensityMapModifier.new(desc.terrainDataPlaneId, desc.startStateChannel, desc.numStateChannels, g_terrainNode)
            fruitModifier:setNewTypeIndexMode(DensityIndexCompareMode.ZERO)

            local fruitFilter = DensityMapFilter.new(desc.terrainDataPlaneId, desc.startStateChannel, desc.numStateChannels)
            fruitFilter:setValueCompareParams(DensityValueCompareType.GREATER, 0)

            if fieldFilter ~= nil then
                modifier:addExecuteSet(0, fruitModifier, fruitFilter, fieldFilter)
            else
                modifier:addExecuteSet(0, fruitModifier, fruitFilter)
            end

            if desc.terrainDataPlaneIdHaulm ~= nil then
                local haulmModifier = DensityMapModifier.new(desc.terrainDataPlaneIdHaulm, desc.startStateChannelHaulm, desc.numStateChannelsHaulm, g_terrainNode)
                haulmModifier:setNewTypeIndexMode(DensityIndexCompareMode.ZERO)

                local haulmFilter = DensityMapFilter.new(desc.terrainDataPlaneIdHaulm, desc.startStateChannelHaulm, desc.numStateChannelsHaulm)
                haulmFilter:setValueCompareParams(DensityValueCompareType.GREATER, 0)
                haulmFilter:setTypeIndexCompareMode(DensityTypeCompareType.ALWAYS)

                if fieldFilter ~= nil then
                    modifier:addExecuteSet(0, haulmModifier, haulmFilter, fieldFilter)
                else
                    modifier:addExecuteSet(0, haulmModifier, haulmFilter)
                end
            end
        end
    end

    self.__fgCropDestroyModifiers[cacheKey] = modifier
    return modifier
end

function FieldGroundMudPhysics:destroyCropFruitArea(x0, z0, x1, z1, x2, z2, onlyOnFields)
    local modifier = self:getCropDestroyModifier(onlyOnFields)
    if modifier == nil then return end

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

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

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

function FieldGroundMudPhysics:lowerGrassArea(x0, z0, x1, z1, x2, z2, onlyOnFields)
    if FSDensityMapUtil == nil or FSDensityMapUtil.updateMowerArea == nil or FruitType == nil then return end

    if FruitType.GRASS ~= nil then
        FSDensityMapUtil.updateMowerArea(FruitType.GRASS, x0, z0, x1, z1, x2, z2, onlyOnFields)
    end

    if FruitType.MEADOW ~= nil and FruitType.MEADOW ~= FruitType.GRASS then
        FSDensityMapUtil.updateMowerArea(FruitType.MEADOW, x0, z0, x1, z1, x2, z2, onlyOnFields)
    end
end

function FieldGroundMudPhysics:applyCropDestructionArea(x0, z0, x1, z1, x2, z2)
    if g_server == nil then return end
    if x0 == nil or z0 == nil or x1 == nil or z1 == nil or x2 == nil or z2 == nil then return end

    local cx, cz = fgGetAreaCenter(x0, z0, x1, z1, x2, z2)
    if not self:canDamageCropsAt(cx, cz) then
        return
    end

    local onlyOnFields = self.eraseFoliageOnlyFields == true
    self:destroyCropFruitArea(x0, z0, x1, z1, x2, z2, onlyOnFields)
    self:lowerGrassArea(x0, z0, x1, z1, x2, z2, onlyOnFields)
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: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 rawValue = self:getGroundTypeValueAtWorldPos(x, z)
    if rawValue == nil then
        return 0, nil, nil
    end

    local fieldGroundType = nil
    if g_currentMission ~= nil
        and g_currentMission.fieldGroundSystem ~= nil
        and g_currentMission.fieldGroundSystem.getFieldGroundTypeByValue ~= nil then
        fieldGroundType = g_currentMission.fieldGroundSystem:getFieldGroundTypeByValue(rawValue)
    elseif FieldGroundType ~= nil and FieldGroundType.getTypeByValue ~= nil then
        fieldGroundType = FieldGroundType.getTypeByValue(rawValue)
    end

    local prof = nil
    local profileKey = rawValue
    if fieldGroundType ~= nil then
        profileKey = fieldGroundType
        prof = self:getProfileByFieldGroundTypeValue(fieldGroundType)
    end

    local isNoneGround = FieldGroundType ~= nil and fieldGroundType == FieldGroundType.NONE
    if prof == nil and not isNoneGround and self.getProfileByTerrainGroundValue ~= nil then
        prof = self:getProfileByTerrainGroundValue(rawValue)
    end

    if prof == nil then
        return 0, profileKey, nil
    end

    return clamp(prof.mud or 0, 0, 1), profileKey, 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,
            permaRadiusFactor=nil,
            permaNoMudTime=0,
            permaCd=0,
            permaExposure=0,
            permaRestTime=0,
            permaPreSink=nil,
            permaLuckyEscape=false,
            permaLuckyTimer=0,
            permaLuckyHoldSec=0,
            permaLuckyReleaseFromSink=nil,
            permaStuckToken=0,
            permaNotifyToken=0,
            permaReleaseDist=0,
            permaLastPosX=nil,
            permaLastPosZ=nil,
            permaMoveTickMs=-1,
            lastTick=-1,
            ramp=0,
            fieldContactShare=0,
            fieldBoundaryBlend=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 mudBase, _, prof = FieldGroundMudPhysics:getWheelGroundProfile(w)
        if FieldGroundMudPhysics:hasWheelFieldGroundContact(w)
            and prof ~= nil and (mudBase or 0) > 0.01 then
            count = count + 1
        end
    end

    return count
end

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

    st.permaActive = false
    st.permaTargetExtra = 0
    st.permaRadiusFactor = nil
    st.permaNoMudTime = 0
    st.permaExposure = 0
    st.permaRestTime = 0
    st.permaPreSink = nil
    st.permaLuckyEscape = false
    st.permaLuckyTimer = 0
    st.permaLuckyHoldSec = 0
    st.permaLuckyReleaseFromSink = nil
    st.permaStuckToken = 0
    st.permaNotifyToken = 0
    st.permaCd = math.max(st.permaCd or 0, 30)
    fgResetPermaReleaseState(st, vehicle)
end

function FieldGroundMudPhysics:updatePermaStuckState(
    vehicle, dtS, wetnessEff, effMud, slip, speedKph, prof, fieldContactShare
)
    local st = getState(vehicle)
    dtS = math.max(0, tonumber(dtS) or 0)
    if dtS <= 0 then
        return
    end

    st.permaCd = math.max(0, (st.permaCd or 0) - dtS)
    fgUpdatePermaReleaseState(st, vehicle, g_time or 0)

    local releaseStart = math.max(
        0,
        self.permaStuckReleaseDistanceStartM or 3
    )
    local releaseFull = math.max(
        releaseStart + 0.01,
        self.permaStuckReleaseDistanceFullM or 5
    )

    if st.permaActive == true then
        if st.permaLuckyEscape == true then
            st.permaLuckyTimer = (st.permaLuckyTimer or 0) + dtS
            local holdSeconds = math.max(
                0,
                st.permaLuckyHoldSec
                    or self.permaLuckyHoldMinSec
                    or 1.8
            )
            if (st.permaLuckyTimer or 0) >= holdSeconds then
                local releaseSeconds = math.max(
                    0.1,
                    self.permaLuckyReleaseSec or 2.8
                )
                local releaseBlend = clamp(
                    ((st.permaLuckyTimer or 0) - holdSeconds)
                        / releaseSeconds,
                    0,
                    1
                )
                if st.permaLuckyReleaseFromSink == nil then
                    st.permaLuckyReleaseFromSink = st.sink or 0
                end
                local restoreSink = clamp(
                    st.permaPreSink or st.sink or 0,
                    0,
                    1
                )
                st.sink = (st.permaLuckyReleaseFromSink or restoreSink)
                    + (
                        restoreSink
                            - (st.permaLuckyReleaseFromSink or restoreSink)
                    ) * releaseBlend
                st.permaReleaseDist = math.max(
                    st.permaReleaseDist or 0,
                    releaseStart
                        + (releaseFull - releaseStart) * releaseBlend
                )

                if releaseBlend >= 0.999 then
                    fgClearPermaStuck(st, vehicle)
                    st.sink = restoreSink
                    st.stuck = restoreSink
                        >= (self.stuckThreshold or 0.82)
                end
            end
            return
        end

        if fgCountMudWheels(vehicle) <= 0 then
            st.permaNoMudTime = (st.permaNoMudTime or 0) + dtS
            st.permaReleaseDist = math.max(
                st.permaReleaseDist or 0,
                releaseStart
            )
        else
            st.permaNoMudTime = 0
        end

        local resting = (speedKph or 0) <= 0.70 and (slip or 0) <= 0.06
        if resting then
            st.permaRestTime = (st.permaRestTime or 0) + dtS
        else
            st.permaRestTime = math.max(
                0,
                (st.permaRestTime or 0) - dtS * 1.5
            )
        end

        -- Stopping wheelspin does not heal the rut or reset sink. It only
        -- removes the artificial hard lock so a careful reverse attempt,
        -- lower tyre pressure or towing can work against the normal mud model.
        local restStart = math.max(
            0,
            self.permaStuckRestReleaseStartSec or 8
        )
        local restFull = math.max(
            restStart + 0.1,
            self.permaStuckRestReleaseFullSec or 16
        )
        local restRelease = clamp(
            ((st.permaRestTime or 0) - restStart)
                / (restFull - restStart),
            0,
            1
        )
        if restRelease > 0 then
            st.permaReleaseDist = math.max(
                st.permaReleaseDist or 0,
                releaseStart + (releaseFull - releaseStart) * restRelease
            )
        end

        if (st.permaReleaseDist or 0) >= releaseFull - 0.001 then
            fgClearPermaStuck(st, vehicle)
        end
        return
    end

    st.permaRestTime = 0
    if self.permaStuckEnable ~= true
        or prof == nil or prof.permaStuck ~= true
        or (self.permaStuckChanceOnStruggle or 0) <= 0
        or (st.permaCd or 0) > 0 then
        st.permaExposure = math.max(
            0,
            (st.permaExposure or 0)
                - dtS / math.max(0.1, self.permaStuckDecaySec or 10)
        )
        return
    end

    local wheels = vehicle ~= nil and vehicle.spec_wheels ~= nil
        and vehicle.spec_wheels.wheels or nil
    local totalWheels = wheels ~= nil and #wheels or 0
    local mudWheelCount = fgCountMudWheels(vehicle)
    local minimumMudWheels = math.max(
        self.permaStuckMinWheelsInMud or 2,
        math.ceil(totalWheels * 0.50)
    )
    local contactShare = clamp(tonumber(fieldContactShare) or 0, 0, 1)
    local enoughSupport = mudWheelCount >= minimumMudWheels
        and contactShare >= (self.permaStuckContactShareMin or 0.60)

    local wetSeverity = smootherstep(
        self.permaStuckWetnessMin or 0.58,
        0.96,
        wetnessEff or 0
    )
    local sinkSeverity = smootherstep(
        self.permaStuckSinkMin or 0.68,
        0.96,
        st.sink or 0
    )
    local slipSeverity = smootherstep(
        self.permaStuckSlipMin or 0.22,
        0.58,
        slip or 0
    )
    local soilSeverity = smootherstep(0.35, 0.88, effMud or 0)
    local speedSeverity = 1 - smootherstep(
        4.0,
        self.permaStuckMaxSpeedKph or 9.0,
        speedKph or 0
    )
    local severity = enoughSupport
        and math.sqrt(
            wetSeverity * sinkSeverity * slipSeverity * soilSeverity
        ) * speedSeverity
        or 0

    if severity > 0.001 then
        local x, z = fgGetVehicleXZ(vehicle)
        local localMultiplier = 1
        if x ~= nil then
            localMultiplier = clamp(
                fgPermaChanceMulAt(
                    x,
                    z,
                    wetnessEff,
                    dtS * 1000,
                    prof
                ),
                0.50,
                1.50
            )
        end
        local crawlerMultiplier = fgCrawlerLerp(
            1,
            self.crawlerMudPermaStuckChanceMul or 0.12,
            fgGetVehicleCrawlerShare(vehicle)
        )
        local configuredScale = clamp(
            (self.permaStuckChanceOnStruggle or 0.020) / 0.020,
            0,
            2
        )
        local buildSeconds = math.max(5, self.permaStuckBuildSec or 22)
        st.permaExposure = clamp(
            (st.permaExposure or 0)
                + dtS * severity * localMultiplier
                    * crawlerMultiplier * configuredScale / buildSeconds,
            0,
            1
        )
    else
        st.permaExposure = math.max(
            0,
            (st.permaExposure or 0)
                - dtS / math.max(0.1, self.permaStuckDecaySec or 10)
        )
    end

    if (st.permaExposure or 0) >= 0.999 then
        local cMin = self.permaStuckCooldownMin or 45
        local cMax = math.max(cMin, self.permaStuckCooldownMax or 120)
        st.permaActive = true
        st.permaCd = cMin + (cMax - cMin) * math.random()
        st.permaRadiusFactor = clamp(
            self.permaStuckRadiusFactor or 0.62,
            0.45,
            0.90
        )
        st.permaPreSink = clamp(st.sink or 0, 0, 1)
        st.permaLuckyEscape = math.random()
            < clamp(self.permaLuckyEscapeChance or 0.12, 0, 0.50)
        st.permaLuckyTimer = 0
        st.permaLuckyReleaseFromSink = nil
        local luckyHoldMin = math.max(
            0,
            self.permaLuckyHoldMinSec or 1.8
        )
        local luckyHoldMax = math.max(
            luckyHoldMin,
            self.permaLuckyHoldMaxSec or 4.2
        )
        st.permaLuckyHoldSec = luckyHoldMin
            + (luckyHoldMax - luckyHoldMin) * math.random()
        st.permaTargetExtra = 0
        st.permaNoMudTime = 0
        st.permaRestTime = 0
        st.permaStuckToken = (st.permaStuckToken or 0) + 1
        fgResetPermaReleaseState(st, vehicle)

        if MudPhysics ~= nil
            and MudPhysics.showPermaStuckNotification ~= nil then
            MudPhysics:showPermaStuckNotification(vehicle, st)
        end
    end
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, 0
    end

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

    for _, w in ipairs(vehicle.spec_wheels.wheels) do
        local hasTerrainContact = fgHasWheelTerrainContact(w)
        local hasContact = self:hasWheelFieldGroundContact(w)
        local mudBase, gt, prof = self:getWheelGroundProfile(w)
        if hasTerrainContact then
            groundedCount = groundedCount + 1
        end
        if hasContact and prof ~= nil then
            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, 0
    end

    local fieldContactShare = count / math.max(count, groundedCount)
    return mudSum / count, slipSum / count, bestGT, bestProf,
        clamp(fieldContactShare, 0, 1)
end

function FieldGroundMudPhysics:computeVehicleFieldMudCached(vehicle)
    if vehicle == nil then
        return 0, 0, nil, nil, 0
    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,
            st.__fgFieldMudCacheContactShare or 0
    end

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

    return mudAvg, slipAvg, bestGT, bestProf, fieldContactShare or 0
end

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

function FieldGroundMudPhysics:isVehicleResetting(vehicle)
    if vehicle == nil then return true end
    if vehicle.__mudSystemDeleting == true 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

    if vehicle.__mudSystemDeleting == true then
        local deletingState = FieldGroundMudPhysics.vehicleState ~= nil
            and FieldGroundMudPhysics.vehicleState[vehicle] or nil
        if deletingState ~= nil and deletingState.extraPS ~= nil then
            for _, entry in pairs(deletingState.extraPS) do
                fgDeleteExtraPSEntry(entry)
            end
            deletingState.extraPS = nil
        end
        if FieldGroundMudPhysics.vehicleState ~= nil then
            FieldGroundMudPhysics.vehicleState[vehicle] = nil
        end
        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.fieldContactShare = 0
        st.fieldBoundaryBlend = 0
        st.__fgLastMudLimitKph = nil
        st.__fgSmoothedMudLimitKph = nil
        st.__fgTerrainSeverity = 0
        st.__fgSoilState = nil
        st.__fgSmoothedMotorLoad = 1
        st.__tmSpeedKph = 0
        st.lastTick = -1
        st.permaActive = false
        st.permaTargetExtra = 0
        st.permaRadiusFactor = nil
        st.permaNoMudTime = 0
        st.permaCd = 0
        st.permaExposure = 0
        st.permaRestTime = 0
        st.permaPreSink = nil
        st.permaLuckyEscape = false
        st.permaLuckyTimer = 0
        st.permaLuckyHoldSec = 0
        st.permaLuckyReleaseFromSink = nil
        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
                fgDeleteExtraPSEntry(entry)
            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

    fgClearDriveRequests(vehicle)

    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.fieldContactShare = 0
        st.fieldBoundaryBlend = 0
        st.__fgLastMudLimitKph = nil
        st.__fgSmoothedMudLimitKph = nil
        st.__fgTerrainSeverity = 0
        st.__fgSoilState = nil
        st.__fgSmoothedMotorLoad = 1
        st.__tmSpeedKph = 0
        st.lastTick = -1
        st.permaActive = false
        st.permaTargetExtra = 0
        st.permaRadiusFactor = nil
        st.permaNoMudTime = 0
        st.permaCd = 0
        st.permaExposure = 0
        st.permaRestTime = 0
        st.permaPreSink = nil
        st.permaLuckyEscape = false
        st.permaLuckyTimer = 0
        st.permaLuckyHoldSec = 0
        st.permaLuckyReleaseFromSink = nil
        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
                fgDeleteExtraPSEntry(entry)
            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

    fgClearDriveRequests(vehicle)

    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)
    self._wetnessCacheTimeMs = nil
    self._wetnessCacheValue = nil

    -- Delete every emitter reachable from our own state table before replacing
    -- it. Mission vehicle lists are not guaranteed to be complete during shop
    -- reloads and settings synchronization.
    fgDeleteAllVehicleExtraPS()

    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
                fgDeleteExtraPSEntry(entry)
            end
            st.extraPS = nil
        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:getWheelGroundProfile(w)
                    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
                        if isSnowWheel then
                            if nd ~= nil and nd.dirtAmount ~= nil then
                                vehicle:setNodeDirtAmount(nd, clamp((nd.dirtAmount or 0) + wheelAdd, 0, dirtMax), true)
                                w.forceWheelDirtUpdate = true
                            end
                            if snowMudMeshCanStick and ndMud ~= nil and ndMud.dirtAmount ~= nil then
                                vehicle:setNodeDirtAmount(ndMud, clamp((ndMud.dirtAmount or 0) + wheelAdd, 0, dirtMax), true)
                                w.forceWheelDirtUpdate = true
                            end
                        elseif MudWheelVisualSystem ~= nil
                            and MudWheelVisualSystem.reportMudContact ~= nil then
                            MudWheelVisualSystem:reportMudContact(
                                vehicle,
                                w,
                                wheelIndex,
                                dtS,
                                wheelAdd,
                                baseEff * dirtMul * speedFactor,
                                dirtMax,
                                MudExtraParticleEvent ~= nil
                                    and MudExtraParticleEvent.CHANNEL_FIELD or 2
                            )
                        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, dt, pressureMul)
    local st = getState(vehicle)
    if fgTTDHandlesDynamics(vehicle) then
        fgRequestMotorLoad(vehicle, nil)
        st.__fgAppliedMotorLoad = false
        st.lastAppliedMotorLoad = 1
        st.__fgSmoothedMotorLoad = 1
        return
    end
    if not self.motorLoadEnable then
        fgRequestMotorLoad(vehicle, nil)
        st.__fgSmoothedMotorLoad = 1
        return
    end

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

    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

    local crawlerShare = fgGetVehicleCrawlerShare(vehicle)
    local motorMul = fgCrawlerLerp(1.0, self.crawlerMudMotorLoadMul or 0.50, crawlerShare)
    local model = _G.__MudTerramechanicsModel
    local target = 1
    if model ~= nil and effMud > (self.motorLoadMinEff or 0) then
        local soilState = st.__fgSoilState or model:updateSoilState(
            st, "__fgTerrainSeverity", effMud, st.sink, slip,
            (dt or 0)*0.001, self.terramechanicsEnterSec, self.terramechanicsExitSec
        )
        target = model:getMotorLoadTarget(
            soilState, self.motorLoadFromSink, self.motorLoadFromSlip,
            motorMul*mulProf*math.max(0.05, tonumber(pressureMul) or 1),
            self.motorLoadMaxMult, st.stuck
        )
    end

    local mult
    if model ~= nil then
        mult = model:smoothMotorLoad(
            st, "__fgSmoothedMotorLoad", target, (dt or 0)*0.001,
            self.motorLoadEnterSec, self.motorLoadExitSec
        )
    else
        mult = clamp(target, 1, self.motorLoadMaxMult or 4)
    end

    if mult <= 1.005 then
        fgRequestMotorLoad(vehicle, nil)
        st.lastAppliedMotorLoad = 1
        st.__fgAppliedMotorLoad = false
    else
        if math.abs((st.lastAppliedMotorLoad or 1)-mult) > 0.005 then
            fgRequestMotorLoad(vehicle, mult)
            st.lastAppliedMotorLoad = mult
        end
        st.__fgAppliedMotorLoad = true
    end
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]
    local presetId = math.floor(tonumber(FieldGroundMudPhysics.extraParticlesPreset) or 1)
    if entry ~= nil and entry.ps ~= nil and entry.emitter ~= nil then
        if entry.presetId == presetId then
            return entry
        end
        fgDeleteExtraPSEntry(entry)
        st.extraPS[key] = nil
        entry = nil
    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,
        emitterShape=emitterShape,
        psClone=psClone,
        ps=psTbl,
        __fgRootNode=rootNode,
        presetId=presetId
    }
    st.extraPS[key] = entry
    return entry
end

local function updateWheelExtraPS(vehicle, wheelRef, isActive, wx, wy, wz, liftY)
    if vehicle == nil or vehicle.__mudSystemDeleting == true
        or vehicle.isDeleting == true or vehicle.isDeleted == true then
        return
    end

    local state = FieldGroundMudPhysics.vehicleState ~= nil
        and FieldGroundMudPhysics.vehicleState[vehicle] or nil
    local key = fgWheelKey(wheelRef)
    local entry = state ~= nil and state.extraPS ~= nil and state.extraPS[key] or nil
    if entry == nil and isActive ~= true then
        return
    end
    entry = entry or 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)
            if not isActive and ParticleUtil.resetNumOfEmittedParticles ~= nil then
                ParticleUtil.resetNumOfEmittedParticles(ps)
            end
        end
    end
end

local function fgStopWheelExtraPS(vehicle, wheelRef)
    local state = FieldGroundMudPhysics.vehicleState ~= nil
        and FieldGroundMudPhysics.vehicleState[vehicle] or nil
    local entry = state ~= nil and state.extraPS ~= nil
        and state.extraPS[fgWheelKey(wheelRef)] or nil
    local ps = entry ~= nil and entry.ps or nil
    if ps ~= nil then
        local wasActive = ps.isActive == true or ps.isEmitting == true
        ps.isActive = false
        if ParticleUtil ~= nil and ParticleUtil.setEmittingState ~= nil then
            ParticleUtil.setEmittingState(ps, false)
        end
        if wasActive and ParticleUtil ~= nil
            and ParticleUtil.resetNumOfEmittedParticles ~= nil then
            ParticleUtil.resetNumOfEmittedParticles(ps)
        end
    end
    if wheelRef ~= nil and wheelRef.physics ~= nil then
        wheelRef.physics.__fgExtraBurstT = 0
    end
end

local function fgHasParticleMotion(vehicle, wheel)
    if fgGetVehicleSpeedKph(vehicle) >= 0.35 then
        return true
    end

    local physics = wheel ~= nil and wheel.physics or nil
    local wheelSpeed = physics ~= nil and physics.netInfo ~= nil
        and math.abs(tonumber(physics.netInfo.lastSpeedSmoothed) or 0) * 3600 or 0
    return wheelSpeed >= 0.75
end

function FieldGroundMudPhysics:applyNetworkExtraParticles(vehicle, wheelIndex, isActive, wx, wy, wz, burst)
    if g_dedicatedServer ~= nil or (g_server ~= nil and g_client == nil and g_gui == nil)
        or vehicle == nil or vehicle.spec_wheels == nil or vehicle.spec_wheels.wheels == nil then
        return
    end

    local wheel = vehicle.spec_wheels.wheels[wheelIndex]
    local wp = wheel ~= nil and wheel.physics or nil
    if wheel == nil or wp == nil then
        return
    end

    local now = g_time or (g_currentMission ~= nil and g_currentMission.time or 0)
    wp.__fgExtraPSNetUntil = now + 750
    wp.__fgExtraBurstT = burst == true and 0.20 or 0

    local st = self.vehicleState ~= nil and self.vehicleState[vehicle] or nil
    local entry = st ~= nil and st.extraPS ~= nil and st.extraPS[wheel] or nil
    if isActive ~= true or not self.enabled or not self.extraParticlesEnable then
        fgStopWheelExtraPS(vehicle, wheel)
        return
    end

    if self._extraPS == nil or self._extraPS.referencePS == nil or self._extraPS.referenceShape == nil then
        self:loadExtraParticleReferences()
    end
    local _, _, prof = self:getWheelGroundProfile(wheel)
    local liftY = self:computeExtraParticleLiftY(wp, prof)
    updateWheelExtraPS(vehicle, wheel, true, wx, wy, wz, liftY)
end

local function fgSendServerExtraParticleState(wheelPhysics, dt, groundWetness, forceInactive)
    local isMultiplayer = g_currentMission ~= nil
        and g_currentMission.missionDynamicInfo ~= nil
        and g_currentMission.missionDynamicInfo.isMultiplayer == true
    if g_server == nil or not isMultiplayer or MudExtraParticleEvent == nil
        or wheelPhysics == nil or wheelPhysics.vehicle == nil or wheelPhysics.wheel == nil then
        return
    end

    local vehicle = wheelPhysics.vehicle
    local wheel = wheelPhysics.wheel
    local active = false
    local burst = false
    local wx, wy, wz = FieldGroundMudPhysics:getWheelContactPos(wheel)

    if forceInactive ~= true and FieldGroundMudPhysics.enabled
        and FieldGroundMudPhysics.extraParticlesEnable and wx ~= nil and wz ~= nil then
        local hardFrozen = FieldGroundMudPhysics.isHardWinterFrozen ~= nil
            and FieldGroundMudPhysics:isHardWinterFrozen()
        local hasContact = FieldGroundMudPhysics:hasWheelFieldGroundContact(wheel)
        local mudBase, _, prof = FieldGroundMudPhysics:getWheelGroundProfile(wheel)
        local wetnessEff = fgGetWetnessEffectiveCached(groundWetness, dt)
        wetnessEff = fgLocalWetnessAt(wx, wz, wetnessEff, dt, prof)

        active = not hardFrozen and hasContact and prof ~= nil and prof.fxExtra == true
            and (mudBase or 0) > 0.01
            and wetnessEff > (FieldGroundMudPhysics.wetnessThreshold or 0.12)

        local slip = (wheelPhysics.netInfo ~= nil and wheelPhysics.netInfo.slip) or wheelPhysics.slip or 0
        if active and fgGetVehicleSpeedKph(vehicle) < (FieldGroundMudPhysics.extraParticleMinSpeedKph or 18.0)
            and slip < (FieldGroundMudPhysics.extraParticleBurstSlipMin or 0.15) then
            active = false
        end
        if active and not fgHasParticleMotion(vehicle, wheel) then
            active = false
        end
        burst = active and FieldGroundMudPhysics.extraParticleBurstEnable == true
            and slip >= (FieldGroundMudPhysics.extraParticleBurstSlipMin or 0.15)
    end

    local shedNow = g_time or (g_currentMission ~= nil and g_currentMission.time or 0)
    local shedActive = forceInactive ~= true
        and FieldGroundMudPhysics.enabled and FieldGroundMudPhysics.extraParticlesEnable
        and wheelPhysics.__mudsysShedParticleChannel == MudExtraParticleEvent.CHANNEL_FIELD
        and (wheelPhysics.__mudsysShedParticlesUntil or 0) >= shedNow
        and fgHasParticleMotion(vehicle, wheel)
    if shedActive then
        active = true
        burst = true
    end

    local wheelIndex = fgGetWheelIndex(vehicle, wheel)
    if wheelIndex == nil then
        return
    end

    wheelPhysics.__fgNetExtraPSSendT = math.max(
        0, (wheelPhysics.__fgNetExtraPSSendT or 0) - ((dt or 0) * 0.001)
    )
    local changed = wheelPhysics.__fgNetExtraPSLastActive ~= active
    if changed or wheelPhysics.__fgNetExtraPSSendT <= 0 then
        wheelPhysics.__fgNetExtraPSLastActive = active
        wheelPhysics.__fgNetExtraPSSendT = active and 0.10 or 0.45
        MudExtraParticleEvent.send(
            vehicle, wheelIndex, MudExtraParticleEvent.CHANNEL_FIELD,
            active, wx or 0, wy or 0, wz or 0, burst
        )
    end
end

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

    if self:isInShopPreview(vehicle) then
        fgClearDriveRequests(vehicle)
        st.sink = 0
        st.stuck = false
        st.ramp = 0
        st.fieldContactShare = 0
        st.fieldBoundaryBlend = 0
        st.__fgLastMudLimitKph = nil
        st.__fgSmoothedMudLimitKph = nil
        st.__fgTerrainSeverity = 0
        st.__fgSoilState = nil
        st.__fgSmoothedMotorLoad = 1
        st.__tmSpeedKph = 0
        return
    end

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

    wetnessEff = fgVehicleWetness(vehicle, wetnessEff, dt)

    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 contactTarget = inMud
        and clamp(tonumber(fieldContactShare) or 1, 0, 1) or 0
    st.fieldContactShare = contactTarget
    local previousBoundaryBlend = clamp(st.fieldBoundaryBlend or 0, 0, 1)
    local boundarySeconds = contactTarget > previousBoundaryBlend
        and (self.fieldBoundaryBlendInSec or 1.25)
        or (self.fieldBoundaryBlendOutSec or 1.75)
    st.fieldBoundaryBlend = expSmoothing(
        previousBoundaryBlend, contactTarget, dtS, boundarySeconds
    )
    if math.abs(st.fieldBoundaryBlend-contactTarget) < 0.001 then
        st.fieldBoundaryBlend = contactTarget
    end

    local speedKph = fgGetVehicleSpeedKph(vehicle)
    st.__tmSpeedKph = speedKph

    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 crawlerShare = fgGetVehicleCrawlerShare(vehicle)
    local crawlerSinkMul = fgCrawlerLerp(1.0, self.crawlerMudSinkMul or 0.45, crawlerShare)
    local crawlerSpeedMul = fgCrawlerLerp(1.0, self.crawlerMudSpeedLimitMul or 1.55, crawlerShare)
    local wheelLoadFactor = (WheelLoadSystem ~= nil and WheelLoadSystem.getVehicleLoadFactor ~= nil)
        and WheelLoadSystem:getVehicleLoadFactor(vehicle) or 1.0

    local pressureSinkMul = 1.0
    if TirePressureSystem ~= nil and TirePressureSystem.getPressure01 ~= nil and vehicle ~= nil then
        if TirePressureSystem.getVehicleSinkMultiplier ~= nil then
            pressureSinkMul = TirePressureSystem:getVehicleSinkMultiplier(vehicle)
        else
            local p = TirePressureSystem:getPressure01(vehicle)
            pressureSinkMul = (TirePressureSystem.lowPressureSinkMul or 0.55)
                + (1 - (TirePressureSystem.lowPressureSinkMul or 0.55)) * p
        end
    end
    local pressureSinkOutMul = (TirePressureSystem ~= nil and TirePressureSystem.getSinkOutMultiplier ~= nil)
        and TirePressureSystem:getSinkOutMultiplier(vehicle) or 1.0
    local pressureSpeedMul = (TirePressureSystem ~= nil and TirePressureSystem.getMudSpeedLimitMultiplier ~= nil)
        and TirePressureSystem:getMudSpeedLimitMultiplier(vehicle) or 1.0
    local pressureMotorLoadMul = (TirePressureSystem ~= nil and TirePressureSystem.getMotorLoadMultiplier ~= nil)
        and TirePressureSystem:getMotorLoadMultiplier(vehicle) or 1.0
    local sinkIn  = (effMudRamp * sinkMul) * (0.30 + (slipAvg or 0)) * (self.sinkInSpeed or 1.0) * wheelLoadFactor * (1 - widthBonus) * crawlerSinkMul * pressureSinkMul
    local sinkResponseKph = speedKph * (self.legacySpeedResponseScale or 3.6)
    local sinkOut = (1 - effMudRamp) * (sinkResponseKph / 25) * (self.sinkOutSpeed or 0.35) * pressureSinkOutMul

    st.sink = clamp((st.sink or 0) + dtS * (sinkIn - sinkOut), 0, 1)
    st.stuck = (st.sink or 0) >= (self.stuckThreshold or 0.82)
    self:updatePermaStuckState(
        vehicle,
        dtS,
        wetnessEff,
        effMudRamp,
        slipAvg or 0,
        speedKph,
        bestProf,
        st.fieldBoundaryBlend or 0
    )
    local terrainModel = _G.__MudTerramechanicsModel
    if terrainModel ~= nil then
        st.__fgSoilState = terrainModel:updateSoilState(
            st, "__fgTerrainSeverity",
            effMudRamp*clamp(st.fieldBoundaryBlend or 0, 0, 1),
            st.sink, slipAvg or 0, dtS,
            self.terramechanicsEnterSec or 1.45,
            self.terramechanicsExitSec or 2.40
        )
    end
    fgSubmitTerrainDynamics(vehicle, effMudRamp, wetnessEff, slipAvg or 0, st.sink)

    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

    if motor ~= nil and motor.setSpeedLimit ~= nil then
        if terrainModel ~= nil and st.__fgSoilState ~= nil
            and (st.__fgSoilState.severity or 0) > 0.005 then
            local profileCap = bestProf ~= nil and tonumber(bestProf.maxSpeedMudKph) or nil
            local mudCap = math.max(
                self.maxSpeedMudKph or 23.0,
                profileCap or 0
            )*crawlerSpeedMul*pressureSpeedMul
            local stuckCap = (self.maxSpeedStuckKph or 14.5)*crawlerSpeedMul*pressureSpeedMul
            local naturalLimitKph = fgGetNaturalSpeedLimitKph(vehicle, motor, mudCap)
            local limitKph = terrainModel:getSoftSpeedLimit(
                naturalLimitKph, mudCap, stuckCap,
                st.__fgSoilState, self.stuckThreshold
            )
            fgRequestSpeedLimit(
                vehicle,
                limitKph < naturalLimitKph-0.05 and limitKph or nil
            )
            st.__fgAppliedSpeedLimit = true
        else
            fgRequestSpeedLimit(vehicle, nil)
            st.__fgAppliedSpeedLimit = false
            if contactTarget <= 0 and (st.fieldBoundaryBlend or 0) <= 0.001 then
                st.__fgLastMudLimitKph = nil
                st.__fgSmoothedMudLimitKph = nil
            end
        end
    else
        fgRequestSpeedLimit(vehicle, nil)
    end

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

    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 contact=%.2f boundary=%.2f sink=%.2f slip=%.2f stuck=%s permaExposure=%.2f perma=%s",
            gtStr, pn, wetnessEff or 0, wetK, mudBaseAvg or 0, effMudRaw or 0,
            effMudRamp or 0, ramp, fieldContactShare or 0, st.fieldBoundaryBlend or 0,
            st.sink or 0, slipAvg or 0, tostring(st.stuck),
            st.permaExposure or 0, tostring(st.permaActive == true)))
    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 self.wheel ~= nil then
					fgStopWheelExtraPS(self.vehicle, self.wheel)
				end
				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 self ~= nil and self.vehicle ~= nil and self.wheel ~= nil then
                    fgStopWheelExtraPS(self.vehicle, self.wheel)
                end
                if superFunc ~= nil then superFunc(self, dt, groundWetness, currentUpdateDistance) end
                return
            end

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

			local wetnessEff = fgGetWetnessEffectiveCached(groundWetness, dt)

			-- HARD WINTER FREEZE: в мороз имитируем "влажность = 0" ВЕЗДЕ (и для нашей логики, и для ванили)
			local hardWinterFrozen = (FieldGroundMudPhysics.isHardWinterFrozen ~= nil and FieldGroundMudPhysics:isHardWinterFrozen())
			if hardWinterFrozen then
				wetnessEff = 0
			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
            local wetnessWheelEff = wetnessEff
            if x ~= nil then
                mudBase, gt, prof = FieldGroundMudPhysics:getWheelGroundProfile(self.wheel)
                wetnessWheelEff = fgLocalWetnessAt(x, z, wetnessEff, dt, prof)
            end

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

            -- extra particles (fxExtra=true)
            local hardWinterFrozen = (FieldGroundMudPhysics.isHardWinterFrozen ~= nil and FieldGroundMudPhysics:isHardWinterFrozen())
			local netWp = self.wheel ~= nil and self.wheel.physics or nil
			local netNow = g_time or (g_currentMission ~= nil and g_currentMission.time or 0)
			local networkControlled = netWp ~= nil
				and netWp.__fgExtraPSNetUntil ~= nil
				and netNow <= netWp.__fgExtraPSNetUntil

            if not networkControlled and (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 speedKph = fgGetVehicleSpeedKph(self.vehicle)
                    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
                    if active and not fgHasParticleMotion(self.vehicle, self.wheel) 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 speedKph = fgGetVehicleSpeedKph(self.vehicle)
                            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

			elseif not networkControlled then
                -- если зима/мороз или профиль не подходит -> гарантированно гасим эмиттер
                fgStopWheelExtraPS(self.vehicle, self.wheel)
            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 FieldGroundMudPhysics.enabled
					and self ~= nil and self.vehicle ~= nil and self.vehicle.isAddedToPhysics then
					if fgTTDHandlesDynamics(self.vehicle) then
						self.__fgGripMul = nil
					else
					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
				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
                    and not fgTTDHandlesDynamics(self.vehicle) 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
					fgSendServerExtraParticleState(self, dt, groundWetness, true)
					local stDisabled = getState(self.vehicle)
					local timeMs = g_time or 0
					if stDisabled.__fgDriveClearTick ~= timeMs then
						stDisabled.__fgDriveClearTick = timeMs
						fgClearDriveRequests(self.vehicle)
					end
					stDisabled.__fgTerrainSeverity = 0
					stDisabled.__fgSoilState = nil
					stDisabled.__fgSmoothedMotorLoad = 1

					self.__fgDesiredRadius = nil
					self.__fgMudCurExtra = 0
					self.__fgMudBrakeExtra = 0
					self.__fgBrakeCur = 0
					self.__fgGripMul = nil
					self.isFrictionDirty = true
					if _G.__MudRadiusCombiner ~= nil and _G.__MudRadiusCombiner.apply ~= nil then
						_G.__MudRadiusCombiner.apply(self, FieldGroundMudPhysics.freezeRadiusEps or 0.0015)
					end
					return
				end

				if FieldGroundMudPhysics:isInShopPreview(self.vehicle) or FieldGroundMudPhysics:isVehicleResetting(self.vehicle) then
					fgSendServerExtraParticleState(self, dt, groundWetness, true)
					fgClearDriveRequests(self.vehicle)
					if not FieldGroundMudPhysics:isVehicleResetting(self.vehicle) then
						local stPreview = getState(self.vehicle)
						stPreview.__fgTerrainSeverity = 0
						stPreview.__fgSoilState = nil
						stPreview.__fgSmoothedMotorLoad = 1
					end
					self.__fgDesiredRadius = nil
					self.__fgMudCurExtra = 0
					self.__fgMudBrakeExtra = 0
					self.__fgBrakeCur = 0
					self.__fgGripMul = nil
					self.isFrictionDirty = true
					if _G.__MudRadiusCombiner ~= nil and _G.__MudRadiusCombiner.apply ~= nil then
						_G.__MudRadiusCombiner.apply(self, FieldGroundMudPhysics.freezeRadiusEps or 0.0015)
					end
					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.__fgPhysicsTick ~= timeMs then
						stV.__fgPhysicsTick = timeMs
						local wetnessEff = fgGetWetnessEffectiveCached(groundWetness, dt)

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

				fgSendServerExtraParticleState(
					self, dt, groundWetness,
					not FieldGroundMudPhysics.enabled or not FieldGroundMudPhysics.extraParticlesEnable
				)

				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 isCrawlerWheel = fgIsCrawlerWheel(self)
				local crawlerWheelSinkMul = isCrawlerWheel and (FieldGroundMudPhysics.crawlerMudWheelSinkMul or 0.38) or 1.0
				local crawlerRadiusMinBoost = isCrawlerWheel and (FieldGroundMudPhysics.crawlerMudRadiusMinBoost or 0.28) or 0.0
				local crawlerGripMul = isCrawlerWheel and (FieldGroundMudPhysics.crawlerMudGripMul or 1.18) or 1.0

				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 = fgGetWetnessEffectiveCached(groundWetness, dt)

				local hardWinterFrozen = (FieldGroundMudPhysics.isHardWinterFrozen ~= nil and FieldGroundMudPhysics:isHardWinterFrozen())
				if hardWinterFrozen then
					wetnessEff = 0
				end
				
				local hasRealContact = FieldGroundMudPhysics:hasWheelFieldGroundContact(self.wheel)
				local mudBase, gt, prof = FieldGroundMudPhysics:getWheelGroundProfile(self.wheel)
				wetnessEff = fgLocalWetnessAt(wx, wz, wetnessEff, dt, prof)
				mudBase = mudBase or 0

				local onMudProfile = hasRealContact and (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 speedKph = fgGetVehicleSpeedKph(self.vehicle)

				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 * crawlerGripMul, mn, math.max(mx, crawlerGripMul))
						if TirePressureSystem ~= nil and TirePressureSystem.getMudGripMultiplier ~= nil then
							gripMul = gripMul * TirePressureSystem:getMudGripMultiplier(self)
						end
					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 wheelLoadData = (WheelLoadSystem ~= nil and WheelLoadSystem.getWheelLoadData ~= nil)
					and WheelLoadSystem:getWheelLoadData(self.vehicle, self) or nil
				local wheelLoadFactor = wheelLoadData ~= nil and (wheelLoadData.loadFactor or 1.0) or 1.0
				self.__fgWheelLoadKg = wheelLoadData ~= nil and wheelLoadData.loadKg or nil
				self.__fgWheelLoadFactor = wheelLoadFactor
				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 = minFactor + (1 - minFactor) * crawlerRadiusMinBoost
				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 radiusWetK = fgRadiusWetnessStrength(wx, wz, wetnessEff, dt, prof)

				local effMudRaw = 0
				if onMudProfile and prof ~= nil and radiusWetK > 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 radiusWetK > 0 then
					local sinkMul = prof.sinkMul or 1.0

					local speedResponseKph = speedKph * (FieldGroundMudPhysics.legacySpeedResponseScale or 3.6)
					local moving01   = clamp(speedResponseKph / 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 pressureSinkMul = (TirePressureSystem ~= nil and TirePressureSystem.getSinkMultiplier ~= nil)
						and TirePressureSystem:getSinkMultiplier(self) or 1.0
					local pressureRadiusSinkMul = (TirePressureSystem ~= nil and TirePressureSystem.getRadiusSinkMultiplier ~= nil)
						and TirePressureSystem:getRadiusSinkMultiplier(self) or 1.0
					local kRaw = effMudRaw * sinkMul * (0.10 + moveFactor * (0.85 + (stV.sink or 0) * 0.55)) * wheelLoadFactor * (1 - widthBonus) * crawlerWheelSinkMul * pressureSinkMul * pressureRadiusSinkMul
					local radiusMul = fgRadiusEffectMulAt(wx, wz, wetnessEff, dt, prof)
					local k = clamp(kRaw * radiusMul, 0, 1) * radiusWetK

					local pressureMaxExtra = maxExtra * pressureRadiusSinkMul
					targetExtraMove = pressureMaxExtra * k
					targetExtraMove = clamp(targetExtraMove, 0, pressureMaxExtra)
				end

				if FieldGroundMudPhysics.wheelBrakeEnable then
					local wheels = self.vehicle.spec_wheels ~= nil and self.vehicle.spec_wheels.wheels or nil
					local numWheels = wheels ~= nil and #wheels or 0
					local vehicleBrake = (self.vehicle.getBrakeForce ~= nil and self.vehicle:getBrakeForce()) or 0
					local perWheelBase = (numWheels > 0) and (vehicleBrake / numWheels) or 0
					local profileBrake = prof ~= nil and (prof.brakeMul or 1.0) or 1.0
					local brakeProfileMul = 1.0 + math.max(0, profileBrake - 1.0) * 0.35
					local brakeMul = brakeProfileMul * (isCrawlerWheel and (FieldGroundMudPhysics.crawlerMudBrakeMul or 0.36) or 1.0)
					if TirePressureSystem ~= nil and TirePressureSystem.getMudBrakeMultiplier ~= nil then
						brakeMul = brakeMul * TirePressureSystem:getMudBrakeMultiplier(self)
					end
					local targetBrake = 0
					if onMudProfile and prof ~= nil and wetK > 0 and perWheelBase > 0
						and effMudRaw >= (FieldGroundMudPhysics.wheelBrakeMinEff or 0.08) then
						local model = _G.__MudTerramechanicsModel
						if model ~= nil and stV.__fgSoilState ~= nil then
							targetBrake = model:getResistanceRatio(
								stV.__fgSoilState, speedKph,
								FieldGroundMudPhysics.wheelBrakeBase,
								FieldGroundMudPhysics.wheelBrakeFromSink,
								FieldGroundMudPhysics.wheelBrakeFromSlip,
								FieldGroundMudPhysics.wheelBrakeRatio or 0.034,
								brakeMul,
								FieldGroundMudPhysics.wheelBrakeMaxRatio or 2.40,
								slip or 0
							)
						else
							local brakeMudK = clamp(wetK*effMudRaw, 0, 1)
							targetBrake = (FieldGroundMudPhysics.wheelBrakeRatio or 0.034)*brakeMudK*brakeMul*(
								(FieldGroundMudPhysics.wheelBrakeBase or 0)*0.35
								+(FieldGroundMudPhysics.wheelBrakeFromSink or 0)*(stV.sink or 0)
								+(FieldGroundMudPhysics.wheelBrakeFromSlip or 0)*(slip or 0)
							)
						end
						targetBrake = clamp(targetBrake, 0, FieldGroundMudPhysics.wheelBrakeMaxRatio or 2.40)
					end
					local curBrake = self.__fgBrakeCur or 0
					local inBrake = math.max(0.05, FieldGroundMudPhysics.wheelBrakeSmoothIn or 2.1)
					local outBrake = math.max(0.05, FieldGroundMudPhysics.wheelBrakeSmoothOut or 0.40)
					if targetBrake > curBrake then
						curBrake = math.min(targetBrake, curBrake + inBrake * dtS)
					else
						curBrake = math.max(targetBrake, curBrake - outBrake * dtS)
					end
					self.__fgBrakeCur = curBrake
					self.__fgMudBrakeExtra = perWheelBase * curBrake
				else
					self.__fgBrakeCur = 0
					self.__fgMudBrakeExtra = 0
				end

				do
					if FieldGroundMudPhysics.eraseFruitEnable
						and (wetnessEff or 0) >= (FieldGroundMudPhysics.eraseFruitMinWetness or 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
									FieldGroundMudPhysics:applyCropDestructionArea(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
				local permaAffectsWheel = stV.permaActive == true
					and (
						FieldGroundMudPhysics.permaStuckSpreadOnlyInMud ~= true
						or onMudProfile
					)
				if permaAffectsWheel then
					local releaseStart = math.max(0, FieldGroundMudPhysics.permaStuckReleaseDistanceStartM or 3.0)
					local releaseFull = math.max(releaseStart + 0.01, FieldGroundMudPhysics.permaStuckReleaseDistanceFullM or 5.0)
					local releaseDist = stV.permaReleaseDist or 0
					permaReleaseBlend = clamp((releaseDist - releaseStart) / (releaseFull - releaseStart), 0, 1)

					if permaReleaseBlend >= 0.999 then
						fgClearPermaStuck(stV, self.vehicle)
					else
						local factor = clamp(stV.permaRadiusFactor or FieldGroundMudPhysics.permaStuckRadiusFactor or 0.62, 0.45, 0.90)
						local forcedRadius = math.max(0.12, r0 * factor)
						permaForcedTarget = math.max(0, r0 - forcedRadius)
					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 TirePressureSystem ~= nil and TirePressureSystem.getSinkOutMultiplier ~= nil then
					outSpd = outSpd * TirePressureSystem:getSinkOutMultiplier(self)
				end

				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()
	
	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
	

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

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

    fgDeleteAllVehicleExtraPS()

    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

    if MudExtraParticlePresets ~= nil
        and MudExtraParticlePresets.flushRetiredEmitterShapes ~= nil then
        MudExtraParticlePresets.flushRetiredEmitterShapes()
    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
