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


MudPhysics = {}
MudPhysics.name = g_currentModName or "MudPhysics"
MudPhysics.path = g_currentModDirectory or ""

addModEventListener(MudPhysics)

-- =========================================================
-- Настройки
-- =========================================================

MudPhysics.enabled = true
MudPhysics.debug   = false

-- ---------------------------------------------------------------------------
-- Влажность / "мокро" для WheelEffects и грязевых частиц
-- ---------------------------------------------------------------------------
MudPhysics.wetnessThreshold       = 0.10    -- Порог "мокроты" (0..1) для WheelEffects: если влажность ниже — мокрые/грязевые эффекты почти не включаются.
MudPhysics.rainForcesWetnessMin   = 0.10    -- Минимальная сила дождя (0..1), при которой мы считаем поверхность реально мокрой (форсим wetness).
MudPhysics.rainScaleThreshold     = 0.02    -- Минимальное "количество дождя/интенсивность" (зависит от игры), ниже этого дождь считаем слишком слабым и wetness не усиливаем.

-- ---------------------------------------------------------------------------
-- ОПРОС ЗЕМЛИ (throttle) - уменьшает нагрузку CPU
-- ---------------------------------------------------------------------------
MudPhysics.groundSampleIntervalMs = 250    -- как часто можно заново читать слои/ground под колесом (мс). 0 = каждый тик
MudPhysics.groundSampleDistanceM  = 0.40   -- или читать заново только если колесо сместилось дальше (в метрах). 0 = без дистанционного гейта

-- ---------------------------------------------------------------------------
-- PRIORITY: поля важнее кистей MudPhysics
-- Если под колесом groundType из FieldGroundMudPhysics.groundProfiles -> кисти MUD/DIRT/WATER игнорируются
-- ---------------------------------------------------------------------------
MudPhysics.disableBrushOnFields = true

-- ---------------------------------------------------------------------------
-- Поиск грязевых слоёв на карте (terrain layers)
-- ---------------------------------------------------------------------------
MudPhysics.mudNameNeedles = { "MUD", "DIRT", "WATER" }   -- Ключевое слово для поиска слоёв грязи по имени (регистронезависимо).
MudPhysics.ignoreNameNeedles = { "CONCRETE", "STONE", "STONES", "GRAVEL" } -- Слои, которые игнорируем даже если в имени есть MUD/DIRT (твёрдые/каменные/бетонные)

MudPhysics.useDIRTBrushes = true -- Использовать ли кисти террайна DIRT (слои, в имени которых есть "DIRT")

MudPhysics.mudMinToAffect         = 0.10    -- Минимальный вес слоя (0..1), чтобы считать, что колесо реально "в грязи". Больше = реже срабатывает, меньше = чаще/агрессивнее.
MudPhysics.dirtLayerEffectMult 	  = 0.50   -- DIRT будет слабее MUD (0.5 = 50%)

-- ---------------------------------------------------------------------------
-- FROZEN MUD (temperature)
-- ---------------------------------------------------------------------------
MudPhysics.freezeMudByTempEnable = true
MudPhysics.freezeMudTempC        = -3.0   -- если температура ниже этого => грязь "замерзает"
MudPhysics.freezeMudMult         = 0.05   -- 6% от силы грязи


-- ---------------------------------------------------------------------------
-- HARD WINTER FREEZE (disable all dirt/mud wet reaction by temperature)
--  Когда включено и температура ниже порога => wetnessEff=0, грязь/мокрые слои/брызги не работают (как "лёд/снег")
-- ---------------------------------------------------------------------------
MudPhysics.freezeAllLayersInWinterEnable = true
MudPhysics.freezeAllLayersTempC          = -1.0   -- ниже этого (°C) считаем, что грязь замёрзла и не реагирует


-- ---------------------------------------------------------------------------
-- WINTER SLIP (temperature): меньше трения => больше скольжение
-- ---------------------------------------------------------------------------
MudPhysics.winterSlipEnable = true
MudPhysics.winterSlipTempC  = -3.0
MudPhysics.winterSlipMul    = 1.15  -- множитель сцепления зимой/на снегу: меньше 1 = скользче, больше 1 = цепче

MudPhysics.normalSlipEnable = true
MudPhysics.normalSlipMul    = 0.94  -- тепло: тоже можно чуть скользче (1.00 = как ваниль)

-- ---------------------------------------------------------------------------
-- FROZEN MUD: отключать доп. партиклы (второй слой) когда грязь замёрзла
-- ---------------------------------------------------------------------------
MudPhysics.disableExtraParticlesWhenFrozen = true

-- ---------------------------------------------------------------------------
-- RAIN SLIP (ALL SURFACES)
-- ---------------------------------------------------------------------------
MudPhysics.rainSlipEnable        = true
MudPhysics.rainSlipWetnessMin   = 0.25   -- с какой влажности начинается эффект
MudPhysics.rainSlipMul          = 0.85   -- 0.85 = -15% трения (асфальт/трава)
MudPhysics.rainSlipMaxMul       = 0.69   -- максимум при сильном дожде

-- ---------------------------------------------------------------------------
-- Состояние "зарывания" / bog down (общий показатель 0..1)
-- st.sink (0..1) растёт при пробуксовке в грязи и падает при нормальном движении
-- ---------------------------------------------------------------------------
MudPhysics.sinkInSpeed            = 2.05    -- Скорость нарастания "зарывания" (в сек^-1).
MudPhysics.sinkOutSpeed           = 1.75    -- Скорость уменьшения "зарывания" (в сек^-1). 
MudPhysics.legacySpeedResponseScale = 3.6   -- Сохраняет прежнюю настройку динамики sink после исправления единиц getLastSpeed().
MudPhysics.stuckThreshold         = 0.92    -- Глубокий, но достижимый порог st.sink (0..1) для состояния "застряла".
MudPhysics.maxSpeedMudKph         = 38.0    -- Мягкая страховочная скорость в очень тяжёлой грязи; основное замедление даёт сопротивление.
MudPhysics.maxSpeedStuckKph       = 13.5    -- Мягкий предел только при глубоком зарывании.
MudPhysics.terramechanicsEnterSec = 1.35    -- Нарастание сопротивления после въезда в слабый грунт.
MudPhysics.terramechanicsExitSec  = 2.25    -- Остаточное сопротивление при выходе из колеи.
MudPhysics.motorLoadEnterSec      = 1.10    -- Плавность набора нагрузки двигателя.
MudPhysics.motorLoadExitSec       = 1.85    -- Плавность снятия нагрузки двигателя.

-- ---------------------------------------------------------------------------
-- Ограничения зарывания, для разных размеров колёс
-- ---------------------------------------------------------------------------
MudPhysics.smallWheelRadiusThreshold = 0.60  -- м: что считаем "маленьким колесом"
MudPhysics.smallWheelMinRadiusAbs    = 0.295  -- м: абсолютный минимум радиуса для маленьких

-- НОВОЕ: супер-маленькие колёса (чтобы тоже было утопление)
MudPhysics.tinyWheelRadiusThreshold  = 0.305  -- м: если оригинальный радиус меньше этого -> используем tinyWheelMinRadiusAbs
MudPhysics.tinyWheelMinRadiusAbs     = 0.115  -- м: абсолютный минимум радиуса для tiny-колёс
-- ---------------------------------------------------------------------------
-- MUD RAMP (smooth engage/disengage)
-- ---------------------------------------------------------------------------
MudPhysics.mudRampInSec   = 9.0    -- за сколько секунд эффект грязи выходит на полную (въезд)
MudPhysics.mudRampOutSec  = 3.5    -- за сколько секунд эффект уходит в ноль (выезд)
MudPhysics.mudRampPow     = 1.65   -- кривая: 1=линейно, >1 = мягче в начале, резче ближе к концу

-- ---------------------------------------------------------------------------
-- Дополнительная "просадка" колеса через WheelPhysics (визуально + немного ощущения)
-- Это НЕ настоящий радиус-коллизия, а подстройка sink у физики колёс.
-- ---------------------------------------------------------------------------
MudPhysics.extraWheelSinkEnable   = true
MudPhysics.extraWheelSinkMax      = 7.25    -- Максимальная добавочная "просадка" (метры) сверх ванильного sink на одно колесо. -- Слишком большое значение может визуально ломать подвеску.                                             
MudPhysics.extraWheelSinkSpeedIn  = 2.95    -- Как быстро нарастает extra sink (в сек^-1). Больше = быстрее проваливается.
MudPhysics.extraWheelSinkSpeedOut = 0.85    -- Как быстро уменьшается extra sink (в сек^-1). Больше = быстрее "вылезает".
-- Умная нормализация зарывания по размеру колеса
MudPhysics.extraWheelSinkMaxRel      = 0.32  -- максимум доп. зарывания как доля радиуса колеса (0..1). 0.28 = до 28% r0
MudPhysics.radiusMinFactorRelBias    = 0.72  -- 0..1: насколько minRadius подстраивать под rel-логику (0=как было, 1=сильнее смягчаем для больших колёс)

-- ---------------------------------------------------------------------------
-- "Настоящее" зарывание через уменьшение радиуса колеса (REAL physics ощущение)
-- Мы уменьшаем эффективный радиус, создавая реальный эффект "колесо глубже в грунте".
-- ---------------------------------------------------------------------------
MudPhysics.radiusMinFactor        = 0.52    -- Минимальный множитель радиуса (0..1). 0.49 значит: радиус не упадёт ниже 49% исходного.Меньше = сильнее "зарывание", но риск сломать физику/просесть слишком глубоко.
MudPhysics.radiusSinkInSpeed      = 0.12    -- Скорость "зарывания" радиуса (м/сек к целевому значению). Больше = быстрее тонет.
MudPhysics.radiusSinkOutSpeed     = 0.17    -- Скорость восстановления радиуса (м/сек к исходному). Меньше = быстрее отпускает.

-- ---------------------------------------------------------------------------
-- RARE "PERMA-STUCK" EVENT (wheel sinks almost fully until leaving mud)
-- ---------------------------------------------------------------------------
MudPhysics.permaStuckEnable           	= true
MudPhysics.permaStuckChanceOnStruggle 	= 0.005   -- шанс В СЕКУНДУ (0.2% ≈ ОЧЕНЬ редко)
MudPhysics.permaStuckSlipMin          	= 0.10    -- считаем борьбой только если slip >=
MudPhysics.permaStuckSinkMin          	= 0.35    -- и общая глубина уже приличная
MudPhysics.permaStuckRadiusFactor     	= 0.37    -- 37% от оригинального радиуса
MudPhysics.permaStuckCooldownMin      	= 45.0    -- сек, минимальный кулдаун на колесо
MudPhysics.permaStuckCooldownMax		= 120.0   -- сек, чтобы не ловить подряд
MudPhysics.permaStuckSpreadMs			= 1255   -- каждые *мс "заражаем" остальные колёса perma-stuck
MudPhysics.extraParticleMinSpeedKphPerma = 0.9   -- когда PERMA-STUCK активен, разрешаем доп. партиклы почти на месте
MudPhysics.permaStuckRampInSec        	= 3.75   -- за сколько секунд плавно "утопить" колесо до permaStuckRadiusFactor
MudPhysics.permaStuckRampOutSec       	= 1.80   -- за сколько секунд обратно (если хочешь мягкое восстановление в момент выхода из perma-stuck)
MudPhysics.permaStuckReleaseDistanceStartM = 6.00 -- если после посадки на пузо технику протащили дальше этого расстояния, начинаем отпускать perma-stuck
MudPhysics.permaStuckReleaseDistanceFullM  = 8.00 -- после такого смещения perma-stuck полностью отдаёт управление обычной грязевой логике
MudPhysics.permaStuckReleaseOutSpeedMul    = 1.15 -- слегка ускоряем выход из perma-stuck, когда технику реально уже протащили, но без резкого самовыталкивания
MudPhysics.permaStuckNotificationsEnable   = true -- показывать уведомления, когда техника намертво садится в грязь

-- ---------------------------------------------------------------------------
-- АНТИ-ДРОЖЬ: когда стоим в грязи (почти 0 скорость и slip) — не "пульсируем" радиусом
-- ---------------------------------------------------------------------------
MudPhysics.freezeRadiusWhenStopped    = true
MudPhysics.freezeStopSpeedKph         = 0.60    -- считаем "стоим", если скорость ниже (км/ч)
MudPhysics.freezeStopSlip             = 0.010   -- и пробуксовка ниже этого
MudPhysics.freezeSettleSeconds        = 0.45    -- сколько секунд даём "досесть" до target, потом фиксируем
MudPhysics.freezeRadiusEps            = 0.0015  -- м: игнор мелких изменений радиуса (анти-джиттер)

-- ---------------------------------------------------------------------------
-- ONLY WHEN MOVING (hard gate): когда техника стоит -> вообще ничего не считаем
-- ---------------------------------------------------------------------------
MudPhysics.onlyProcessWhenMoving   = true

-- Используем твои пороги остановки (можно отдельно, но лучше так)
MudPhysics.movingSpeedKph          = nil     -- если nil => берём freezeStopSpeedKph
MudPhysics.movingSlip              = nil     -- если nil => берём freezeStopSlip

-- Чтобы не оставались "висящие" эффекты частиц, когда остановился
MudPhysics.stopTurnsOffParticles   = true

-- ---------------------------------------------------------------------------
-- "Повезло, слой тверже" — случайное облегчение (сервер, рандом)
-- Иногда даём небольшой "откат" зарывания, чтобы можно было выбраться.
-- ---------------------------------------------------------------------------
MudPhysics.reliefChancePerSec     = 0.028    -- Шанс в секунду получить облегчение (0..1).
MudPhysics.reliefStrength         = 0.22    -- Насколько откатываем глубину (0..1 от текущего st.sink). 0.22 = откатить 22% текущей глубины.
MudPhysics.reliefBrakeSeconds     = 1.80    -- Сколько секунд после relief мы ослабляем торможение/вязкость.
MudPhysics.reliefBrakeMult        = 1.65    -- Множитель "вязкого торможения" во время relief (меньше = легче выехать).

-- ---------------------------------------------------------------------------
-- Неровность грязи по карте: "пятна" и колебания (ощущение, что грязь разная)
-- ---------------------------------------------------------------------------
MudPhysics.mudVarStrength = 0.47            -- Сила вариации (0..1): 0 = всегда одинаково, 1 = сильно разные участки.
MudPhysics.mudVarCell     = 12.55             -- Размер "пятен" в метрах. Больше = пятна крупнее и реже меняется характер грязи.
MudPhysics.mudBobAmp      = 0.26            -- Амплитуда "то топит, то отпускает" (0..1). Больше = сильнее качает глубину.
MudPhysics.mudBobFreq     = 0.16            -- Частота этих колебаний (Гц). 0.16 ≈ один цикл ~6.25 секунды.

-- ---------------------------------------------------------------------------
-- Нагрузка на двигатель (через VehicleMotor:setExternalTorqueVirtualMultiplicator)
-- Делает эффект "вязко, двигатель тяжело тянет", падает тяга/ускорение.
-- ---------------------------------------------------------------------------
MudPhysics.motorLoadEnable        = true
MudPhysics.motorLoadFromSink      = 4.0     -- Насколько сильно глубина (st.sink) увеличивает нагрузку на мотор.
MudPhysics.motorLoadFromSlip      = 1.8     -- Насколько сильно пробуксовка (slip) увеличивает нагрузку.
MudPhysics.motorLoadMaxMult       = 4.0     -- Физически разумный предел эквивалентной потери мощности (до 72%).
MudPhysics.motorLoadMinEffMud     = 0.10    -- Минимальная сила грязи effMud (0..1), ниже которой нагрузку на мотор не добавляем.

-- ---------------------------------------------------------------------------
-- Частицы (грязь/вода от колёс)
-- ---------------------------------------------------------------------------
MudPhysics.particlesEnable        = true
MudPhysics.emitMultWetMud         = 22.5    -- Множитель интенсивности эмиссии частиц в мокрой грязи (сколько "летит").
MudPhysics.sizeMultWetMud         = 2.1     -- Множитель размера частиц в мокрой грязи (насколько крупные брызги).
MudPhysics.speedMultWetMud        = 2.15    -- Множитель стартовой скорости частиц (как "высоко/далеко" улетает).

-- Дополнительная частица
MudPhysics.extraParticlesEnable     = true
MudPhysics.extraParticlesPreset     = 4
MudPhysics.extraParticlesI3D        = "particleSystems/mud.i3d"
MudPhysics.extraEmitterShapeI3D     = "particleSystems/mudEmitShape.i3d"
MudPhysics.extraParticleContactTol = 0.12

MudPhysics.extraParticleOnlyWetMud  = true   -- включать только когда wetMud=true
MudPhysics.extraParticleOffsetY     = -0.22   -- на сколько выше слоя/земли (метры)
MudPhysics.extraParticleOffsetZ     = 0.35   -- НОВОЕ: смещение точки спавна по Z (метры) в локале машины. + = вперёд, - = назад
MudPhysics.extraParticleClipDist    = 90
MudPhysics.extraParticleMaxCount    = 900   -- можно поднять/опустить под FPS (не работает)
MudPhysics.extraParticleMinSpeedKph = 7.2   -- ниже этой скорости доп. партиклы НЕ летят

-- Дополнительное формирование движения частиц
MudPhysics.extraParticleBurstEnable        = true
MudPhysics.extraParticleOffsetMoveMax      = 0.18  -- меньше, стабильнее
MudPhysics.extraParticleOffsetMoveKphFull  = 14.0  -- чтобы не “врубалось” на 2-3 км/ч
MudPhysics.extraParticleBurstSlipMin       = 0.15  -- если slip выше — можем "плевать" вверх
MudPhysics.extraParticleBurstChancePerSec  = 0.20  -- шанс/сек сделать бурст при slip
MudPhysics.extraParticleBurstYOffset       = 0.08  -- добавка по Y (м) во время бурста
MudPhysics.extraParticleBurstTimeMin       = 0.12  -- бурст короткий, как “плевок”
MudPhysics.extraParticleBurstTimeMax       = 0.18
MudPhysics.extraParticleBurstForwardMul    = 0.95  -- НОВОЕ: бурст в основном летит по ходу основного выброса
MudPhysics.extraParticleBurstUpMul         = 0.22  -- НОВОЕ: только небольшой подъём, без "свечки"
MudPhysics.extraParticleBurstSlipFull      = 0.65  -- при каком slip считаем бурст "максимально злым"
MudPhysics.extraParticleBurstSpeedFullKph  = 22.0  -- при какой скорости бонус дальности/частоты выходит на максимум
MudPhysics.extraParticleBurstChanceSlipMul = 2.10  -- насколько slip разгоняет частоту плевков
MudPhysics.extraParticleBurstChanceSpeedMul= 0.85  -- насколько скорость добавляет шанс плевка
MudPhysics.extraParticleBurstChanceStillMul= 1.35  -- дополнительный буст шанса, если буксуем почти на месте
MudPhysics.extraParticleBurstForwardSlipMul= 0.85  -- насколько slip усиливает бросок вперёд
MudPhysics.extraParticleBurstUpSlipMul     = 0.12  -- насколько slip усиливает бросок вверх
MudPhysics.extraParticleBurstSpeedMul      = 1.10  -- временный буст скорости частиц во время плевка
MudPhysics.extraParticleBurstEmitMul       = 0.90  -- временный буст эмиссии во время плевка

MudPhysics.extraParticleOffsetMoveKphFullFwd = 8.0 -- вперёд: при какой скорости смещение достигает максимума
MudPhysics.extraParticleOffsetMoveKphFullRev = 2.0 -- задний ход: меньшее значение → быстрее достигается максимум при движении назад
MudPhysics.extraParticleOffsetMoveDeadzoneKph = 0.55 -- ниже этой подписанной скорости → считается остановкой (без направления)
MudPhysics.extraParticleOffsetMoveReverseMul = 1.05 -- дополнительный толчок для заднего хода (опционально)

MudPhysics.extraParticleOffsetMoveMinFwd = 0.00  -- вперёд: обычно 0
MudPhysics.extraParticleOffsetMoveMinRev = 0.11  -- назад: 0.05..0.20 обычно достаточно
MudPhysics.extraParticleOffsetMoveMaxFwd = 0.18  -- вперёд
MudPhysics.extraParticleOffsetMoveMaxRev = 0.08  -- назад (можно чуть больше, напр. 0.26)

-- ---------------------------------------------------------------------------
-- Налипание грязи на технику
-- BODY = кузов/общая грязь
-- WHEEL = колёса/меши mudAmount
-- ---------------------------------------------------------------------------
MudPhysics.dirtEnable             = true
MudPhysics.dirtMinEffMud          = 0.06    -- С какого effMud (0..1) вообще начинаем добавлять грязь. Ниже — считаем, что грязи мало.
MudPhysics.dirtWetnessMin         = 0.18    -- Минимальная "мокрота" (0..1), чтобы грязь реально налипала (в сухую грязь можно поставить ниже).
MudPhysics.dirtBodyPerSec         = 0.012   -- Скорость загрязнения кузова в секунду при effMud=1.0 (масштаб 0..1 грязи).
MudPhysics.dirtWheelPerSec        = 0.075   -- Скорость загрязнения колёс в секунду при effMud=1.0 (быстрее кузова).
MudPhysics.dirtSpeedBoostKph      = 15.0    -- Влияние скорости (км/ч): чем больше значение, тем слабее ускорение загрязнения от скорости. Меньше = грязь налипает заметно быстрее на ходу.
MudPhysics.dirtSpeedBoostKph      = 15.0    -- Влияние скорости (км/ч): чем больше значение, тем слабее ускорение загрязнения от скорости. Меньше = грязь налипает заметно быстрее на ходу.
MudPhysics.dirtMax                = 1.00    -- Максимум грязи (0..1). 1 = полностью грязный.

-- ---------------------------------------------------------------------------
-- ВОДА КАК ГРЯЗЬ (контакт с водой = тонем как в mud даже без кистей)
-- ---------------------------------------------------------------------------
MudPhysics.waterActsAsMud            = true   -- Главный тумблер: вода считается как mud.
MudPhysics.waterMudFactor            = 1.00   -- Какой "mudHere" выставлять в воде (0..1). 1.0 = максимально вязко.
MudPhysics.waterForcesWetness        = true   -- В воде wetnessEff принудительно = 1.0.
MudPhysics.waterDisableDeepPocket    = true   -- Отключить "глубокие ямы" в воде.
MudPhysics.waterDisableMudVar        = true   -- Отключить вариации/качку (mudVar/bob) в воде.
MudPhysics.waterDisableDirt          = true   -- Не наносить грязь на технику/колёса, когда источник = вода.
MudPhysics.waterDisableExtraParticles= false   -- Не запускать наши extra mud particles при контакте с водой.

MudPhysics.waterFixedRadiusEnable = true
MudPhysics.waterFixedRadiusFactor = 0.80   -- -20% от базового радиуса
MudPhysics.waterFixedRadiusRampInSec  = 3.75  -- за сколько секунд плавно "вжать" радиус при контакте с водой
MudPhysics.waterFixedRadiusRampOutSec = 4.50  -- за сколько секунд плавно вернуть радиус после выхода из воды


-----------------------------------------------------------------------------
-- "Глубокая яма" — случайная глубокая точка при въезде в грязь.
-- Делает иногда резкое проваливание, чтобы грязь была "типо живой".
-----------------------------------------------------------------------------
MudPhysics.deepPocketChanceOnEnter = 0.0034   -- Шанс (0..1) сработать "яме" в момент, когда колесо впервые попало в грязь.
MudPhysics.deepPocketDurationMin   = 0.95   -- Минимальная длительность ямы (сек).
MudPhysics.deepPocketDurationMax   = 1.55   -- Максимальная длительность ямы (сек).
MudPhysics.deepPocketTargetBoost   = 0.28   -- Насколько увеличить целевую глубину (доля от extraWheelSinkMax) во время ямы.
MudPhysics.deepPocketInSpeedMul    = 2.25    -- Во сколько раз ускорить "въедание" (скорость sink-in) пока яма активна.
MudPhysics.deepPocketBiasByPatch   = 0.58   -- Привязка к "жёстким/плохим" пятнам (0..1): больше = чаще ямы именно в тяжёлой грязи.

-- ---------------------------------------------------------------------------
-- "Глубокая яма" уже во время нахождения в грязи.
-- Рандомно может "дёрнуть" глубже, когда колесо реально борется.
-- ---------------------------------------------------------------------------
MudPhysics.deepPocketChancePerSecInMud = 0.022  -- Шанс/сек (0..1) словить яму, когда уже едем по грязи.
MudPhysics.deepPocketCooldownMin       = 2.86   -- Минимальный кулдаун (сек) между ямами на одном колесе.
MudPhysics.deepPocketCooldownMax       = 4.86   -- Максимальный кулдаун (сек) между ямами на одном колесе.
MudPhysics.deepPocketStruggleSlip      = 0.05  -- Считаем "борьбу" если slip выше этого порога.
MudPhysics.deepPocketStruggleSink      = 0.25  -- ИЛИ если st.sink выше этого порога (уже прилично зарылись).
MudPhysics.deepPocketBiasByPatchInMud  = 0.85  -- Как сильно "жёсткие" пятна увеличивают шанс ям в процессе (0..1).

-- ---------------------------------------------------------------------------
-- Реальное замедление через добавление "вязкого торможения" в brakeForce (per wheel)
-- Это один из самых заметных параметров для "скорость режется".
-- ---------------------------------------------------------------------------
MudPhysics.wheelBrakeEnable     = true
MudPhysics.wheelBrakeBase       = 2.75      -- Базовая вязкость в грязи: чем выше, тем сильнее "тянет назад" даже при малой глубине.
MudPhysics.wheelBrakeFromSink   = 6.05     -- Дополнительная вязкость от глубины st.sink (чем глубже, тем сильнее держит).
MudPhysics.wheelBrakeFromSlip   = 4.25      -- Дополнительная вязкость от пробуксовки slip (газуешь — ещё сильнее вязнет).
MudPhysics.wheelBrakeRatio      = 0.038     -- Перевод wheelBrake* в долю от штатной силы тормозов техники (vehicle:getBrakeForce()). Больше = сильнее замедление от тех же wheelBrake*.
MudPhysics.wheelBrakeMaxRatio   = 2.50     -- Максимальный предел доли (на колесо), чтобы техника не превращалась в "бетон" и не клинила физику.
MudPhysics.wheelBrakeMinEffMud  = 0.08     -- С какого effMud (0..1) включаем вязкость. Ниже — никакого доп. торможения.

-- ---------------------------------------------------------------------------
-- BRAKE LIVENESS (smooth + wandering + rare hard lock)
-- ---------------------------------------------------------------------------
MudPhysics.wheelBrakeSmoothIn        = 2.22    -- как быстро НАРАСТАЕТ торможение (ratio/sec)
MudPhysics.wheelBrakeSmoothOut       = 0.42    -- плавное снятие сопротивления после выхода из колеи (ratio/sec)

MudPhysics.wheelBrakeWanderEnable    = true
MudPhysics.wheelBrakeWanderMin       = 0.68   -- нижняя граница "гуляния" (множитель к baseRatio)
MudPhysics.wheelBrakeWanderMax       = 1.28   -- верхняя граница "гуляния"
MudPhysics.wheelBrakeWanderPeriodMin = 1.6    -- сек: как часто может меняться цель (минимум)
MudPhysics.wheelBrakeWanderPeriodMax = 3.4    -- сек: (максимум)

MudPhysics.wheelBrakeHardLockEnable      = true
MudPhysics.wheelBrakeHardLockChancePerSec= 0.020  -- шанс/сек стать "жёстким" (очень редко)
MudPhysics.wheelBrakeHardLockTimeMin     = 0.55   -- сек
MudPhysics.wheelBrakeHardLockTimeMax     = 1.25   -- сек
MudPhysics.wheelBrakeHardLockMul         = 1.45   -- насколько сильнее baseRatio, когда "закусило"

-- =========================================================
-- бонусы ширины колеса
-- =========================================================
MudPhysics.widthBonusEnable   = true
MudPhysics.widthBonusRefWidth = 0.62
MudPhysics.widthBonusStrength = 1.45
MudPhysics.widthBonusMax      = 0.35

-- ---------------------------------------------------------------------------
-- CRAWLER / TRACKED VEHICLES
-- ---------------------------------------------------------------------------
MudPhysics.crawlerMudSupportEnable       = true
MudPhysics.crawlerMudSinkMul             = 0.68
MudPhysics.crawlerMudWheelSinkMul        = 0.70
MudPhysics.crawlerMudRadiusMinBoost      = 0.12
MudPhysics.crawlerMudBrakeMul            = 0.40
MudPhysics.crawlerMudMotorLoadMul        = 0.55
MudPhysics.crawlerMudSpeedLimitMul       = 1.45
MudPhysics.crawlerMudPermaStuckChanceMul = 0.18
MudPhysics.crawlerMudDeepPocketChanceMul = 0.35

-- ---------------------------------------------------------------------------
-- Логи / отладка
-- ---------------------------------------------------------------------------
MudPhysics.logEnterMud            = false   -- Писать в лог событие, когда колесо впервые заехало в грязевой слой.
MudPhysics.logEnterMudCooldownMs  = 350    -- Анти-спам (мс) для logEnterMud на одно колесо.
MudPhysics.debugPrintIntervalMs   = 700    -- Интервал (мс) для периодических отладочных сводок, когда MudPhysics.debug=true.

local function mpGetSmallWheelMinAbs(r0)
    if MudPhysics == nil then return nil end
    if type(r0) ~= "number" or r0 ~= r0 then return nil end

    local smallMin = MudPhysics.smallWheelMinRadiusAbs
    local tinyTh   = MudPhysics.tinyWheelRadiusThreshold
    local tinyMin  = MudPhysics.tinyWheelMinRadiusAbs

    if type(tinyTh) == "number" and type(tinyMin) == "number"
        and tinyTh > 0 and tinyMin > 0
        and r0 < tinyTh then
        return tinyMin
    end

    return smallMin
end

-- =========================================================
-- Глобальный комбайнер (на оба скрипта)
-- =========================================================
_G.__MudRadiusCombiner = _G.__MudRadiusCombiner or {}

function _G.__MudRadiusCombiner.apply(wp, eps)
    if wp == nil then
        return
    end

    local radiusVehicle = wp.vehicle
    if radiusVehicle ~= nil
        and (radiusVehicle.__mudSystemDeleting == true
            or radiusVehicle.isDeleting == true
            or radiusVehicle.isDeleted == true) then
        return
    end

    local r0 = nil
    local wheel = wp.wheel
    local function considerOriginal(candidate)
        if type(candidate) == "number" and candidate == candidate and candidate > 0.05 then
            r0 = r0 == nil and candidate or math.max(r0, candidate)
        end
    end
    considerOriginal(wp.__mpOrigRadius)
    considerOriginal(wp.__fgOrigRadius)
    considerOriginal(wp.__tpOrigRadius)
    considerOriginal(wp.__punctureOrigRadius)
    considerOriginal(wp.__ttdOrigRadius)
    considerOriginal(wp.__uytOrigRadius)
    considerOriginal(wp.radiusOriginal)
    considerOriginal(wheel ~= nil and wheel.ttdOriginalRadius or nil)
    considerOriginal(wp.radius)
    if type(r0) ~= "number" or r0 ~= r0 or r0 <= 0.05 then
        return
    end

    local target = r0
    local d1 = wp.__mpDesiredRadius
    local d2 = wp.__fgDesiredRadius
    local d3 = wp.__tpDesiredRadius
    local d4 = wp.__punctureDesiredRadius
    local d5 = wp.__ttdDesiredRadius
    local d6 = wp.__uytDesiredRadius
    if type(d1) == "number" and d1 == d1 then target = math.min(target, d1) end
    if type(d2) == "number" and d2 == d2 then target = math.min(target, d2) end
    if type(d3) == "number" and d3 == d3 then target = math.min(target, d3) end
    if type(d4) == "number" and d4 == d4 then target = math.min(target, d4) end
    if type(d5) == "number" and d5 == d5 then target = math.min(target, d5) end
    if type(d6) == "number" and d6 == d6 then target = math.min(target, d6) end

	if MudPhysics ~= nil then
		local smallTh  = MudPhysics.smallWheelRadiusThreshold or 0.60
		local smallMin = mpGetSmallWheelMinAbs(r0) or (MudPhysics.smallWheelMinRadiusAbs or 0.25)

		if r0 < smallTh and type(smallMin) == "number" and smallMin > 0 then
			target = math.min(r0, math.max(target, smallMin))
		end
	end


    if type(target) ~= "number" or target ~= target or target <= 0.05 then
        return
    end

    local e = eps or 0.0015
    local curR = wp.radius or 0
    if math.abs(target - curR) <= e then
        return
    end

    local veh = wp.vehicle
    if veh == nil or not veh.isAddedToPhysics then
        wp.__mpFxDesiredRadius = target
        if wp.netInfo ~= nil then
            wp.netInfo.wheelRadius = target
        end
        return
    end

    local isWheelOwnerActive = false
    if veh.getIsWheelOwnerActive ~= nil then
        isWheelOwnerActive = veh:getIsWheelOwnerActive()
    end

    if veh.isServer or isWheelOwnerActive then
        wp.radius = target
        wp.isPositionDirty = true
        wp.isFrictionDirty = true

        if wp.netInfo ~= nil then
            wp.netInfo.wheelRadius = target
        end
    else
        wp.__mpFxDesiredRadius = target
        if wp.netInfo ~= nil then
            wp.netInfo.wheelRadius = target
        end
    end
end

-- =========================================================
-- Утилиты, хелперы и всякая такая байда
-- =========================================================

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 mpGetWheelWidth(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 mpGetWidthBonus(wp)
    if MudPhysics.widthBonusEnable == false then
        return 0
    end

    local width = mpGetWheelWidth(wp)
    if type(width) ~= "number" or width <= 0 then
        return 0
    end

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

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

local function mpMarkCrawlerWheels(vehicle)
    if vehicle == nil or vehicle.__mpCrawlerWheelsScanned == 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.__mpIsCrawlerTrackWheel = true
                    if wheel.physics ~= nil then
                        wheel.physics.__mpIsCrawlerTrackWheel = 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.__mpIsCrawlerTrackWheel = true
                    wheel.physics.__mpIsCrawlerTrackWheel = true
                end
            end
        end
        vehicle.__mpCrawlerWheelsScanned = true
    end
end

local function mpIsCrawlerWheel(wheelOrPhysics)
    if MudPhysics.crawlerMudSupportEnable == false or wheelOrPhysics == nil then
        return false
    end

    if wheelOrPhysics.__mpIsCrawlerTrackWheel == 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

    mpMarkCrawlerWheels(vehicle)

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

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

    mpMarkCrawlerWheels(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 mpIsCrawlerWheel(wheel) then
                crawler = crawler + 1
            end
        end
    end

    if total <= 0 then
        return 0
    end

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

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

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

local function mpDeleteExtraPSEntry(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
        -- Detaches the shape from the vehicle before Vehicle.delete recursively
        -- removes the vehicle hierarchy.
        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

    -- ParticleUtil.deleteParticleSystem normally removes psClone. The guarded
    -- call also covers partially loaded particle systems.
    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 mpDeleteAllVehicleExtraPS()
    for _, state in pairs(MudPhysics.vehicleState or {}) do
        if state ~= nil and state.extraPS ~= nil then
            for _, entry in pairs(state.extraPS) do
                mpDeleteExtraPSEntry(entry)
            end
            state.extraPS = nil
        end
    end
end

local function mpGetWetnessServer(groundWetness)
    local w = groundWetness or 0
    if g_currentMission ~= nil and g_currentMission.environment ~= nil and g_currentMission.environment.weather ~= nil then
        local ww = g_currentMission.environment.weather
        if ww.getGroundWetness ~= nil then
            w = math.max(w, ww:getGroundWetness() or 0)
        end
    end
    return w
end

local function mpGetRootNodeForWheel(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 mpIsDedicatedServer()
    return g_dedicatedServer ~= nil or (g_server ~= nil and g_client == nil and g_gui == nil)
end

local function mpGetVehicleSpeedKph(vehicle)
    if vehicle == nil then return 0 end

    -- FS25 Vehicle:getLastSpeed() is already expressed in 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

    -- lastSpeedReal is m/s and is also available on dedicated servers.
    return math.abs((tonumber(vehicle.lastSpeedReal) or 0) * 3600)
end

local function mpRequestSpeedLimit(vehicle, limitKph)
    local coordinator = _G.__MudDriveCoordinator
    if coordinator ~= nil and coordinator.requestSpeedLimit ~= nil then
        coordinator:requestSpeedLimit(vehicle, "mud", 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 mpGetNaturalSpeedLimitKph(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, mpGetVehicleSpeedKph(vehicle)+5)
end

local function mpRequestMotorLoad(vehicle, multiplier)
    local coordinator = _G.__MudDriveCoordinator
    if coordinator ~= nil and coordinator.requestMotorLoad ~= nil then
        coordinator:requestMotorLoad(vehicle, "mud", 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 mpTTDHandlesDynamics(vehicle)
    local compatibility = rawget(_G, "TractorTerrainDynamicsCompatibility")
    return compatibility ~= nil and compatibility.isHandlingVehicleDynamics ~= nil
        and compatibility:isHandlingVehicleDynamics(vehicle)
end

local function mpSubmitTerrainDynamics(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, "mud", {
            active=true,
            intensity=intensity,
            wetness=wetness,
            slip=slip,
            sink=sink
        })
    else
        coordinator:requestTerrainDynamics(vehicle, "mud", nil)
    end
end

local function mpClearDriveRequests(vehicle)
    mpRequestSpeedLimit(vehicle, nil)
    mpRequestMotorLoad(vehicle, nil)
    local coordinator = _G.__MudDriveCoordinator
    if coordinator ~= nil and coordinator.requestTerrainDynamics ~= nil then
        coordinator:requestTerrainDynamics(vehicle, "mud", nil)
    end
end

local function mpGetClientVisualSpeedKph(vehicle, fallbackNode)
    if vehicle ~= nil then
        return mpGetVehicleSpeedKph(vehicle)
    end
    if fallbackNode ~= nil and fallbackNode ~= 0 and getLinearVelocity ~= nil then
        local vx, vy, vz = getLinearVelocity(fallbackNode)
        return math.sqrt(vx*vx + vy*vy + vz*vz) * 3.6
    end
    return 0
end

function MudPhysics:isVehicleMoving(vehicle, fallbackNode)
    if vehicle == nil then
        return false
    end

    local spTh = self.movingSpeedKph
    if spTh == nil then spTh = self.freezeStopSpeedKph or 0.60 end

    local slTh = self.movingSlip
    if slTh == nil then slTh = self.freezeStopSlip or 0.010 end

    local speedKph = mpGetClientVisualSpeedKph(vehicle, fallbackNode) or 0
    if speedKph > spTh then
        return true
    end

    -- Если колёса буксуют на месте (газ/пробуксовка) — это считаем "псевдодвижением"
    if vehicle.spec_wheels ~= nil and vehicle.spec_wheels.wheels ~= nil then
        for _, w in ipairs(vehicle.spec_wheels.wheels) do
            local wp = w ~= nil and w.physics or nil
            local slip = 0
            if wp ~= nil then
                slip = (wp.netInfo ~= nil and wp.netInfo.slip) or wp.slip or 0
            end
            if (slip or 0) > slTh then
                return true
            end
        end
    end

    return false
end

-- =========================================================
-- SHOP PREVIEW
-- =========================================================
function MudPhysics: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

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

    local st = MudPhysics.vehicleState ~= nil and MudPhysics.vehicleState[vehicle] or nil
    if st ~= nil then
        st.sink = 0
        st.stuck = false
        st.permaStuckActive = false
        st.permaStuckCount = 0
        st.permaStuckToken = 0
        st.permaStuckNextMs = 0
        st.permaReleaseDist = 0
        st.permaLastPosX = nil
        st.permaLastPosZ = nil
        st.permaMoveTickMs = -1
        st.permaNotifyToken = 0
        st.__mpTerrainSeverity = 0
        st.__mpSoilState = nil
        st.__mpSmoothedMotorLoad = 1
        st.__tmSpeedKph = 0
    end

    mpClearDriveRequests(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
                w.physics.__mpMudBrakeExtra = 0
                w.physics.__mpMudDeepFactor = 0
                w.physics.__mpMudReliefT = 0
            end
        end
    end
end

-- =========================================================
-- Применение настроек
-- =========================================================

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

    local st = (MudPhysics.vehicleState ~= nil) and MudPhysics.vehicleState[vehicle] or nil
    if st ~= nil then
        st.sink = 0
        st.stuck = false
        st.permaStuckActive = false
        st.permaStuckCount = 0
        st.permaStuckToken = 0
        st.permaStuckNextMs = 0
        st.permaReleaseDist = 0
        st.permaLastPosX = nil
        st.permaLastPosZ = nil
        st.permaMoveTickMs = -1
        st.permaNotifyToken = 0
        st.ramp = 0
        st.lastTick = -1
        st.mudRamp = 0
        st.__mpTerrainSeverity = 0
        st.__mpSoilState = nil
        st.__mpSmoothedMotorLoad = 1
        st.__tmSpeedKph = 0

        if st.extraPS ~= nil then
            for _, entry in pairs(st.extraPS) do
                mpDeleteExtraPSEntry(entry)
            end
            st.extraPS = nil
        end

        st.__mpAppliedMotorLoad = false
        st.__mpAppliedSpeedLimit = false
        st.lastAppliedMotorLoad = nil
    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

    mpClearDriveRequests(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.__mpDesiredRadius = nil
                wp.__mpMudCurExtra = 0
                wp.__mpMudBrakeExtra = 0
                wp.__mpMudDeepFactor = 0
                wp.__mpMudReliefT = 0
                wp.__mpPerma = false

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

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

function MudPhysics:onSettingsChanged()
    self.winterSlipMul      = clamp(tonumber(self.winterSlipMul) or 0, 0, 2)
    self.normalSlipMul      = clamp(tonumber(self.normalSlipMul) or 0, 0, 1)
    self.mudVarCell         = clamp(tonumber(self.mudVarCell) or 12.55, 0.25, 100)
    self.sinkInSpeed        = clamp(tonumber(self.sinkInSpeed) or 0, 0, 10)
    self.sinkOutSpeed       = clamp(tonumber(self.sinkOutSpeed) or 0, 0, 10)
    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.emitMultWetMud     = clamp(tonumber(self.emitMultWetMud) or 0, 0, 50)
    self.sizeMultWetMud     = clamp(tonumber(self.sizeMultWetMud) or 0, 0, 50)
    self.speedMultWetMud    = clamp(tonumber(self.speedMultWetMud) or 0, 0, 50)
    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)


    -- Если поменяли флаг использования DIRT-кистей — пересобираем кэш слоёв принудетельно
    local useDIRT = (self.useDIRTBrushes ~= false)
    if self._lastUseDIRTBrushes == nil then
        self._lastUseDIRTBrushes = useDIRT
    elseif self._lastUseDIRTBrushes ~= useDIRT then
        self._lastUseDIRTBrushes = useDIRT
        self:refreshMudLayerCache()
    end

    -- Never discard vehicleState before walking it directly. The FS25 mission
    -- vehicle list may be unavailable or incomplete during reload/shop changes.
    mpDeleteAllVehicleExtraPS()

    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

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

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

    if MudPhysics.debug then
        print(string.format("[MudPhysics] settings changed -> enabled=%s useDIRT=%s",
            tostring(self.enabled), tostring(useDIRT)))
    end
end


-- =========================================================
-- VEHICLE RESET/RELOAD GUARD (Перезагрузка при сбросе, фикс вылета при сбросе техники)
-- =========================================================

function MudPhysics: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.__mpResetGuard == true then return true end
    return false
end

function MudPhysics:cleanupVehicleState(vehicle, reason)
    if vehicle == nil then return end

    if MudWheelVisualSystem ~= nil
        and MudWheelVisualSystem.clearVehicle ~= nil then
        MudWheelVisualSystem:clearVehicle(vehicle)
    end

    vehicle.__mpResetGuard = true

    if vehicle.__mudSystemDeleting == true then
        local deletingState = MudPhysics.vehicleState ~= nil
            and MudPhysics.vehicleState[vehicle] or nil
        if deletingState ~= nil and deletingState.extraPS ~= nil then
            for _, entry in pairs(deletingState.extraPS) do
                mpDeleteExtraPSEntry(entry)
            end
            deletingState.extraPS = nil
        end
        if MudPhysics.vehicleState ~= nil then
            MudPhysics.vehicleState[vehicle] = nil
        end
        return
    end

    local st = (MudPhysics.vehicleState ~= nil) and MudPhysics.vehicleState[vehicle] or nil
    if st ~= nil then
        st.sink = 0
        st.stuck = false
        st.permaStuckActive = false
        st.permaStuckCount = 0
        st.permaStuckToken = 0
        st.permaStuckNextMs = 0
        st.permaReleaseDist = 0
        st.permaLastPosX = nil
        st.permaLastPosZ = nil
        st.permaMoveTickMs = -1
        st.permaNotifyToken = 0
        st.ramp = 0
        st.lastTick = -1
        st.__mpTerrainSeverity = 0
        st.__mpSoilState = nil
        st.__mpSmoothedMotorLoad = 1
        st.__tmSpeedKph = 0

        if st.extraPS ~= nil then
            for _, entry in pairs(st.extraPS) do
                mpDeleteExtraPSEntry(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

    mpClearDriveRequests(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.__mpDesiredRadius = nil
                wp.__mpMudCurExtra = 0
                wp.__mpMudBrakeExtra = 0
                wp.__mpMudDeepFactor = 0
                wp.__mpMudReliefT = 0
                wp.__mpPerma = false

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

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

function MudPhysics: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
                mpDeleteExtraPSEntry(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.__mpDesiredRadius = nil
                wp.__mpMudCurExtra = 0
                wp.__mpMudBrakeExtra = 0
            end
        end
    end
end

function MudPhysics.installResetHooks()
    if Utils == nil or Utils.overwrittenFunction == nil then
        print("[MudPhysics] 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 MudPhysics ~= nil then
                    MudPhysics: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 MudPhysics ~= nil then
                    MudPhysics: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, MudPhysics.onVehicleReset, MudPhysics)
    end

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

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

function MudPhysics.loadExtraParticleReferences()
    if not MudPhysics.extraParticlesEnable then
        return
    end

    if MudExtraParticlePresets ~= nil then
        MudExtraParticlePresets.applyMudPhysics(MudPhysics)
    end

    MudPhysics._extraPS = MudPhysics._extraPS or {}
    if MudPhysics._extraPS.referencePS ~= nil and MudPhysics._extraPS.referenceShape ~= nil then
        return
    end

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

    local psNode = I3DUtil.indexToObject(root, "0")
	MudPhysics._extraPS.sourcePsNode = psNode
	MudPhysics._extraPS.sourcePsRoot = root
	
    if psNode == nil then
        print(string.format("[MudPhysics] 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, MudPhysics.extraParticleClipDist or 90)

    if ps.geometry ~= nil then
        setMaxNumOfParticles(ps.geometry, MudPhysics.extraParticleMaxCount or 2500)
    end

    MudPhysics._extraPS.referencePS = ps

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

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

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

local function ensureWheelExtraPS(vehicle, wheelKey)
    if vehicle == nil then
        return nil
    end

    if MudPhysics ~= nil and MudPhysics.isVehicleResetting ~= nil and MudPhysics:isVehicleResetting(vehicle) then
        return nil
    end

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

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

    local st = MudPhysics.vehicleState[vehicle]
    if st == nil then
        st = { extraPS = {} }
        MudPhysics.vehicleState[vehicle] = st
    end

    st.extraPS = st.extraPS or {}

    local entry = st.extraPS[wheelKey]
    local presetId = math.floor(tonumber(MudPhysics.extraParticlesPreset) or 1)
    if entry ~= nil and entry.ps ~= nil and entry.emitter ~= nil then
        if entry.presetId == presetId then
            return entry
        end
        mpDeleteExtraPSEntry(entry)
        st.extraPS[wheelKey] = nil
        entry = nil
    end

    if MudPhysics._extraPS == nil
        or MudPhysics._extraPS.sourcePsNode == nil
        or MudPhysics._extraPS.referenceShape == nil then
        return nil
    end

    local rootNode = nil
    if vehicle.getRootNode ~= nil then
        local rn = vehicle:getRootNode()
        if rn ~= nil and rn ~= 0 then
            rootNode = rn
        end
    end
    if rootNode == nil and vehicle.components ~= nil and vehicle.components[1] ~= nil then
        local n = vehicle.components[1].node
        if n ~= nil and n ~= 0 then
            rootNode = n
        end
    end
    if rootNode == nil then
        rootNode = vehicle.rootNode
    end

    if rootNode == nil or rootNode == 0 or (entityExists ~= nil and not entityExists(rootNode)) then
        if MudPhysics.debug then
            print(string.format("[MudPhysics][ExtraPS] NO rootNode for vehicle=%s", tostring(vehicle.typeName)))
        end
        return nil
    end

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

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

    local src = MudPhysics._extraPS.sourcePsNode
    if src == nil or src == 0 then
        delete(emitter)
        return nil
    end

    local psClone = clone(src, true, false, true)
    link(emitter, psClone)

    local psTbl = {}
    ParticleUtil.loadParticleSystemFromNode(psClone, psTbl, false, true, false)

    ParticleUtil.setEmitterShape(psTbl, emitterShape)
    ParticleUtil.setEmittingState(psTbl, false)
    psTbl.isActive = false

    entry = {
        emitter = emitter,
        ps = psTbl,
        psClone = psClone,
        emitterShape = emitterShape,
        presetId = presetId
    }

    st.extraPS[wheelKey] = entry
    return entry
end

function MudPhysics:getCurrentTemperatureC()
    if g_currentMission ~= nil and g_currentMission.environment ~= nil then
        local env = g_currentMission.environment

        if env.weather ~= nil and env.weather.getCurrentTemperature ~= nil then
            local t = env.weather:getCurrentTemperature()
            if t ~= nil then
                return t
            end
        end

        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 MudPhysics:getMudFreezeMult()
    if self.freezeMudByTempEnable ~= true then
        return 1.0
    end

    local t = self:getCurrentTemperatureC()
    if t ~= nil and t < (self.freezeMudTempC or -3.0) then
        return clamp(self.freezeMudMult or 0.12, 0.0, 1.0)
    end

    return 1.0
end


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

    local t = self:getCurrentTemperatureC()
    if t == nil then
        return false
    end

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


function MudPhysics:getSlipMultByTemperature()

    if self.winterSlipEnable ~= true and self.normalSlipEnable ~= true then
        return 1.0
    end

    local t = self:getCurrentTemperatureC()
    if t == nil then
        if self.normalSlipEnable == true then
            return clamp(self.normalSlipMul or 1.0, 0.10, 1.00)
        end
        return 1.0
    end

    local th = self.winterSlipTempC or -3.0

    if t < th then
        if self.winterSlipEnable == true then
            return clamp(self.winterSlipMul or 1.15, 0.10, 2.00)
        end
        return 1.0
    else
        if self.normalSlipEnable == true then
            return clamp(self.normalSlipMul or 1.0, 0.10, 1.00)
        end
        return 1.0
    end
end

function MudPhysics: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 fillType = DensityMapHeightUtil.getFillTypeAtLine(x, probeY + 0.35, z, x, probeY - 0.45, z, probeRadius)
    if fillType ~= nil and (FillType == nil or fillType ~= FillType.UNKNOWN) then
        return fillType
    end

    return nil
end

function MudPhysics:isWheelOnSnow(wheel, x, z)
    if FillType == nil or FillType.SNOW == nil then
        return false
    end

    local wx = x
    local wy = nil
    local wz = z
    if wx == nil or wz == nil then
        wx, wy, wz = self:getWheelContactPos(wheel)
    end
    if wx == nil or wz == nil then
        return false
    end

    return self:getFillTypeAtWorldPos(wx, wy, wz) == FillType.SNOW
end

local function mpGetVehicleRootNode(vehicle)
    if vehicle == nil then return nil 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 mpGetForwardDirWorld(node)
    local x0, y0, z0 = localToWorld(node, 0, 0, 0)
    local x1, y1, z1 = localToWorld(node, 0, 0, 1)

    local dx = x1 - x0
    local dy = y1 - y0
    local dz = z1 - z0

    local len = math.sqrt(dx*dx + dy*dy + dz*dz)
    if len < 0.000001 then
        return 0, 0, 1
    end
    return dx/len, dy/len, dz/len
end

local function mpGetSignedForwardSpeedKph(node)
    if getLinearVelocity ~= nil then
        local vx, vy, vz = getLinearVelocity(node)
        local fdx, fdy, fdz = mpGetForwardDirWorld(node)
        local vFwd = (vx * fdx) + (vy * fdy) + (vz * fdz)
        return vFwd * 3.6
    end

    if getLocalLinearVelocity ~= nil then
        local lvx, lvy, lvz = getLocalLinearVelocity(node)
        return (lvz or 0) * 3.6
    end

    return 0
end

local function mpGetBurstDirectionLocal(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 = mpGetVehicleRootNode(vehicle) or rootNode
    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 mpGetWheelIndexCached(vehicle, wheel)
    if vehicle == nil or wheel == nil then return nil end
    if MudPhysics == nil or MudPhysics.vehicleState == nil then return nil end

    local st = MudPhysics.vehicleState[vehicle]
    if st == nil then return nil end

    st.__mpWheelIndexByRef = st.__mpWheelIndexByRef or {}
    local idx = st.__mpWheelIndexByRef[wheel]

    if idx == nil and vehicle.spec_wheels ~= nil and vehicle.spec_wheels.wheels ~= nil then
        for i, w in ipairs(vehicle.spec_wheels.wheels) do
            st.__mpWheelIndexByRef[w] = i
        end
        idx = st.__mpWheelIndexByRef[wheel]
    end

    return idx
end

local computeWetnessEffective
local mpHasRealContactForExtra
local getState

local function mpFieldGroundOwnsExtraParticles(wheel)
    local fg = rawget(_G, "FieldGroundMudPhysics")
    if fg == nil or fg.enabled ~= true or fg.extraParticlesEnable ~= true
        or wheel == nil or fg.getWheelGroundProfile == nil then
        return false
    end
    if fg.isHardWinterFrozen ~= nil and fg:isHardWinterFrozen() then
        return false
    end

    local mudBase, _, prof = fg:getWheelGroundProfile(wheel)
    return prof ~= nil and prof.fxExtra == true and (mudBase or 0) > 0.01
end

local function mpHasParticleMotion(vehicle, wheel)
    if mpGetVehicleSpeedKph(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

local function mpSendServerExtraParticleState(wheelPhysics, dt, groundWetness, forceInactive)
    if g_server == nil
        or MudExtraParticleEvent == nil
        or MudPhysics.isMultiplayerSession == nil
        or not MudPhysics:isMultiplayerSession()
        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 = MudPhysics:getWheelContactPos(wheel)

    if forceInactive ~= true
        and MudPhysics.enabled
        and MudPhysics.extraParticlesEnable
        and not mpFieldGroundOwnsExtraParticles(wheel)
        and wx ~= nil and wz ~= nil then
        local mudHere, _, mudKind = 0, nil, nil
        local hasContact = mpHasRealContactForExtra(wheel, wx, wy or 0, wz)
        local inWater = hasContact and MudPhysics.waterActsAsMud and MudPhysics:isWheelInWater(wheel)

        if hasContact then
            mudHere, _, mudKind = MudPhysics:getMudFactorCached(wheel, wx, wz)
            mudHere = mudHere or 0
            if inWater then
                mudHere = math.max(mudHere, MudPhysics.waterMudFactor or 1.0)
                mudKind = "WATER"
            end
        end

        local freezeMul = MudPhysics.getMudFreezeMult ~= nil and (MudPhysics:getMudFreezeMult() or 1.0) or 1.0
        local frozen = MudPhysics.isHardWinterFrozen ~= nil and MudPhysics:isHardWinterFrozen()
        if not frozen and not ((MudPhysics.disableExtraParticlesWhenFrozen ~= false)
            and MudPhysics.freezeMudByTempEnable == true and freezeMul < 1.0)
            and not (inWater and MudPhysics.waterDisableExtraParticles) then
            local wetSrv = mpGetWetnessServer(groundWetness)
            local wetEff = computeWetnessEffective(wetSrv)
            local wetFinal = math.max(wetSrv or 0, wetEff or 0)
            local isMud = mudHere >= (MudPhysics.mudMinToAffect or 0.10)

            active = hasContact and isMud
            if active and MudPhysics.extraParticleOnlyWetMud ~= false then
                active = mudKind == "WATER" or wetFinal > (MudPhysics.wetnessThreshold or 0.10)
            end

            local slip = (wheelPhysics.netInfo ~= nil and wheelPhysics.netInfo.slip) or wheelPhysics.slip or 0
            if active then
                local stV = getState(vehicle)
                local isPerma = stV.permaStuckActive == true or wheelPhysics.__mpPermaStuck == true
                local minKph = isPerma
                    and (MudPhysics.extraParticleMinSpeedKphPerma or 0.9)
                    or (MudPhysics.extraParticleMinSpeedKph or 10.2)
                if mpGetVehicleSpeedKph(vehicle) < minKph
                    and slip < (MudPhysics.extraParticleBurstSlipMin or 0.15) then
                    active = false
                end
                if active and not mpHasParticleMotion(vehicle, wheel) then
                    active = false
                end
            end
            burst = active and MudPhysics.extraParticleBurstEnable == true
                and slip >= (MudPhysics.extraParticleBurstSlipMin or 0.15)
        end
    end

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

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

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

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

    local state = MudPhysics.vehicleState ~= nil and MudPhysics.vehicleState[vehicle] or nil
    local entry = state ~= nil and state.extraPS ~= nil and state.extraPS[wheelRef] 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 = mpGetVehicleRootNode(vehicle)
    if rootNode ~= nil and wx ~= nil then
        local yOff = (MudPhysics.extraParticleOffsetY or 0.18)

        local vFwdKph = mpGetSignedForwardSpeedKph(rootNode)
        local speedAbsKph = math.abs(vFwdKph)

        local deadKph = MudPhysics.extraParticleOffsetMoveDeadzoneKph or 0.35
        local dir = 0
        if vFwdKph > deadKph then
            dir = 1
        elseif vFwdKph < -deadKph then
            dir = -1
        end

        local moveX = 0
        local moveZ = 0

        local moveMaxF = MudPhysics.extraParticleOffsetMoveMaxFwd
        local moveMaxR = MudPhysics.extraParticleOffsetMoveMaxRev
        local moveMax  = (dir > 0 and (moveMaxF ~= nil and moveMaxF or (MudPhysics.extraParticleOffsetMoveMax or 0)))
                      or (dir < 0 and (moveMaxR ~= nil and moveMaxR or (MudPhysics.extraParticleOffsetMoveMax or 0)))
                      or 0

        if moveMax ~= 0 and dir ~= 0 then
            local fullF = math.max(0.1, MudPhysics.extraParticleOffsetMoveKphFullFwd or (MudPhysics.extraParticleOffsetMoveKphFull or 14.0))
            local fullR = math.max(0.1, MudPhysics.extraParticleOffsetMoveKphFullRev or 5.0)

            local full = (dir > 0) and fullF or fullR
            local t = clamp(speedAbsKph / full, 0, 1)

            local moveMag = moveMax * t

            local minF = MudPhysics.extraParticleOffsetMoveMinFwd or 0
            local minR = MudPhysics.extraParticleOffsetMoveMinRev or 0
            local minOff = (dir > 0) and minF or minR

            if minOff > 0 then
                local tMin = clamp(speedAbsKph / 2.0, 0, 1)
                moveMag = moveMag + (minOff * tMin)
            end

            if dir < 0 then
                moveMag = moveMag * (MudPhysics.extraParticleOffsetMoveReverseMul or 1.0)
            end

            moveX, moveZ = mpGetBurstDirectionLocal(rootNode, vehicle, wheelRef, moveMag)
        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 MudPhysics.extraParticleBurstEnable and wp ~= nil then
            wp.__mpExtraBurstT = wp.__mpExtraBurstT or 0

            if wp.__mpExtraBurstT > 0 then
                local slip = (wp.netInfo ~= nil and wp.netInfo.slip) or wp.slip or 0
                local slipMin = MudPhysics.extraParticleBurstSlipMin or 0.15
                local slipFull = math.max(slipMin + 0.01, MudPhysics.extraParticleBurstSlipFull or 0.65)
                burstSlip01 = clamp((slip - slipMin) / (slipFull - slipMin), 0, 1)

                local upMul = (MudPhysics.extraParticleBurstUpMul or 0.65) * (1 + burstSlip01 * (MudPhysics.extraParticleBurstUpSlipMul or 0.60))
                local fMul  = (MudPhysics.extraParticleBurstForwardMul or 0.55) * (1 + burstSlip01 * (MudPhysics.extraParticleBurstForwardSlipMul or 1.05))
                local baseMag = math.sqrt(moveX * moveX + moveZ * moveZ)
                if baseMag < 0.001 then
                    local fallbackMag = (MudPhysics.extraParticleOffsetMoveMax or 0.18) * (0.55 + burstSlip01 * 0.75)
                    moveX, moveZ = mpGetBurstDirectionLocal(rootNode, vehicle, wheelRef, fallbackMag)
                    baseMag = math.sqrt(moveX * moveX + moveZ * moveZ)
                end

                burstY = (MudPhysics.extraParticleBurstYOffset or 0.28) * upMul
                if baseMag > 0.0001 then
                    burstX = moveX * fMul
                    burstZ = moveZ * fMul
                end
            end
        end

        local lx, ly, lz = worldToLocal(rootNode, wx, wy, wz)

        local zOff = (MudPhysics.extraParticleOffsetZ or 0)

        lx = lx + moveX + burstX
        ly = ly + yOff + burstY
        lz = lz + zOff + moveZ + burstZ

        setTranslation(entry.emitter, lx, ly, lz)
    end

    local ps = entry.ps
    if ps ~= nil then
        ps.__mpBurstOrigEmit = ps.__mpBurstOrigEmit or 1
        ps.__mpBurstOrigParticleSpeed = ps.__mpBurstOrigParticleSpeed or ps.particleSpeed
        ps.__mpBurstOrigParticleRandomSpeed = ps.__mpBurstOrigParticleRandomSpeed or ps.particleRandomSpeed

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

                if ParticleUtil.setEmitCountScale then
                    local emitMul = 1 + slip01 * (MudPhysics.extraParticleBurstEmitMul or 0.90)
                    ParticleUtil.setEmitCountScale(ps, (ps.__mpBurstOrigEmit or 1) * emitMul)
                end
            elseif ParticleUtil.setEmitCountScale then
                ParticleUtil.setEmitCountScale(ps, ps.__mpBurstOrigEmit or 1)
            end
        elseif ParticleUtil.setEmitCountScale then
            ParticleUtil.setEmitCountScale(ps, ps.__mpBurstOrigEmit or 1)
        end

        if ps.__mpBurstOrigParticleSpeed ~= nil then
            ps.particleSpeed = ps.__mpBurstOrigParticleSpeed * burstMul
        end
        if ps.__mpBurstOrigParticleRandomSpeed ~= nil then
            ps.particleRandomSpeed = ps.__mpBurstOrigParticleRandomSpeed * 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 mpStopWheelExtraPS(vehicle, wheelRef)
    local state = MudPhysics.vehicleState ~= nil and MudPhysics.vehicleState[vehicle] or nil
    local entry = state ~= nil and state.extraPS ~= nil and state.extraPS[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.__mpExtraBurstT = 0
    end
end

function MudPhysics:applyNetworkExtraParticles(vehicle, wheelIndex, isActive, wx, wy, wz, burst)
    if mpIsDedicatedServer() 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.__mpExtraPSNetUntil = now + 750
    wp.__mpExtraPSNetActive = isActive == true
    wp.__mpExtraBurstT = 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
        mpStopWheelExtraPS(vehicle, wheel)
        return
    end

    if self._extraPS == nil or self._extraPS.sourcePsNode == nil or self._extraPS.referenceShape == nil then
        self.loadExtraParticleReferences()
    end
    updateWheelExtraPS(vehicle, wheel, true, wx, wy, wz, 0)
end

local function hash2D(x, z)
    local n = math.sin(x * 12.9898 + z * 78.233) * 43758.5453
    return n - math.floor(n)
end

function MudPhysics:sampleMudVar(x, z, timeMs)
    local cell = self.mudVarCell or 7.0
    local cx = math.floor(x / cell)
    local cz = math.floor(z / cell)

    local base = hash2D(cx, cz)

    local s = clamp(self.mudVarStrength or 0.45, 0, 1)

    local patchMul = (1.0 - s) + (0.7 + 0.6 * base) * s

    local amp = clamp(self.mudBobAmp or 0.22, 0, 1)
    local freq = math.max(0.01, self.mudBobFreq or 0.18) -- Hz
    local phase = hash2D(cx + 11.3, cz - 7.9) * math.pi * 2

    local t = (timeMs or 0) * 0.001
    local bob = math.sin(t * (math.pi * 2) * freq + phase) * amp

    return patchMul, bob
end

function MudPhysics:sampleMudVarWheel(x, z, wheelSeed, timeMs)
    local cell = self.mudVarCell or 7.0

    local sx = (wheelSeed or 0) * 0.11
    local sz = (wheelSeed or 0) * 0.07

    local cx = math.floor((x + sx) / cell)
    local cz = math.floor((z + sz) / cell)

    local base = hash2D(cx, cz)

    local s = clamp(self.mudVarStrength or 0.45, 0, 1)
    local patchMul = (1.0 - s) + (0.7 + 0.6 * base) * s

    local amp  = clamp(self.mudBobAmp or 0.22, 0, 1)
    local freq = math.max(0.01, self.mudBobFreq or 0.18)
    local phase = hash2D(cx + 11.3 + (wheelSeed or 0) * 0.013, cz - 7.9) * math.pi * 2

    local t = (timeMs or 0) * 0.001
    local bob = math.sin(t * (math.pi * 2) * freq + phase) * amp

    return patchMul, bob
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

-- =========================================================
-- TERRAIN NODE
-- =========================================================

function MudPhysics:getTerrainNode()
    if g_currentMission ~= nil and g_currentMission.terrainRootNode ~= nil then
        return g_currentMission.terrainRootNode
    end
    return g_terrainNode
end

-- =========================================================
-- CACHED MUD LAYER IDS
-- =========================================================

MudPhysics.mudLayerIds   = {}
MudPhysics.mudLayerMeta  = {}

function MudPhysics:refreshMudLayerCache()
    self.mudLayerIds  = {}
    self.mudLayerMeta = {}

    local terrainNode = self:getTerrainNode()
    if terrainNode == nil or getTerrainNumOfLayers == nil or getTerrainLayerName == nil then
        if self.debug then
            print("[MudPhysics] Terrain API not ready yet (terrainRootNode nil). Will retry automatically.")
        end
        return false, "NOT_READY"
    end

    local useDIRT = (self.useDIRTBrushes ~= false)

    local needles = {}
    if type(self.mudNameNeedles) == "table" and #self.mudNameNeedles > 0 then
        for _, n in ipairs(self.mudNameNeedles) do
            local s = string.upper(tostring(n or ""))
            if s ~= "" then
                if not useDIRT and s == "DIRT" then
                else
                    table.insert(needles, s)
                end
            end
        end
    else
        local s = string.upper(self.mudNameNeedle or "MUD")
        if useDIRT or s ~= "DIRT" then
            table.insert(needles, s)
        end
    end

    local ignores = {}
    if type(self.ignoreNameNeedles) == "table" and #self.ignoreNameNeedles > 0 then
        for _, n in ipairs(self.ignoreNameNeedles) do
            local s = string.upper(tostring(n or ""))
            if s ~= "" then
                table.insert(ignores, s)
            end
        end
    end

    local numLayers = getTerrainNumOfLayers(terrainNode)

    for layerId = 0, numLayers - 1 do
        local name = getTerrainLayerName(terrainNode, layerId)
        if name ~= nil then
            local up = string.upper(name)

            local ignore = false
            for _, ig in ipairs(ignores) do
                if string.find(up, ig, 1, true) ~= nil then
                    ignore = true
                    break
                end
            end

            if not ignore then
                local matched = false
                for _, needle in ipairs(needles) do
                    if string.find(up, needle, 1, true) ~= nil then
                        matched = true
                        break
                    end
                end

                if matched then
                    table.insert(self.mudLayerIds, layerId)

                    local meta = { name = name }

                    if string.find(up, "WATER", 1, true) ~= nil then
                        meta.kind = "WATER"
                    elseif string.find(up, "DIRT", 1, true) ~= nil then
                        meta.kind = "DIRT"
                    else
                        meta.kind = "MUD"
                    end

                    if getTerrainLayerXmlAttribute ~= nil then
                        meta.viscosity   = getTerrainLayerXmlAttribute(terrainNode, layerId, "viscosity")
                        meta.firmnessWet = getTerrainLayerXmlAttribute(terrainNode, layerId, "firmnessWet")
                    end

                    self.mudLayerMeta[layerId] = meta
                end
            end
        end
    end

    -- Логируем только один итоговый результат сканирования, без спама
    if #self.mudLayerIds > 0 then
        print(string.format("[MudPhysics] Mud layers cached: %d (useDIRT=%s needles='%s' ignore='%s')",
            #self.mudLayerIds,
            tostring(useDIRT),
            table.concat(needles, ","),
            (#ignores > 0 and table.concat(ignores, ",") or "-")
        ))
    else
        print(string.format("[MudPhysics] No mud terrain layers found on this map (useDIRT=%s needles='%s' ignore='%s')",
            tostring(useDIRT),
            table.concat(needles, ","),
            (#ignores > 0 and table.concat(ignores, ",") or "-")
        ))
    end

    if self.debug and #self.mudLayerIds > 0 then
        for _, id in ipairs(self.mudLayerIds) do
            local m = self.mudLayerMeta[id]
            print(string.format("[MudPhysics]  layerId=%d kind=%s name='%s' viscosity=%s firmnessWet=%s",
                id, tostring(m and m.kind), tostring(m and m.name), tostring(m and m.viscosity), tostring(m and m.firmnessWet)))
        end
    end

    return true, "READY"
end
-- =========================================================
-- TERRAIN → MUD FACTOR (0..1) + strongest layer name
-- =========================================================

function MudPhysics:getMudFactorAtWorldPos(x, z)
    local terrainNode = self:getTerrainNode()
    if terrainNode == nil or getTerrainLayerAtWorldPos == nil then
        return 0, nil, nil
    end

    if self.mudLayerIds == nil or #self.mudLayerIds == 0 then
        return 0, nil, nil
    end

    local bestW = 0
    local bestId = nil

    for _, layerId in ipairs(self.mudLayerIds) do
        local w = getTerrainLayerAtWorldPos(terrainNode, layerId, x, 0, z) or 0
        if w > bestW then
            bestW = w
            bestId = layerId
        end
    end

    if bestW <= 0 then
        return 0, nil, nil
    end

    local mud = clamp(bestW, 0, 1)

    local meta = bestId ~= nil and self.mudLayerMeta[bestId] or nil
    if meta ~= nil then
        if meta.viscosity ~= nil and meta.viscosity < 1 then
            mud = mud + (1 - meta.viscosity) * 0.30
        end
        if meta.firmnessWet ~= nil and meta.firmnessWet < 1 then
            mud = mud + (1 - meta.firmnessWet) * 0.30
        end

        if meta.kind == "DIRT" then
            mud = mud * (self.dirtLayerEffectMult or 0.50)
        end
    end

    return clamp(mud, 0, 1), (meta ~= nil and meta.name or nil), (meta ~= nil and meta.kind or nil)
end

-- =========================================================
-- VEHICLE STATE
-- =========================================================

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

getState = function(vehicle)
    MudPhysics.vehicleState = MudPhysics.vehicleState or setmetatable({}, { __mode = "k" })

    local st = MudPhysics.vehicleState[vehicle]
    if st == nil then
        st = {}
        MudPhysics.vehicleState[vehicle] = st
    end

    st.sink       = st.sink or 0
    st.stuck      = st.stuck or false
    st.lastTick   = st.lastTick ~= nil and st.lastTick or -1
    st.lastDbgTime= st.lastDbgTime ~= nil and st.lastDbgTime or -999999

    if st.wheelEnterLogT == nil then
        st.wheelEnterLogT = setmetatable({}, { __mode = "k" })
    end
    if st.wheelWasMud == nil then
        st.wheelWasMud = setmetatable({}, { __mode = "k" })
    end

    st.origExtTorqueMult      = st.origExtTorqueMult or nil
    st.lastAppliedMotorLoad   = st.lastAppliedMotorLoad or 1
    st.__mpAppliedMotorLoad   = st.__mpAppliedMotorLoad or false
    st.__mpAppliedSpeedLimit  = st.__mpAppliedSpeedLimit or false

    st.mudRamp      = st.mudRamp or 0
    st.mudRampHardT = st.mudRampHardT or 0

    st.permaStuckActive = st.permaStuckActive or false
    st.permaStuckToken  = st.permaStuckToken  or 0
    st.permaStuckNextMs = st.permaStuckNextMs or 0
    st.permaStuckCount  = st.permaStuckCount  or 0
    st.permaReleaseDist = st.permaReleaseDist or 0
    st.permaLastPosX    = st.permaLastPosX
    st.permaLastPosZ    = st.permaLastPosZ
    st.permaMoveTickMs  = st.permaMoveTickMs or -1
    st.permaNotifyToken = st.permaNotifyToken or 0

    st.extraPS = st.extraPS or {}

    return st
end

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

function MudPhysics:getWheelContactPos(wheel)
    local node = wheel.repr or wheel.node or wheel.wheelNode or wheel.driveNode
    if node == nil 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

    return x, y - r, z
end

-- =========================================================
function MudPhysics:getTimeMs()
    if g_currentMission ~= nil and g_currentMission.time ~= nil then
        return g_currentMission.time
    end
    if g_time ~= nil then
        return g_time
    end
    return 0
end

local function mpGetVehicleRootNode(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 mpGetVehicleXZ(vehicle)
    local node = mpGetVehicleRootNode(vehicle)
    if node == nil then
        return nil, nil
    end

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

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

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

local function mpUpdatePermaReleaseState(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 = mpGetVehicleXZ(vehicle)

    if st.permaStuckActive == 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, MudPhysics.permaStuckReleaseDistanceFullM or 8.0)
                st.permaReleaseDist = math.min(fullDist, (st.permaReleaseDist or 0) + dist)
            end
        end

        st.permaLastPosX = x
        st.permaLastPosZ = z
    else
        st.permaReleaseDist = 0
        st.permaLastPosX = x
        st.permaLastPosZ = z
    end

    return st.permaReleaseDist or 0
end

local function mpClearPermaStuckState(wp, st)
    if wp ~= nil and wp.__mpPermaStuck == true then
        wp.__mpPermaStuck = false
    end

    if wp ~= nil and wp.__mpPermaStuckCounted == true then
        wp.__mpPermaStuckCounted = false
        st.permaStuckCount = math.max(0, (st.permaStuckCount or 0) - 1)
    end

    if (st.permaStuckCount or 0) <= 0 then
        st.permaStuckActive = false
        st.permaStuckToken  = 0
        st.permaStuckNextMs = 0
        st.permaNotifyToken = 0
        mpResetPermaReleaseState(st, wp ~= nil and wp.vehicle or nil)
    end
end

local function mpIsVehicleControlledByPlayer(vehicle)
    if vehicle == nil or g_currentMission == nil then
        return false
    end

    if g_currentMission.controlledVehicle ~= nil and g_currentMission.controlledVehicle == vehicle then
        return true
    end

    if vehicle.getIsEntered ~= nil and vehicle:getIsEntered() then
        return true
    end

    if vehicle.spec_enterable ~= nil and vehicle.spec_enterable.isEntered == true then
        return true
    end

    return false
end

function MudPhysics:getRandomPermaStuckNotificationText()
    local fallback = {
        "Looks like you're stuck. Might be time to call a tow truck.",
        "This mud has won. A tow truck would help here.",
        "You're buried in the mud now. A tow might be the quickest way out.",
        "That turned into a proper bog-down. You may need recovery help.",
        "No easy way out now. Feels like tow-truck territory."
    }

    local variants = {}
    for i = 1, 5 do
        local key = string.format("mudsys_permaStuckNotice_%02d", i)
        if g_i18n ~= nil and g_i18n:hasText(key) then
            variants[#variants + 1] = g_i18n:getText(key)
        end
    end

    if #variants == 0 then
        variants = fallback
    end

    return variants[math.random(1, #variants)]
end

function MudPhysics:showPermaStuckNotification(vehicle, st)
    if self.permaStuckNotificationsEnable ~= true then
        return
    end

    if not mpIsVehicleControlledByPlayer(vehicle) then
        return
    end

    if g_currentMission == nil then
        return
    end

    local token = st ~= nil and (st.permaStuckToken or 0) or 0
    if st ~= nil and st.permaNotifyToken == token and token ~= 0 then
        return
    end

    local text = self:getRandomPermaStuckNotificationText()
    if text == nil or text == "" then
        return
    end

    if g_currentMission.addIngameNotification ~= nil and FSBaseMission ~= nil and FSBaseMission.INGAME_NOTIFICATION_CRITICAL ~= nil then
        g_currentMission:addIngameNotification(FSBaseMission.INGAME_NOTIFICATION_CRITICAL, text)
    elseif g_currentMission.showBlinkingWarning ~= nil then
        g_currentMission:showBlinkingWarning(text, 3500)
    end

    if st ~= nil then
        st.permaNotifyToken = token
    end
end

function MudPhysics:isWheelOnHardObjectSurface(wheel)
    if wheel == nil or wheel.physics == nil then
        return false
    end

    local wp = wheel.physics
    local objectContactType = (WheelContactType ~= nil and WheelContactType.OBJECT) or 2

    if wp.contact == objectContactType then
        return true
    end

    if wp.netInfo ~= nil and wp.netInfo.contact == objectContactType then
        return true
    end

    return false
end

function MudPhysics:getMudFactorCached(wheel, x, z)
    if wheel == nil or x == nil or z == nil then
        return 0, nil, nil
    end

    local cache = wheel.__mpGroundCache
    if cache == nil then
        cache = { t=-1, x=x, z=z, mud=0, layerName=nil, kind=nil }
        wheel.__mpGroundCache = cache
    end

    local now = self:getTimeMs()

    local intervalMs = self.groundSampleIntervalMs or 0
    local distTh     = self.groundSampleDistanceM  or 0

    local dtOk = (intervalMs <= 0) or (cache.t < 0) or ((now - cache.t) >= intervalMs)

    local distOk = true
    if distTh > 0 and cache.x ~= nil and cache.z ~= nil then
        local dx = x - cache.x
        local dz = z - cache.z
        distOk = (dx*dx + dz*dz) >= (distTh*distTh)
    end

    local hasHardObjectSurface = self:isWheelOnHardObjectSurface(wheel)
    local surfaceChanged = (cache.hardObjectSurface ~= hasHardObjectSurface)

    if surfaceChanged or (dtOk and distOk) then
        if hasHardObjectSurface then
            cache.t         = now
            cache.x         = x
            cache.z         = z
            cache.mud       = 0
            cache.layerName = "OBJECT_SURFACE"
            cache.kind      = "OBJECT"
            cache.fieldGT   = nil
            cache.fieldMud  = 0
            cache.hardObjectSurface = true

            return 0, cache.layerName, cache.kind
        end

        if self.disableBrushOnFields
            and _G.FieldGroundMudPhysics ~= nil
            and FieldGroundMudPhysics.enabled ~= false
            and type(FieldGroundMudPhysics.getProfileAtWorldPos) == "function" then

            local mudF, gt, prof = FieldGroundMudPhysics:getProfileAtWorldPos(x, z)

            if gt ~= nil and gt ~= 0 and type(prof) == "table" then
                cache.t         = now
                cache.x         = x
                cache.z         = z
                cache.mud       = 0
                cache.layerName = tostring(prof.name or "FIELD")
                cache.kind      = "FIELD"

                cache.fieldGT   = gt
                cache.fieldMud  = mudF or 0
                cache.hardObjectSurface = false

                return 0, cache.layerName, cache.kind
            end
        end

        local mud, layerName, kind = self:getMudFactorAtWorldPos(x, z)
        cache.t         = now
        cache.x         = x
        cache.z         = z
        cache.mud       = mud or 0
        cache.layerName = layerName
        cache.kind      = kind
        cache.fieldGT   = nil
        cache.fieldMud  = 0
        cache.hardObjectSurface = false
    end

    return cache.mud or 0, cache.layerName, cache.kind
end

-- =========================================================
-- WATER CONTACT helpers (GIANTS WheelPhysics.hasWaterContact)
-- =========================================================
function MudPhysics:isWheelInWater(wheel)
    if wheel == nil then return false end
    local wp = wheel.physics
    if wp ~= nil then
        if wp.hasWaterContact == true then
            return true
        end
        if wp.netInfo ~= nil and wp.netInfo.hasWaterContact == true then
            return true
        end
    end
    return false
end

function MudPhysics:computeVehicleWaterContact(vehicle)
    if vehicle == nil or vehicle.spec_wheels == nil or vehicle.spec_wheels.wheels == nil then
        return false
    end
    for _, w in ipairs(vehicle.spec_wheels.wheels) do
        if self:isWheelInWater(w) then
            return true
        end
    end
    return false
end

function MudPhysics:hasWheelGroundContact(wheel)
    if wheel == nil or wheel.physics == nil then return false end

    local wp = wheel.physics
    if self:isWheelInWater(wheel) then return true end

    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 explicitContact = nil
    if wp.hasGroundContact ~= nil then
        explicitContact = wp.hasGroundContact == true
    elseif wp.netInfo ~= nil and wp.netInfo.hasGroundContact ~= nil then
        explicitContact = wp.netInfo.hasGroundContact == true
    elseif wp.groundContact ~= nil then
        explicitContact = wp.groundContact == true
    end
    if explicitContact == false then return false end

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

    -- If a dedicated-server wheel exposes no contact flag, retain the old
    -- geometric fallback.  An explicit false always wins above.
    return explicitContact ~= false
end

function MudPhysics:computeVehicleMud(vehicle)
    if vehicle.spec_wheels == nil or vehicle.spec_wheels.wheels == nil then
        return 0, 0
    end

    local mudSum, slipSum, count = 0, 0, 0

    for _, w in ipairs(vehicle.spec_wheels.wheels) do
        local hasContact = self.hasWheelGroundContact == nil or self:hasWheelGroundContact(w)
        local x, _, z = self:getWheelContactPos(w)
        if hasContact and x ~= nil then
            local mud = self:getMudFactorCached(w, x, z)
			if self.waterActsAsMud and self:isWheelInWater(w) then
				local wm = self.waterMudFactor or 1.0
				mud = math.max(mud or 0, wm)
			end

            local slip = 0
            if w.physics ~= nil and w.physics.netInfo ~= nil then
                slip = w.physics.netInfo.slip or 0
            end
            mudSum  = mudSum + mud
            slipSum = slipSum + slip
            count   = count + 1
        end
    end

    if count == 0 then
        return 0, 0
    end

    return mudSum / count, slipSum / count
end

-- =========================================================
-- MOTOR LOAD (power loss)
-- =========================================================

function MudPhysics:applyMotorLoad(vehicle, effMud, slip, dt, pressureMul)
    local st = getState(vehicle)
    if mpTTDHandlesDynamics(vehicle) then
        mpRequestMotorLoad(vehicle, nil)
        st.__mpAppliedMotorLoad = false
        st.lastAppliedMotorLoad = 1
        st.__mpSmoothedMotorLoad = 1
        return
    end
    if not self.motorLoadEnable then
        mpRequestMotorLoad(vehicle, nil)
        st.__mpSmoothedMotorLoad = 1
        return
    end

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

    st.__mpAppliedMotorLoad = st.__mpAppliedMotorLoad or false

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

    local crawlerShare = mpGetVehicleCrawlerShare(vehicle)
    local motorMul = mpCrawlerLerp(1.0, self.crawlerMudMotorLoadMul or 0.55, crawlerShare)
    local model = _G.__MudTerramechanicsModel
    local target = 1
    if model ~= nil and effMud > (self.motorLoadMinEffMud or 0) then
        local soilState = st.__mpSoilState or model:updateSoilState(
            st, "__mpTerrainSeverity", effMud, st.sink, slip,
            (dt or 0)*0.001, self.terramechanicsEnterSec, self.terramechanicsExitSec
        )
        target = model:getMotorLoadTarget(
            soilState, self.motorLoadFromSink, self.motorLoadFromSlip,
            motorMul*math.max(0.05, tonumber(pressureMul) or 1),
            self.motorLoadMaxMult, st.stuck
        )
    end

    local mult
    if model ~= nil then
        mult = model:smoothMotorLoad(
            st, "__mpSmoothedMotorLoad", 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
        mpRequestMotorLoad(vehicle, nil)
        st.lastAppliedMotorLoad = 1
        st.__mpAppliedMotorLoad = false
    else
        if math.abs((st.lastAppliedMotorLoad or 1)-mult) > 0.005 then
            mpRequestMotorLoad(vehicle, mult)
            st.lastAppliedMotorLoad = mult
        end
        st.__mpAppliedMotorLoad = true
    end
end

-- =========================================================
-- DIRT / MUD (Washable)
-- =========================================================

function MudPhysics:applyMudDirt(vehicle, dt, effMudLive, wetnessEff, speedKph)
    if not self.dirtEnable then
        return
    end

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

    if vehicle == nil or vehicle.isServer ~= true then
        return
    end

    if vehicle.getDirtAmount == nil or vehicle.setDirtAmount == nil then
        return
    end

    local dtS = (dt or 0) * 0.001
    if dtS <= 0 then
        return
    end
	
	local freezeMul = self:getMudFreezeMult() or 1.0

    if (wetnessEff or 0) < (self.dirtWetnessMin or 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

    if effMudLive >= (self.dirtMinEffMud or 0) then
        local bodyAdd = (self.dirtBodyPerSec or 0.01) * effMudLive * speedFactor * dtS * freezeMul
        if bodyAdd > 0 then
            local cur = vehicle:getDirtAmount() or 0
            vehicle:setDirtAmount(clamp(cur + bodyAdd, 0, dirtMax))
        end
    end

    if vehicle.spec_wheels == nil or vehicle.spec_wheels.wheels == nil then
        return
    end

    if vehicle.getWashableNodeByCustomIndex == nil or vehicle.setNodeDirtAmount == nil then
        return
    end

    local wheels = vehicle.spec_wheels.wheels
    local minMud = (self.dirtMinEffMud or 0.06)

    for wheelIndex, w in ipairs(wheels) do
        if w ~= nil then
            local x, _, z = self:getWheelContactPos(w)
            if x ~= nil then
                local mudHere = (select(1, self:getMudFactorCached(w, x, z))) or 0

                if mudHere >= minMud then
                    local wetFactor = clamp(((wetnessEff or 0) - 0.15) / 0.55, 0, 1)
                    local effMudWheel = mudHere * wetFactor
                    effMudWheel = math.max(effMudWheel, mudHere * 0.20)

                    if effMudWheel >= minMud then
                        local wheelAdd = (self.dirtWheelPerSec or 0.045) * effMudWheel * speedFactor * dtS * freezeMul
                        if wheelAdd > 0 then
                            if MudWheelVisualSystem ~= nil
                                and MudWheelVisualSystem.reportMudContact ~= nil then
                                MudWheelVisualSystem:reportMudContact(
                                    vehicle,
                                    w,
                                    wheelIndex,
                                    dtS,
                                    wheelAdd,
                                    effMudWheel * speedFactor,
                                    dirtMax,
                                    MudExtraParticleEvent ~= nil
                                        and MudExtraParticleEvent.CHANNEL_MUD or 1
                                )
                            end
                        end
                    end
                end
            end
        end
    end
end

function MudPhysics:applyMudPhysics(vehicle, dt, mud, slip, wetness, waterAsMud)
    local st = getState(vehicle)

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

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

    local wetFactor = clamp((wetness - 0.15) / 0.55, 0, 1)
    local effMud = mud * wetFactor

    local vx, _, vz = 0, 0, 0
    if vehicle.getRootNode ~= nil then
        local rn = vehicle:getRootNode()
        if rn ~= nil and rn ~= 0 then
            vx, _, vz = getWorldTranslation(rn)
        end
    elseif vehicle.rootNode ~= nil then
        vx, _, vz = getWorldTranslation(vehicle.rootNode)
    end

    local timeMs = g_time or (g_currentMission ~= nil and g_currentMission.time or 0)
    local patchMul, bob = self:sampleMudVar(vx, vz, timeMs)

    local effMudLive = clamp(effMud * patchMul + bob * 0.25, 0, 1)

    local freezeMul = self:getMudFreezeMult()
    if freezeMul < 1.0 then
        effMudLive = effMudLive * freezeMul
    end

    local speedKph = mpGetVehicleSpeedKph(vehicle)
    st.__tmSpeedKph = speedKph
    local crawlerShare = mpGetVehicleCrawlerShare(vehicle)
    local crawlerSinkMul = mpCrawlerLerp(1.0, self.crawlerMudSinkMul or 0.48, crawlerShare)
    local crawlerBrakeMul = mpCrawlerLerp(1.0, self.crawlerMudBrakeMul or 0.40, crawlerShare)
    local crawlerSpeedMul = mpCrawlerLerp(1.0, self.crawlerMudSpeedLimitMul or 1.45, crawlerShare)
    local wheelLoadFactor = (WheelLoadSystem ~= nil and WheelLoadSystem.getVehicleLoadFactor ~= nil)
        and WheelLoadSystem:getVehicleLoadFactor(vehicle) or 1.0
    local pressureSinkMul = (TirePressureSystem ~= nil and TirePressureSystem.getVehicleSinkMultiplier ~= nil)
        and TirePressureSystem:getVehicleSinkMultiplier(vehicle) or 1.0
    local pressureSinkOutMul = (TirePressureSystem ~= nil and TirePressureSystem.getSinkOutMultiplier ~= nil)
        and TirePressureSystem:getSinkOutMultiplier(vehicle) or 1.0
    local pressureBrakeMul = (TirePressureSystem ~= nil and TirePressureSystem.getMudBrakeMultiplier ~= nil)
        and TirePressureSystem:getMudBrakeMultiplier(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

    if not (waterAsMud == true and self.waterDisableDirt) then
        self:applyMudDirt(vehicle, dt, effMudLive, wetness, speedKph)
    end

    st.mudRamp = st.mudRamp or 0

    local inSec  = math.max(0.05, self.mudRampInSec  or 8.0)
    local outSec = math.max(0.05, self.mudRampOutSec or 3.0)
    local powK   = math.max(0.25, self.mudRampPow or 1.5)

    local inMud = (effMudLive > 0.06)
    if inMud then
        st.mudRamp = math.min(1, st.mudRamp + dtS / inSec)
    else
        st.mudRamp = math.max(0, st.mudRamp - dtS / outSec)
    end

    local ramp = st.mudRamp ^ powK

    local effMudRamp = effMudLive * ramp

    local sinkIn  = effMudRamp * (0.30 + slip) * (self.sinkInSpeed or 1.0) * wheelLoadFactor * 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 + dtS * (sinkIn - sinkOut), 0, 1)
    st.stuck = st.sink >= (self.stuckThreshold or 0.80)
    local terrainModel = _G.__MudTerramechanicsModel
    if terrainModel ~= nil then
        st.__mpSoilState = terrainModel:updateSoilState(
            st, "__mpTerrainSeverity", effMudRamp, st.sink, slip, dtS,
            self.terramechanicsEnterSec or 1.35,
            self.terramechanicsExitSec or 2.25
        )
    end
    mpSubmitTerrainDynamics(vehicle, effMudRamp, wetness, slip, 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.__mpAppliedSpeedLimit = st.__mpAppliedSpeedLimit or false

    if motor ~= nil and motor.setSpeedLimit ~= nil then
        if terrainModel ~= nil and st.__mpSoilState ~= nil
            and (st.__mpSoilState.severity or 0) > 0.005 then
            local mudCap = (self.maxSpeedMudKph or 38.0)*crawlerSpeedMul*pressureSpeedMul
            local stuckCap = (self.maxSpeedStuckKph or 13.5)*crawlerSpeedMul*pressureSpeedMul
            local naturalKph = mpGetNaturalSpeedLimitKph(vehicle, motor, mudCap)
            local maxKph = terrainModel:getSoftSpeedLimit(
                naturalKph, mudCap, stuckCap, st.__mpSoilState, self.stuckThreshold
            )
            mpRequestSpeedLimit(vehicle, maxKph < naturalKph-0.05 and maxKph or nil)
            st.__mpAppliedSpeedLimit = true
        else
            mpRequestSpeedLimit(vehicle, nil)
            st.__mpAppliedSpeedLimit = false
        end
    else
        mpRequestSpeedLimit(vehicle, nil)
    end

    self:applyMotorLoad(vehicle, effMudRamp, slip, dt, pressureMotorLoadMul)

    if self.wheelBrakeEnable and vehicle.spec_wheels ~= nil and vehicle.spec_wheels.wheels ~= nil then
        local wheels = vehicle.spec_wheels.wheels
        local numWheels = #wheels
        local vehicleBrake = (vehicle.getBrakeForce ~= nil and vehicle:getBrakeForce()) or 0
        local perWheelBase = (numWheels > 0) and (vehicleBrake / numWheels) or 0

        local ratioBase = (self.wheelBrakeRatio or 0.03)
        local ratioMax  = (self.wheelBrakeMaxRatio or 2.5)
        local minEffMud = (self.wheelBrakeMinEffMud or 0.08)

        local timeMs2 = timeMs

        local baseRatio = 0
        if effMudRamp >= minEffMud and perWheelBase > 0 then
            if terrainModel ~= nil and st.__mpSoilState ~= nil then
                baseRatio = terrainModel:getResistanceRatio(
                    st.__mpSoilState, speedKph,
                    self.wheelBrakeBase, self.wheelBrakeFromSink, self.wheelBrakeFromSlip,
                    ratioBase, crawlerBrakeMul*pressureBrakeMul, ratioMax
                )
            else
                baseRatio = ratioBase*crawlerBrakeMul*pressureBrakeMul*effMudRamp*(
                    (self.wheelBrakeBase or 0)
                    +(self.wheelBrakeFromSink or 0)*st.sink
                    +(self.wheelBrakeFromSlip or 0)*slip
                )
            end

            baseRatio = baseRatio * clamp(patchMul + bob * 0.15, 0.65, 1.35)
            baseRatio = clamp(baseRatio, 0, ratioMax)
        end

        local inSpd  = math.max(0.05, self.wheelBrakeSmoothIn  or 1.15)
        local outSpd = math.max(0.05, self.wheelBrakeSmoothOut or 0.42)

        local wanderOn = (self.wheelBrakeWanderEnable ~= false)
        local wMin = clamp(self.wheelBrakeWanderMin or 0.80, 0.10, 2.50)
        local wMax = clamp(self.wheelBrakeWanderMax or 1.20, 0.10, 3.50)
        if wMax < wMin then wMin, wMax = wMax, wMin end

        local pMin = math.max(0.20, self.wheelBrakeWanderPeriodMin or 1.6)
        local pMax = math.max(pMin, self.wheelBrakeWanderPeriodMax or 3.4)

        local hardOn  = (self.wheelBrakeHardLockEnable == true)
        local hardPps = math.max(0.0, self.wheelBrakeHardLockChancePerSec or 0.020)
        local hardTmin= math.max(0.10, self.wheelBrakeHardLockTimeMin or 0.55)
        local hardTmax= math.max(hardTmin, self.wheelBrakeHardLockTimeMax or 1.25)
        local hardMul = clamp(self.wheelBrakeHardLockMul or 1.35, 1.0, 3.0)

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

                wp.__mpMudBrakeRatioCur    = wp.__mpMudBrakeRatioCur or 0
                wp.__mpMudBrakeRatioTarget = wp.__mpMudBrakeRatioTarget or 0
                wp.__mpMudBrakeNextWanderMs= wp.__mpMudBrakeNextWanderMs or 0
                wp.__mpMudBrakeHardT       = wp.__mpMudBrakeHardT or 0

                if wp.__mpMudBrakeHardT > 0 then
                    wp.__mpMudBrakeHardT = math.max(0, wp.__mpMudBrakeHardT - dtS)
                end

                local wheelCrawlerMul = mpIsCrawlerWheel(wp) and (self.crawlerMudBrakeMul or 0.40) or 1.0
                local target = baseRatio * wheelCrawlerMul

                if wanderOn and baseRatio > 0.0001 then
                    if timeMs2 >= (wp.__mpMudBrakeNextWanderMs or 0) then
                        local t = math.random()
                        local mul = wMin + (wMax - wMin) * t
                        wp.__mpMudBrakeRatioTarget = clamp(baseRatio * wheelCrawlerMul * mul, 0, ratioMax)

                        local per = pMin + (pMax - pMin) * math.random()
                        wp.__mpMudBrakeNextWanderMs = timeMs2 + math.floor(per * 1000)
                    end
                    target = wp.__mpMudBrakeRatioTarget or target
                end

                if hardOn and baseRatio > 0.0001 and wp.__mpMudBrakeHardT <= 0 and dtS > 0 then
                    local p = clamp(hardPps * dtS, 0, 0.25)
                    if math.random() < p then
                        wp.__mpMudBrakeHardT = hardTmin + (hardTmax - hardTmin) * math.random()
                    end
                end
                if wp.__mpMudBrakeHardT > 0 then
                    target = clamp(baseRatio * wheelCrawlerMul * hardMul, 0, ratioMax)
                end

                if baseRatio <= 0.0001 then
                    target = 0
                    wp.__mpMudBrakeRatioTarget = 0
                end

                local cur = wp.__mpMudBrakeRatioCur or 0
                if target > cur then
                    cur = math.min(target, cur + inSpd * dtS)
                else
                    cur = math.max(target, cur - outSpd * dtS)
                end
                wp.__mpMudBrakeRatioCur = cur

                wp.__mpMudBrakeExtra = perWheelBase * cur
            end
        end
    else
        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
                    w.physics.__mpMudBrakeExtra = 0
                    w.physics.__mpMudBrakeRatioCur = 0
                    w.physics.__mpMudBrakeRatioTarget = 0
                    w.physics.__mpMudBrakeHardT = 0
                end
            end
        end
    end
end

-- =========================================================
-- PARTICLE TUNING
-- =========================================================

local function cacheParticleOrig(ps)
    if ps == nil or ps.__mpCached then
        return
    end
    ps.__mpCached = true

    ps.__mpOrigEmit = ps.__mpOrigEmit or 1
    ps.__mpLastEmit = ps.__mpLastEmit or 1
    ps.__mpOrigSizeScale = ps.sizeScale or 1

    ps.__mpOrigSpriteScaleX = ParticleUtil.getParticleSystemSpriteScaleX and ParticleUtil.getParticleSystemSpriteScaleX(ps) or nil
    ps.__mpOrigSpriteScaleY = ParticleUtil.getParticleSystemSpriteScaleY and ParticleUtil.getParticleSystemSpriteScaleY(ps) or nil
    ps.__mpOrigSpriteScaleXGain = ParticleUtil.getParticleSystemSpriteScaleXGain and ParticleUtil.getParticleSystemSpriteScaleXGain(ps) or nil
    ps.__mpOrigSpriteScaleYGain = ParticleUtil.getParticleSystemSpriteScaleYGain and ParticleUtil.getParticleSystemSpriteScaleYGain(ps) or nil

    ps.__mpOrigParticleSpeed = ps.particleSpeed
    ps.__mpOrigParticleRandomSpeed = ps.particleRandomSpeed
end

local function applyWetMudBoost(ps)
    if ps == nil then
        return
    end
    cacheParticleOrig(ps)

	local sizeMult  = MudPhysics.sizeMultWetMud or 1.0
	local speedMult = MudPhysics.speedMultWetMud or 1.0
	local emitMult  = MudPhysics.emitMultWetMud or 1.0

	local freezeMul = 1.0
	if MudPhysics.getMudFreezeMult ~= nil then
		freezeMul = MudPhysics:getMudFreezeMult()
	end

	sizeMult  = 1.0 + (sizeMult  - 1.0) * freezeMul
	speedMult = 1.0 + (speedMult - 1.0) * freezeMul
	emitMult  = 1.0 + (emitMult  - 1.0) * freezeMul

    ps.sizeScale = (ps.__mpOrigSizeScale or 1) * sizeMult

    if ps.__mpOrigSpriteScaleX ~= nil and ParticleUtil.setParticleSystemSpriteScaleX then
        ParticleUtil.setParticleSystemSpriteScaleX(ps, ps.__mpOrigSpriteScaleX * sizeMult)
    end
    if ps.__mpOrigSpriteScaleY ~= nil and ParticleUtil.setParticleSystemSpriteScaleY then
        ParticleUtil.setParticleSystemSpriteScaleY(ps, ps.__mpOrigSpriteScaleY * sizeMult)
    end
    if ps.__mpOrigSpriteScaleXGain ~= nil and ParticleUtil.setParticleSystemSpriteScaleXGain then
        ParticleUtil.setParticleSystemSpriteScaleXGain(ps, ps.__mpOrigSpriteScaleXGain * sizeMult)
    end
    if ps.__mpOrigSpriteScaleYGain ~= nil and ParticleUtil.setParticleSystemSpriteScaleYGain then
        ParticleUtil.setParticleSystemSpriteScaleYGain(ps, ps.__mpOrigSpriteScaleYGain * sizeMult)
    end

    if ps.__mpOrigParticleSpeed ~= nil then
        ps.particleSpeed = ps.__mpOrigParticleSpeed * speedMult
    end
    if ps.__mpOrigParticleRandomSpeed ~= nil then
        ps.particleRandomSpeed = ps.__mpOrigParticleRandomSpeed * speedMult
    end

    if ParticleUtil.setEmitCountScale then
        local target = math.max(ps.__mpLastEmit or 1, emitMult)
        ParticleUtil.setEmitCountScale(ps, target)
        ps.__mpLastEmit = target
    end
end

local function restoreParticle(ps)
    if ps == nil or not ps.__mpCached then
        return
    end

    ps.sizeScale = ps.__mpOrigSizeScale or ps.sizeScale or 1

    if ps.__mpOrigSpriteScaleX ~= nil and ParticleUtil.setParticleSystemSpriteScaleX then
        ParticleUtil.setParticleSystemSpriteScaleX(ps, ps.__mpOrigSpriteScaleX)
    end
    if ps.__mpOrigSpriteScaleY ~= nil and ParticleUtil.setParticleSystemSpriteScaleY then
        ParticleUtil.setParticleSystemSpriteScaleY(ps, ps.__mpOrigSpriteScaleY)
    end
    if ps.__mpOrigSpriteScaleXGain ~= nil and ParticleUtil.setParticleSystemSpriteScaleXGain then
        ParticleUtil.setParticleSystemSpriteScaleXGain(ps, ps.__mpOrigSpriteScaleXGain)
    end
    if ps.__mpOrigSpriteScaleYGain ~= nil and ParticleUtil.setParticleSystemSpriteScaleYGain then
        ParticleUtil.setParticleSystemSpriteScaleYGain(ps, ps.__mpOrigSpriteScaleYGain)
    end

    if ps.__mpOrigParticleSpeed ~= nil then
        ps.particleSpeed = ps.__mpOrigParticleSpeed
    end
    if ps.__mpOrigParticleRandomSpeed ~= nil then
        ps.particleRandomSpeed = ps.__mpOrigParticleRandomSpeed
    end

    if ParticleUtil.setEmitCountScale then
        local base = ps.__mpOrigEmit or 1
        ParticleUtil.setEmitCountScale(ps, base)
        ps.__mpLastEmit = base
    end
end

-- =========================================================
-- EXTRA WHEEL SINK (помошник для еболы колёсной)
-- =========================================================

computeWetnessEffective = function(groundWetness)
    local wetnessEff = groundWetness or 0

    if MudPhysics.isHardWinterFrozen ~= nil and MudPhysics:isHardWinterFrozen() then
        return 0
    end
    local weather = getWeather()
    if weather ~= nil then
        if weather.getGroundWetness ~= nil then
            wetnessEff = math.max(wetnessEff, weather:getGroundWetness() or 0)
        end
        if weather.getRainFallScale ~= nil then
            local rain = weather:getRainFallScale() or 0
            if rain >= (MudPhysics.rainScaleThreshold or 0) then
                wetnessEff = math.max(wetnessEff, MudPhysics.rainForcesWetnessMin or wetnessEff)
            end
        end
    end
    return wetnessEff
end

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

    local wp = wheel.physics

    if wp.netInfo ~= nil then
        if wp.netInfo.hasGroundContact ~= nil then return wp.netInfo.hasGroundContact == true end
        if wp.netInfo.groundContact   ~= nil then return wp.netInfo.groundContact   == true end
        if wp.netInfo.contact         ~= nil then return wp.netInfo.contact         == true end
    end

    if wp.hasGroundContact ~= nil then return wp.hasGroundContact == true end
    if wp.contact ~= nil then return wp.contact == true end

    return true
end

local function mpHasTerrainContactAt(wheel, wx, wy, wz)
    if wheel == nil or wx == nil or wz == nil or wy == nil then
        return false
    end

    local terrainNode = MudPhysics ~= nil and MudPhysics.getTerrainNode ~= nil and MudPhysics:getTerrainNode() or nil
    if terrainNode == nil or getTerrainHeightAtWorldPos == nil then
        return false
    end

    local th = getTerrainHeightAtWorldPos(terrainNode, wx, 0, wz)
    if th == nil then
        return false
    end

    local tol = (MudPhysics.extraParticleContactTol or 0.14)
    local dy = (wy - th)

    return dy <= tol
end

mpHasRealContactForExtra = function(wheel, wx, wy, wz)
    return mpHasTerrainContactAt(wheel, wx, wy, wz) == true
end

-- =========================================================
function MudPhysics:isMultiplayerSession()
    if g_currentMission ~= nil and g_currentMission.missionDynamicInfo ~= nil then
        return g_currentMission.missionDynamicInfo.isMultiplayer == true
    end
    return false
end

-- =========================================================
-- HOOKS
-- =========================================================

function MudPhysics.installHooks()
    if Utils == nil or Utils.overwrittenFunction == nil then
        print("[MudPhysics] 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 MudPhysics:isVehicleResetting(self.vehicle) then
                    if self.wheel ~= nil then
                        mpStopWheelExtraPS(self.vehicle, self.wheel)
                    end
                    if superFunc ~= nil then superFunc(self, dt, groundWetness, currentUpdateDistance) end
                    return
                end

                if not MudPhysics.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
                        mpStopWheelExtraPS(self.vehicle, self.wheel)
                    end
                    if superFunc ~= nil then
                        superFunc(self, dt, groundWetness, currentUpdateDistance)
                    end
                    return
                end

                if MudPhysics.isInShopPreview ~= nil and MudPhysics:isInShopPreview(self.vehicle) then
                    mpResetMudEffectsForPreview(self.vehicle)

                    if self.driveGroundParticleSystems ~= nil then
                        for _, ps in ipairs(self.driveGroundParticleSystems) do
                            restoreParticle(ps)
                        end
                    end

                    mpStopWheelExtraPS(self.vehicle, self.wheel)

                    if self.wheel ~= nil and self.wheel.physics ~= nil and self.wheel.physics.__mpOrigHasSoilContact ~= nil then
                        self.wheel.physics.hasSoilContact = self.wheel.physics.__mpOrigHasSoilContact
                    end

                    if superFunc ~= nil then
                        superFunc(self, dt, groundWetness, currentUpdateDistance)
                    end
                    return
                end
				
				-- =========================================================
                -- HARD GATE: если техника стоит -> вообще ничего не считаем
                -- =========================================================
                local gateWp = self.wheel ~= nil and self.wheel.physics or nil
                local gateNow = g_time or (g_currentMission ~= nil and g_currentMission.time or 0)
                local networkKeepsExtraActive = gateWp ~= nil
                    and gateWp.__mpExtraPSNetActive == true
                    and gateWp.__mpExtraPSNetUntil ~= nil
                    and gateNow <= gateWp.__mpExtraPSNetUntil
                if MudPhysics.onlyProcessWhenMoving == true and not networkKeepsExtraActive then
                    local rn = mpGetRootNodeForWheel(self.vehicle, self.wheel)
                    if not MudPhysics:isVehicleMoving(self.vehicle, rn) then
                        if MudPhysics.stopTurnsOffParticles ~= false then
                            if MudPhysics.particlesEnable and self.driveGroundParticleSystems ~= nil then
                                for _, ps in ipairs(self.driveGroundParticleSystems) do
                                    restoreParticle(ps)
                                end
                            end

                            if MudPhysics.extraParticlesEnable and not mpIsDedicatedServer() then
                                mpStopWheelExtraPS(self.vehicle, self.wheel)
                            end
                        end

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

                local wetnessEff = computeWetnessEffective(groundWetness)
                if MudPhysics.waterActsAsMud and MudPhysics.waterForcesWetness and MudPhysics:computeVehicleWaterContact(self.vehicle) then
                    wetnessEff = 1.0
                end

                local wetnessFinal = math.max(groundWetness or 0, wetnessEff or 0)

				local x, y, z = MudPhysics:getWheelContactPos(self.wheel)
				local mudHere, mudLayerName, mudKind = 0, nil, nil

				local inWater = false
				local waterMudHere = 0

				if x ~= nil then
					mudHere, mudLayerName, mudKind = MudPhysics:getMudFactorCached(self.wheel, x, z)

					inWater = MudPhysics.waterActsAsMud and MudPhysics:isWheelInWater(self.wheel)
					if inWater then
						waterMudHere = MudPhysics.waterMudFactor or 1.0
						mudHere = math.max(mudHere or 0, waterMudHere)

						mudLayerName = mudLayerName or "water"
						mudKind = mudKind or "water"
					end

				end

				
				local hasTerrainContact = false
				if x ~= nil and y ~= nil and z ~= nil then
					hasTerrainContact = mpHasTerrainContactAt(self.wheel, x, y, z) == true
				end


                local forcedSoil = false
                if hasTerrainContact
				   and not inWater
				   and mudHere >= (MudPhysics.mudMinToAffect or 0.10)
				   and (mudKind == "WATER" or wetnessFinal > (MudPhysics.wetnessThreshold or 0.10))
				then
                    if self.wheel.physics ~= nil then
                        self.wheel.physics.__mpOrigHasSoilContact = self.wheel.physics.__mpOrigHasSoilContact or self.wheel.physics.hasSoilContact
                        self.wheel.physics.hasSoilContact = true
                        forcedSoil = true
                    end
                end

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

                if forcedSoil and self.wheel.physics ~= nil and self.wheel.physics.__mpOrigHasSoilContact ~= nil then
                    self.wheel.physics.hasSoilContact = self.wheel.physics.__mpOrigHasSoilContact
                end

                local hasRealContact = mpHasGroundContact(self.wheel)
                if not hasRealContact and self.wheel ~= nil and self.wheel.physics ~= nil then
                    hasRealContact = (self.wheel.physics.hasSoilContact == true)
                end

				local wetMud = hasTerrainContact
					and (mudHere >= (MudPhysics.mudMinToAffect or 0.10))
					and (
						mudKind == "WATER"
						or (wetnessFinal > (MudPhysics.wetnessThreshold or 0.10))
					)



                if MudPhysics.particlesEnable and self.driveGroundParticleSystems ~= nil then
                    for _, ps in ipairs(self.driveGroundParticleSystems) do
                        if wetMud and (ps.isActive == true) then
							applyWetMudBoost(ps)
						else
							restoreParticle(ps)
						end

                    end
                end

				do
					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.__mpExtraPSNetUntil ~= nil
						and netNow <= netWp.__mpExtraPSNetUntil

					if not networkControlled and MudPhysics.extraParticlesEnable and not mpIsDedicatedServer()
                        and not mpFieldGroundOwnsExtraParticles(self.wheel) then

						local freezeMul = 1.0
						if MudPhysics.getMudFreezeMult ~= nil then
							freezeMul = MudPhysics:getMudFreezeMult() or 1.0
						end
						local isFrozenMud = (MudPhysics.freezeMudByTempEnable == true) and (freezeMul < 1.0)
						local disableExtraWhenFrozen = (MudPhysics.disableExtraParticlesWhenFrozen ~= false)

						-- HARD WINTER FREEZE: если включено и холодно, любые кастомные грязевые частицы выключаем
						local hardWinterFrozen = (MudPhysics.isHardWinterFrozen ~= nil and MudPhysics:isHardWinterFrozen())

						-- если замёрзло "по-зимнему" — сразу гасим PS и выходим, дабы не хуярило дальше
						if hardWinterFrozen then
							mpStopWheelExtraPS(self.vehicle, self.wheel)
						else
							if not (disableExtraWhenFrozen and isFrozenMud) then
								if MudPhysics._extraPS == nil or MudPhysics._extraPS.sourcePsNode == nil or MudPhysics._extraPS.referenceShape == nil then
									if MudPhysics.loadExtraParticleReferences ~= nil then
										MudPhysics.loadExtraParticleReferences()
									end
								end

								if x ~= nil and z ~= nil and updateWheelExtraPS ~= nil then
									local active = false

									local inMudLayer = (mudHere or 0) >= (MudPhysics.mudMinToAffect or 0.10)

									local hasContactExtra = mpHasRealContactForExtra(self.wheel, x, (y or 0), z)

									if hasContactExtra and inMudLayer then
										if MudPhysics.extraParticleOnlyWetMud == false then
											active = true
										else
											active = (mudKind == "WATER")
												or ((wetnessFinal or 0) > (MudPhysics.wetnessThreshold or 0.10))
										end
									end

									if active then
										local rootNode = mpGetRootNodeForWheel(self.vehicle, self.wheel)
										local speedKph = mpGetClientVisualSpeedKph(self.vehicle, rootNode)
										local slip = 0
										if self.wheel ~= nil and self.wheel.physics ~= nil then
											slip = (self.wheel.physics.netInfo ~= nil and self.wheel.physics.netInfo.slip) or self.wheel.physics.slip or 0
										end

										local stV = getState(self.vehicle)
										local isPerma = (stV ~= nil and stV.permaStuckActive == true)
											or (self.wheel ~= nil and self.wheel.physics ~= nil and self.wheel.physics.__mpPermaStuck == true)

										local minKph = MudPhysics.extraParticleMinSpeedKph or 7.2
										if isPerma then
											minKph = MudPhysics.extraParticleMinSpeedKphPerma or 0.9
										end

										if speedKph < minKph and slip < (MudPhysics.extraParticleBurstSlipMin or 0.15) then
											active = false
										end
										if active and not mpHasParticleMotion(self.vehicle, self.wheel) then
											active = false
										end
									end

									if active and MudPhysics.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 0

										wp.__mpExtraBurstT = math.max(0, (wp.__mpExtraBurstT or 0) - (dt or 0) * 0.001)

										if slip >= (MudPhysics.extraParticleBurstSlipMin or 0.15) then
											local chancePerSec = MudPhysics.extraParticleBurstChancePerSec or 0.20
											local slipMin = MudPhysics.extraParticleBurstSlipMin or 0.15
											local slipFull = math.max(slipMin + 0.01, MudPhysics.extraParticleBurstSlipFull or 0.65)
											local slip01 = clamp((slip - slipMin) / (slipFull - slipMin), 0, 1)
										local speedKph = mpGetVehicleSpeedKph(self.vehicle)
											local speed01 = clamp(speedKph / math.max(0.1, MudPhysics.extraParticleBurstSpeedFullKph or 22.0), 0, 1)
											local still01 = 1 - speed01
											local chanceMul = 0.30
												+ slip01 * (MudPhysics.extraParticleBurstChanceSlipMul or 2.10)
												+ speed01 * (MudPhysics.extraParticleBurstChanceSpeedMul or 0.85)
												+ still01 * slip01 * (MudPhysics.extraParticleBurstChanceStillMul or 1.35)
											local p = clamp((chancePerSec * chanceMul) * (dt or 0) * 0.001, 0, 0.85)

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

									updateWheelExtraPS(self.vehicle, self.wheel, active, x, y or 0, z, dt)
								else
									mpStopWheelExtraPS(self.vehicle, self.wheel)
								end

							else
								mpStopWheelExtraPS(self.vehicle, self.wheel)
							end
						end
					elseif not networkControlled then
						-- Field mud took ownership, particles were disabled, or
						-- the wheel left a DIRT/MUD layer: kill the old emitter.
						mpStopWheelExtraPS(self.vehicle, self.wheel)
					end
				end

            end
        )
    else
        print("[MudPhysics] WheelEffects not found (dedicated/server) - skipping particle hooks")
    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 MudPhysics:isVehicleResetting(self.vehicle) then
                    self.__mpMudBrakeExtra = 0
                    if superFunc ~= nil then superFunc(self, brakeForce, torque) end
                    return
                end

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

                local extra = 0
                if MudPhysics.enabled and MudPhysics.wheelBrakeEnable and self ~= nil
                    and not mpTTDHandlesDynamics(self.vehicle) then
                    extra = self.__mpMudBrakeExtra or 0

                    local deep = self.__mpMudDeepFactor or 0
                    if deep > 0 then
                        extra = extra * (1.0 + deep * 0.95)
                    end

                    local rt = self.__mpMudReliefT or 0
                    if rt > 0 then
                        extra = extra * (MudPhysics.reliefBrakeMult or 0.55)
                    end
                end

                if superFunc ~= nil then
                    superFunc(self, (brakeForce or 0) + extra, torque)
                end
            end
        )
    else
        print("[MudPhysics] WheelPhysics.updatePhysics not found (wheel brake resistance disabled)")
    end
	
	if WheelPhysics ~= nil and WheelPhysics.updateTireFriction ~= nil then
		WheelPhysics.updateTireFriction = Utils.overwrittenFunction(
			WheelPhysics.updateTireFriction,
			function(self, superFunc, dt)
				if self ~= nil and self.vehicle ~= nil and MudPhysics.isInShopPreview ~= nil and MudPhysics:isInShopPreview(self.vehicle) then
					if superFunc ~= nil then
						superFunc(self, dt)
					end
					return
				end
				
				if MudPhysics.onlyProcessWhenMoving == true and self ~= nil and self.vehicle ~= nil then
                    local rn = mpGetRootNodeForWheel(self.vehicle, self.wheel)
                    if not MudPhysics:isVehicleMoving(self.vehicle, rn) then
                        if superFunc ~= nil then
                            superFunc(self, dt)
                        end
                        return
                    end
                end

				local origFrictionScale = self ~= nil and self.frictionScale or nil
				local applied = false

				if MudPhysics.enabled
					and self ~= nil
					and self.wheel ~= nil
					and origFrictionScale ~= nil
					and MudPhysics.getSlipMultByTemperature ~= nil
					and not mpTTDHandlesDynamics(self.vehicle) then

					local wx, _, wz = MudPhysics:getWheelContactPos(self.wheel)
					if wx ~= nil then
						local mudHere = (select(1, MudPhysics:getMudFactorCached(self.wheel, wx, wz))) or 0
                        local inWater = MudPhysics.waterActsAsMud and (self.hasWaterContact == true or (self.netInfo ~= nil and self.netInfo.hasWaterContact == true))
                        if inWater then
                            mudHere = math.max(mudHere, MudPhysics.waterMudFactor or 1.0)
                        end

						local onSnow = MudPhysics:isWheelOnSnow(self.wheel, wx, wz)
						local mul = MudPhysics:getSlipMultByTemperature() or 1.0
						if onSnow and MudPhysics.winterSlipEnable == true then
							mul = clamp(MudPhysics.winterSlipMul or 1.15, 0.10, 2.00)
						end

						if MudPhysics.rainSlipEnable and not onSnow then
							local weather = g_currentMission.environment.weather
							local wet = weather and weather:getGroundWetness() or 0

							if wet >= (MudPhysics.rainSlipWetnessMin or 0.2) then
								local t = clamp((wet - (MudPhysics.rainSlipWetnessMin or 0.2)) / 0.6, 0, 1)
								local rainMul = (MudPhysics.rainSlipMul or 0.85)
									+ ((MudPhysics.rainSlipMaxMul or 0.65) - (MudPhysics.rainSlipMul or 0.85)) * t

								if mudHere >= (MudPhysics.mudMinToAffect or 0.10) then
									rainMul = rainMul * 0.85
								end

								mul = mul * rainMul
							end
						end

						if math.abs(mul - 1.0) > 0.001 then
							self.frictionScale = math.max(0.01, origFrictionScale * clamp(mul, 0.10, 2.00))
							applied = true
						end
					end

				end

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

				if applied then
					self.frictionScale = origFrictionScale
				end
			end
		)
	else
		print("[MudPhysics] WheelPhysics.updateTireFriction not found (temperature slip disabled)")
	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 self ~= nil and self.vehicle ~= nil and MudPhysics:isVehicleResetting(self.vehicle) then
					mpSendServerExtraParticleState(self, dt, groundWetness, true)
					mpClearDriveRequests(self.vehicle)
					self.__mpDesiredRadius = nil
					self.__mpMudCurExtra = 0
					self.__mpMudBrakeExtra = 0
					if _G.__MudRadiusCombiner ~= nil and _G.__MudRadiusCombiner.apply ~= nil then
						_G.__MudRadiusCombiner.apply(self, MudPhysics.freezeRadiusEps or 0.0015)
					end
					return
				end

				if self ~= nil and self.vehicle ~= nil and MudPhysics.isInShopPreview ~= nil and MudPhysics:isInShopPreview(self.vehicle) then
					mpSendServerExtraParticleState(self, dt, groundWetness, true)
					mpClearDriveRequests(self.vehicle)
					if self.__mpOrigRadius ~= nil then
						self.__mpDesiredRadius = nil
						_G.__MudRadiusCombiner.apply(self, MudPhysics.freezeRadiusEps or 0.0015)
					end
					self.__mpMudDeepFactor = 0
					self.__mpMudBrakeExtra = 0
					local stPreview = getState(self.vehicle)
					stPreview.__mpTerrainSeverity = 0
					stPreview.__mpSoilState = nil
					stPreview.__mpSmoothedMotorLoad = 1
					return
				end

				local timeMs = g_time or (g_currentMission ~= nil and g_currentMission.time or 0)
				local stCore = getState(self.vehicle)
				if not MudPhysics.enabled then
					if stCore.__mpDriveClearTick ~= timeMs then
						stCore.__mpDriveClearTick = timeMs
						mpClearDriveRequests(self.vehicle)
					end
					stCore.sink = 0
					stCore.stuck = false
					stCore.__mpTerrainSeverity = 0
					stCore.__mpSoilState = nil
					stCore.__mpSmoothedMotorLoad = 1
				elseif self.vehicle.isServer == true and stCore.__mpPhysicsTick ~= timeMs then
					stCore.__mpPhysicsTick = timeMs
					local wetnessEff = computeWetnessEffective(groundWetness)
					local mudAvg, slipAvg = MudPhysics:computeVehicleMud(self.vehicle)
					local waterAsMud = MudPhysics.waterActsAsMud and MudPhysics:computeVehicleWaterContact(self.vehicle)
					MudPhysics:applyMudPhysics(self.vehicle, dt, mudAvg, slipAvg, wetnessEff, waterAsMud)

					if MudPhysics.debug and timeMs - (stCore.lastDbgTime or 0) > (MudPhysics.debugPrintIntervalMs or 700) then
						stCore.lastDbgTime = timeMs
						print(string.format("[MudPhysics] wet=%.2f mud=%.2f slip=%.2f sink=%.2f stuck=%s motorLoad=%.2f",
							wetnessEff, mudAvg, slipAvg, stCore.sink or 0, tostring(stCore.stuck), stCore.lastAppliedMotorLoad or 1))
					end
				end

				mpSendServerExtraParticleState(
					self, dt, groundWetness,
					not MudPhysics.enabled or not MudPhysics.extraParticlesEnable
				)

				if not MudPhysics.enabled or not MudPhysics.extraWheelSinkEnable then
					self.__mpDesiredRadius = nil
					_G.__MudRadiusCombiner.apply(self, MudPhysics.freezeRadiusEps or 0.0015)
					return
				end
				if self.vehicle == nil or self.wheel == nil or self.vehicle.isServer ~= true then
					return
				end

				local dtS = (dt or 0) * 0.001
				if self.__mpOrigRadius == nil then
					self.__mpOrigRadius = self.radiusOriginal or self.radius
				end
				local r0 = self.__mpOrigRadius
				if r0 == nil then return end
				local isCrawlerWheel = mpIsCrawlerWheel(self)
				local crawlerWheelSinkMul = isCrawlerWheel and (MudPhysics.crawlerMudWheelSinkMul or 0.42) or 1.0
				local crawlerRadiusMinBoost = isCrawlerWheel and (MudPhysics.crawlerMudRadiusMinBoost or 0.24) or 0.0
				local crawlerPermaChanceMul = isCrawlerWheel and (MudPhysics.crawlerMudPermaStuckChanceMul or 0.18) or 1.0
				local crawlerDeepChanceMul = isCrawlerWheel and (MudPhysics.crawlerMudDeepPocketChanceMul or 0.35) or 1.0

				local eps = MudPhysics.freezeRadiusEps or 0.0015
				
				-- =========================================================
                -- HARD GATE: если техника стоит -> не считаем радиус/грязь вообще
                -- =========================================================
                if MudPhysics.onlyProcessWhenMoving == true then
                    local speedKphAbs = mpGetVehicleSpeedKph(self.vehicle)

                    local spTh = MudPhysics.movingSpeedKph
                    if spTh == nil then spTh = MudPhysics.freezeStopSpeedKph or 0.60 end

                    local slTh = MudPhysics.movingSlip
                    if slTh == nil then slTh = MudPhysics.freezeStopSlip or 0.010 end

                    local slipNow = (self.netInfo ~= nil and self.netInfo.slip) or 0

                    if speedKphAbs <= spTh and (slipNow or 0) <= slTh then
                        return
                    end
                end

				if (self.__mpMudReliefT or 0) > 0 then
					self.__mpMudReliefT = math.max(0, self.__mpMudReliefT - dtS)
				end
				if (self.__mpMudPocketT or 0) > 0 then
					self.__mpMudPocketT = math.max(0, self.__mpMudPocketT - dtS)
					if self.__mpMudPocketT <= 0 then
						self.__mpMudPocketMul = nil
					end
				end
				if (self.__mpMudPocketCD or 0) > 0 then
					self.__mpMudPocketCD = math.max(0, self.__mpMudPocketCD - dtS)
				end
				if (self.__mpPermaStuckCD or 0) > 0 then
					self.__mpPermaStuckCD = math.max(0, self.__mpPermaStuckCD - dtS)
				end

				local wx, wy, wz = MudPhysics:getWheelContactPos(self.wheel)
				if wx == nil then return end

				local hasRealContact = MudPhysics:hasWheelGroundContact(self.wheel)
				local mudHere, mudKind = 0, nil
				if hasRealContact then
					mudHere, _, mudKind = MudPhysics:getMudFactorCached(self.wheel, wx, wz)
					mudHere = mudHere or 0
				end

				local inWater = hasRealContact and MudPhysics.waterActsAsMud and MudPhysics:isWheelInWater(self.wheel)
				if inWater then
					local wm = MudPhysics.waterMudFactor or 1.0
					mudHere = math.max(mudHere, wm)
					mudKind = "WATER" -- вода как отдельный тип
				end

				
				-- ---------------------------------------------------------
				-- WATER: FIXED RADIUS MODE (SMOOTH RAMP IN/OUT)
				-- ---------------------------------------------------------
				if MudPhysics.waterFixedRadiusEnable == true then
					self.__mpWaterFixedT = self.__mpWaterFixedT or 0

					local inSec  = math.max(0.05, MudPhysics.waterFixedRadiusRampInSec  or 3.75)
					local outSec = math.max(0.05, MudPhysics.waterFixedRadiusRampOutSec or 4.50)

					local wantT = inWater and 1 or 0
					local t = self.__mpWaterFixedT

					if wantT > t then
						t = math.min(1, t + (dtS / inSec))
					elseif wantT < t then
						t = math.max(0, t - (dtS / outSec))
					end
					self.__mpWaterFixedT = t

					if t > 0 then
						local f = clamp(MudPhysics.waterFixedRadiusFactor or 0.80, 0.20, 0.98)

						local forcedRadius = r0 * (1.0 - t * (1.0 - f))

						local smallTh  = MudPhysics.smallWheelRadiusThreshold or 0.60
						local smallMin = mpGetSmallWheelMinAbs(r0) or (MudPhysics.smallWheelMinRadiusAbs or 0.25)
						if r0 < smallTh and type(smallMin) == "number" and smallMin > 0 then
							forcedRadius = math.min(r0, math.max(forcedRadius, smallMin))
						end

						self.__mpMudCurExtra   = math.max(0, r0 - forcedRadius)
						self.__mpMudDeepFactor = t
						self.__mpDesiredRadius = forcedRadius

						_G.__MudRadiusCombiner.apply(self, MudPhysics.freezeRadiusEps or 0.0015)
						return
					end
				end

				-- ---------------------------------------------------------
				self.__mpWasInWater = (self.__mpWasInWater == true)

				if (not inWater) and self.__mpWasInWater then
					self.__mpWaterExitSlowT = MudPhysics.waterExitSlowSec or 1.4
				end
				self.__mpWasInWater = inWater

				if (self.__mpWaterExitSlowT or 0) > 0 then
					self.__mpWaterExitSlowT = math.max(0, (self.__mpWaterExitSlowT or 0) - dtS)
				end

				self.__mpWaterHoldT = self.__mpWaterHoldT or 0
				if inWater then
					self.__mpWaterHoldT = MudPhysics.waterHoldAfterExitSec or 0.85
				else
					self.__mpWaterHoldT = math.max(0, (self.__mpWaterHoldT or 0) - dtS)
				end

				local waterBlend = 0
				local holdMax = MudPhysics.waterHoldAfterExitSec or 0.85
				if self.__mpWaterHoldT > 0 and holdMax > 0 then
					local t = clamp(self.__mpWaterHoldT / holdMax, 0, 1) -- 1..0
					local p = MudPhysics.waterHoldPow or 1.0
					waterBlend = (p ~= 1.0) and (t ^ p) or t
				end

				if not inWater and waterBlend > 0 then
					mudHere = math.max(mudHere, (MudPhysics.waterMudFactor or 1.0) * waterBlend)
				end

				local st = getState(self.vehicle)
				mpUpdatePermaReleaseState(st, self.vehicle, timeMs)

				if mudHere < (MudPhysics.mudMinToAffect or 0) then
					self.__mpWasMud = false
					self.__mpMudPocketT = 0
					self.__mpMudPocketMul = nil
					self.__mpMudPocketCD = 0
					self.__mpMudStillT = 0

					mpClearPermaStuckState(self, st)

					local cur = self.__mpMudCurExtra or 0
					if cur > 0 then
						local outSpd = (MudPhysics.radiusSinkOutSpeed or 0.75)
						if (self.__mpWaterExitSlowT or 0) > 0 then
							outSpd = math.min(outSpd, MudPhysics.waterExitRadiusOutSpeed or outSpd)
						end

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

					if (self.__mpMudCurExtra or 0) > 0 then
						local r = clamp(r0 - (self.__mpMudCurExtra or 0), 0.12, r0)

						local smallTh  = MudPhysics.smallWheelRadiusThreshold or 0.60
						local smallMin = mpGetSmallWheelMinAbs(r0) or (MudPhysics.smallWheelMinRadiusAbs or 0.25)
						if r0 < smallTh and type(smallMin) == "number" and smallMin > 0 then
							r = math.min(r0, math.max(r, smallMin))
						end

						self.__mpDesiredRadius = r
					else
						self.__mpDesiredRadius = nil
					end

					_G.__MudRadiusCombiner.apply(self, eps)

					self.__mpMudDeepFactor = 0
					self.__mpWheelSink = 0

					return

				end
				
				local wetnessEff = computeWetnessEffective(groundWetness)
				
				if MudPhysics.waterActsAsMud and MudPhysics.waterForcesWetness then
					if inWater or (waterBlend > 0) then
						wetnessEff = 1.0
					end
				end


				if (mudKind ~= "WATER")
				   and ((wetnessEff or 0) <= (MudPhysics.wetnessThreshold or 0.10))
				then

					self.__mpWasMud = false
					self.__mpMudPocketT   = 0
					self.__mpMudPocketMul = nil
					self.__mpMudPocketCD  = 0
					self.__mpMudStillT    = 0

					mpClearPermaStuckState(self, st)

					local dtS2 = (dt or 0) * 0.001
					local outSpd = (MudPhysics.radiusSinkOutSpeed or 0.75)
					local cur = self.__mpMudCurExtra or 0
					if cur > 0 and dtS2 > 0 then
						cur = math.max(0, cur - outSpd * dtS2)
						self.__mpMudCurExtra = cur
					end

					if (self.__mpMudCurExtra or 0) > 0 then
						local r = clamp(r0 - (self.__mpMudCurExtra or 0), 0.12, r0)

						local smallTh  = MudPhysics.smallWheelRadiusThreshold or 0.60
						local smallMin = mpGetSmallWheelMinAbs(r0) or (MudPhysics.smallWheelMinRadiusAbs or 0.25)
						if r0 < smallTh and type(smallMin) == "number" and smallMin > 0 then
							r = math.min(r0, math.max(r, smallMin))
						end

						self.__mpDesiredRadius = r
					else
						self.__mpDesiredRadius = nil
					end

					_G.__MudRadiusCombiner.apply(self, MudPhysics.freezeRadiusEps or 0.0015)

					self.__mpMudDeepFactor = 0
					self.__mpWheelSink = 0

					return

				end

				local wetFactor
				if mudKind == "WATER" then
					wetFactor = 1.0
				else
					wetFactor = clamp((wetnessEff - 0.15) / 0.55, 0, 1)
				end

				local effMud = mudHere * wetFactor
				if mudKind ~= "WATER" then
					effMud = math.max(effMud, mudHere * 0.20)
				end



				local freezeMul = 1.0
				if MudPhysics.getMudFreezeMult ~= nil then
					freezeMul = MudPhysics:getMudFreezeMult()
				end
				if freezeMul < 1.0 then
					effMud = effMud * freezeMul
				end

				local slip = 0
				if self.netInfo ~= nil then
					slip = self.netInfo.slip or 0
				end

				local speedKph = mpGetVehicleSpeedKph(self.vehicle)

				local freezeOn = (MudPhysics.freezeRadiusWhenStopped == true)
				local stopSpeed = MudPhysics.freezeStopSpeedKph or 0.6
				local stopSlip  = MudPhysics.freezeStopSlip or 0.01
				local settleT   = MudPhysics.freezeSettleSeconds or 0.45
				local isStopped = freezeOn and (speedKph <= stopSpeed) and ((slip or 0) <= stopSlip)

				if isStopped then
					self.__mpMudStillT = (self.__mpMudStillT or 0) + dtS
				else
					self.__mpMudStillT = 0
				end

				local seedNode = self.wheel.repr or self.wheel.node or self.wheel.wheelNode or self.wheel.driveNode or 0
				local patchMul, bob = MudPhysics:sampleMudVarWheel(wx, wz, seedNode, timeMs)
				if inWater and MudPhysics.waterDisableMudVar then
					patchMul = 1.0
					bob = 0.0
				end
				if isStopped then bob = 0 end

				if st.permaStuckActive == true then
					local spreadMs = MudPhysics.permaStuckSpreadMs or 150
					if timeMs >= (st.permaStuckNextMs or 0) then
						st.permaStuckNextMs = timeMs + spreadMs
						st.permaStuckToken  = (st.permaStuckToken or 0) + 1
					end

					if self.__mpPermaStuck ~= true then
						local tok = st.permaStuckToken or 0
						if (self.__mpPermaStuckTokenApplied or -1) ~= tok then
							self.__mpPermaStuckTokenApplied = tok
							self.__mpPermaStuck = true
							self.__mpMudPocketT = 0
							self.__mpMudPocketMul = nil
							self.__mpMudReliefT = 0

							if self.__mpPermaStuckCounted ~= true then
								self.__mpPermaStuckCounted = true
								st.permaStuckCount = (st.permaStuckCount or 0) + 1
							end
						end
					end
				end

				if MudPhysics.permaStuckEnable and (self.__mpPermaStuck ~= true) then
					local struggling =
						((slip or 0) >= (MudPhysics.permaStuckSlipMin or 0.10))
						and ((st.sink or 0) >= (MudPhysics.permaStuckSinkMin or 0.55))

					if not isStopped and struggling and (self.__mpPermaStuckCD or 0) <= 0 and dtS > 0 then
						local chancePerSec = (MudPhysics.permaStuckChanceOnStruggle or 0) * crawlerPermaChanceMul
						if chancePerSec > 0 then
							local p = clamp(chancePerSec * dtS, 0, 0.25)
							if math.random() < p then
								self.__mpPermaStuck = true
								local cMin = MudPhysics.permaStuckCooldownMin or 45.0
								local cMax = MudPhysics.permaStuckCooldownMax or 120.0
								self.__mpPermaStuckCD = cMin + (cMax - cMin) * math.random()

								self.__mpMudPocketT = 0
								self.__mpMudPocketMul = nil
								self.__mpMudReliefT = 0

								if st.permaStuckActive ~= true then
									mpResetPermaReleaseState(st, self.vehicle)
								end
								st.permaStuckActive = true
								st.permaStuckToken  = (st.permaStuckToken or 0) + 1
								st.permaStuckNextMs = timeMs + (MudPhysics.permaStuckSpreadMs or 150)

								if self.__mpPermaStuckCounted ~= true then
									self.__mpPermaStuckCounted = true
									st.permaStuckCount = (st.permaStuckCount or 0) + 1
								end

								MudPhysics:showPermaStuckNotification(self.vehicle, st)
							end
						end
					end
				end

				local permaForcedTarget = nil
				local permaReleaseBlend = 0
				if self.__mpPermaStuck == true then
                    local releaseStart = math.max(0, MudPhysics.permaStuckReleaseDistanceStartM or 6.0)
                    local releaseFull = math.max(releaseStart + 0.01, MudPhysics.permaStuckReleaseDistanceFullM or 8.0)
					local releaseDist = st.permaReleaseDist or 0
					permaReleaseBlend = clamp((releaseDist - releaseStart) / (releaseFull - releaseStart), 0, 1)

					if permaReleaseBlend >= 0.999 then
						mpClearPermaStuckState(self, st)
					else
					local factor = clamp(MudPhysics.permaStuckRadiusFactor or 0.15, 0.05, 0.95)
					local forcedRadius = math.max(0.12, r0 * factor)

					local smallTh  = MudPhysics.smallWheelRadiusThreshold or 0.60
					local smallMin = mpGetSmallWheelMinAbs(r0) or (MudPhysics.smallWheelMinRadiusAbs or 0.25)
					if r0 < smallTh and type(smallMin) == "number" and smallMin > 0 then
						forcedRadius = math.min(r0, math.max(forcedRadius, smallMin))
					end


					local targetExtra = math.max(0, r0 - forcedRadius)
					permaForcedTarget = targetExtra

					local inSec = math.max(0.05, MudPhysics.permaStuckRampInSec or 1.25)
					local stepIn = targetExtra * (dtS / inSec)

					local cur = self.__mpMudCurExtra or 0
					if cur < targetExtra then
						cur = math.min(targetExtra, cur + stepIn)
					else
						cur = math.max(targetExtra, cur)
					end
					self.__mpMudCurExtra = cur

					local newRadius = clamp(r0 - cur, forcedRadius, r0)

					local denom = math.max(0.0001, targetExtra)
					self.__mpMudDeepFactor = clamp(cur / denom, 0, 1)

					if permaReleaseBlend <= 0.0001 then
						self.__mpDesiredRadius = newRadius
						_G.__MudRadiusCombiner.apply(self, eps)
						return
					end
					end
				end

				local wasMud = (self.__mpWasMud == true)
				if not wasMud then
					self.__mpWasMud = true
					local chance = ((inWater and MudPhysics.waterDisableDeepPocket) and 0) or ((MudPhysics.deepPocketChanceOnEnter or 0) * crawlerDeepChanceMul)
					if chance > 0 then
						local bias = clamp(MudPhysics.deepPocketBiasByPatch or 0, 0, 1)
						local patch01 = clamp((patchMul - 0.65) / 0.85, 0, 1)
						local p = chance * (1.0 + bias * patch01)
						p = clamp(p, 0, 0.95)

						if math.random() < p then
							local dMin = MudPhysics.deepPocketDurationMin or 0.35
							local dMax = MudPhysics.deepPocketDurationMax or 0.65
							self.__mpMudPocketT = dMin + (dMax - dMin) * math.random()
							self.__mpMudPocketMul = 0.85 + 0.60 * math.random()

							local cMin = MudPhysics.deepPocketCooldownMin or 1.8
							local cMax = MudPhysics.deepPocketCooldownMax or 3.8
							self.__mpMudPocketCD = cMin + (cMax - cMin) * math.random()
						end
					end
				end

				if not isStopped then
					local cd = self.__mpMudPocketCD or 0
					local pocketT = self.__mpMudPocketT or 0
					if pocketT <= 0 and cd <= 0 then
						local struggling =
							((slip or 0) > (MudPhysics.deepPocketStruggleSlip or 0.05))
							or ((st.sink or 0) > (MudPhysics.deepPocketStruggleSink or 0.25))

						if not (inWater and MudPhysics.waterDisableDeepPocket) and struggling then
								local chancePerSec = (MudPhysics.deepPocketChancePerSecInMud or 0) * crawlerDeepChanceMul
							if chancePerSec > 0 and dtS > 0 then
								local bias = clamp(MudPhysics.deepPocketBiasByPatchInMud or 0, 0, 1)
								local patch01 = clamp((patchMul - 0.65) / 0.85, 0, 1)
								local p = chancePerSec * (1.0 + bias * patch01)
								p = clamp(p * dtS, 0, 0.95)

								if math.random() < p then
									local dMin = MudPhysics.deepPocketDurationMin or 0.35
									local dMax = MudPhysics.deepPocketDurationMax or 0.65
									self.__mpMudPocketT = dMin + (dMax - dMin) * math.random()
									self.__mpMudPocketMul = 0.85 + 0.60 * math.random()

									local cMin = MudPhysics.deepPocketCooldownMin or 1.8
									local cMax = MudPhysics.deepPocketCooldownMax or 3.8
									self.__mpMudPocketCD = cMin + (cMax - cMin) * math.random()
								end
							end
						end
					end
				end

				local effMudWheel = clamp(effMud * patchMul + bob * 0.25, 0, 1)
				
				-- ---------------------------------------------------------
				-- PER-WHEEL SINK (независимое зарывание на каждое колесо)
				-- ---------------------------------------------------------
				self.__mpWheelSink = self.__mpWheelSink or 0

				local minMud = (MudPhysics.mudMinToAffect or 0.10)
				local wheelInMudOrWater = (mudHere or 0) >= minMud

				local sinkInSpd  = (MudPhysics.sinkInSpeed or 1.0)
				local sinkOutSpd = (MudPhysics.sinkOutSpeed or 0.75)
				local widthBonus = mpGetWidthBonus(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.__mpWheelLoadKg = wheelLoadData ~= nil and wheelLoadData.loadKg or nil
				self.__mpWheelLoadFactor = wheelLoadFactor

				local pressureSinkMul = (TirePressureSystem ~= nil and TirePressureSystem.getSinkMultiplier ~= nil)
					and TirePressureSystem:getSinkMultiplier(self) or 1.0
				local pressureSinkOutMul = (TirePressureSystem ~= nil and TirePressureSystem.getSinkOutMultiplier ~= nil)
					and TirePressureSystem:getSinkOutMultiplier(self) or 1.0
				local pressureRadiusSinkMul = (TirePressureSystem ~= nil and TirePressureSystem.getRadiusSinkMultiplier ~= nil)
					and TirePressureSystem:getRadiusSinkMultiplier(self) or 1.0
				local sinkIn  = (wheelInMudOrWater and effMudWheel > 0.02) and (effMudWheel * (0.30 + slip) * sinkInSpd * wheelLoadFactor * (1 - widthBonus) * crawlerWheelSinkMul * pressureSinkMul) or 0
				local sinkResponseKph = speedKph * (MudPhysics.legacySpeedResponseScale or 3.6)
				local sinkOut = (1 - effMudWheel) * (sinkResponseKph / 25) * sinkOutSpd * pressureSinkOutMul

				if not wheelInMudOrWater then
					sinkOut = sinkOut + sinkOutSpd * 1.25 * pressureSinkOutMul
				end

				self.__mpWheelSink = clamp(self.__mpWheelSink + dtS * (sinkIn - sinkOut), 0, 1)


				local extraMaxAbs = MudPhysics.extraWheelSinkMax or 0
				local extraMaxRel = clamp(MudPhysics.extraWheelSinkMaxRel or 0.0, 0.0, 0.95) * r0
				local extraMax = extraMaxAbs
				if extraMaxRel > 0 then
					extraMax = math.min(extraMaxAbs, extraMaxRel)
				end
				extraMax = extraMax * pressureRadiusSinkMul

				local wheelSink = self.__mpWheelSink or 0
				if permaReleaseBlend > 0 then
					local releaseSinkOut = (0.035 + 0.115 * permaReleaseBlend) * dtS
					wheelSink = math.max(0, wheelSink - releaseSinkOut)
					self.__mpWheelSink = wheelSink
					st.sink = math.max(0, (st.sink or 0) - releaseSinkOut * 0.10)
				end
				local k = effMudWheel * (0.22 + wheelSink * 1.05 + slip * 0.70) * wheelLoadFactor * (1 - widthBonus) * crawlerWheelSinkMul * pressureSinkMul * pressureRadiusSinkMul

				k = clamp(k, 0, 1)
				local target = extraMax * k
				if permaForcedTarget ~= nil then
					target = permaForcedTarget + (target - permaForcedTarget) * permaReleaseBlend
				end

				local pocketT = self.__mpMudPocketT or 0
				if pocketT > 0 then
					local boost = clamp(MudPhysics.deepPocketTargetBoost or 0.55, 0, 1.25)
					local mul = self.__mpMudPocketMul or 1.0
					target = target + extraMax * boost * mul
				end

				local cur = self.__mpMudCurExtra or 0

				local freezeNow = isStopped and (self.__mpMudStillT or 0) >= (MudPhysics.freezeSettleSeconds or 0.45)
				if freezeNow then
					target = cur
				end

				local inSpd  = (MudPhysics.radiusSinkInSpeed  or 0.07)
				local outSpd = (MudPhysics.radiusSinkOutSpeed or 0.75)
				if permaForcedTarget ~= nil and permaReleaseBlend > 0 then
					local outMul = math.max(1.0, MudPhysics.permaStuckReleaseOutSpeedMul or 1.85)
					local extraReleaseSpd = math.max(0.006, (permaForcedTarget or 0) * (0.05 + 0.12 * permaReleaseBlend))
					outSpd = outSpd * outMul + extraReleaseSpd
				end
				
				if (self.__mpWaterExitSlowT or 0) > 0 then
					local slowOut = MudPhysics.waterExitRadiusOutSpeed
					if slowOut ~= nil then
						outSpd = math.min(outSpd, slowOut)
					end
				end

				if (slip or 0) < 0.02 then
					inSpd = inSpd * 0.65
				end
				if (self.__mpMudReliefT or 0) > 0 then
					target = target * 0.65
				end
				if pocketT > 0 then
					inSpd = inSpd * (MudPhysics.deepPocketInSpeedMul or 8.0)
				end

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

				if not isStopped then
					if (MudPhysics.reliefChancePerSec or 0) > 0 and dtS > 0 then
						local struggling = ((slip or 0) > 0.03) or ((st.sink or 0) > 0.35)
						if struggling then
							local p = clamp((MudPhysics.reliefChancePerSec or 0) * dtS, 0, 0.95)
							if math.random() < p then
								local strength = clamp(MudPhysics.reliefStrength or 0.22, 0, 0.95)
								cur = cur * (1.0 - strength)
								self.__mpMudReliefT = MudPhysics.reliefBrakeSeconds or 0.7
							end
						end
					end
				end

				self.__mpMudCurExtra = cur

				local minFactor = MudPhysics.radiusMinFactor or 0.55
				minFactor = minFactor + (1 - minFactor) * widthBonus
				minFactor = minFactor + (1 - minFactor) * crawlerRadiusMinBoost
				local bias = clamp(MudPhysics.radiusMinFactorRelBias or 0.0, 0, 1)
				local relDepth = clamp((cur / math.max(0.001, r0)), 0, 0.95)
				local relSoft = clamp(1.0 - relDepth * 0.35, 0.35, 1.0)
				local minRadius = math.max(0.12, r0 * clamp(minFactor * (1.0 - bias) + (minFactor * relSoft) * bias, 0.12, 0.98))

				local smallTh  = MudPhysics.smallWheelRadiusThreshold or 0.60
				local smallMin = mpGetSmallWheelMinAbs(r0) or (MudPhysics.smallWheelMinRadiusAbs or 0.25)
				if r0 < smallTh and type(smallMin) == "number" and smallMin > 0 then
					minRadius = math.min(r0, math.max(minRadius, smallMin))
				end

				local newRadius = clamp(r0 - cur, minRadius, r0)

				local deep = 0
				if extraMax > 0.0001 then
					deep = clamp(cur / extraMax, 0, 1)
				end
				self.__mpMudDeepFactor = deep

				self.__mpDesiredRadius = newRadius
				_G.__MudRadiusCombiner.apply(self, eps)
			end
		)
	else
		print("[MudPhysics] WheelPhysics.serverUpdate not found (real radius sink disabled)")
	end

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

-- =========================================================
-- MOD EVENTS
-- =========================================================

function MudPhysics:loadMap()
    self._pendingTerrainCache = true

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

    local ok, state = self:refreshMudLayerCache()

    if ok and state == "READY" then
        self._pendingTerrainCache = false
    end

    MudPhysics.loadExtraParticleReferences()

    self.installHooks()
    MudPhysics.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

    addConsoleCommand("gsMudPhysics", "MudPhysics on/off/debug/refresh", "consoleCommandMudPhysics", self)
end

function MudPhysics:deleteMap()
    mpDeleteAllVehicleExtraPS()

    if MudExtraParticlePresets ~= nil then
        MudExtraParticlePresets.clearCache(MudPhysics)
    elseif MudPhysics._extraPS ~= nil then
        if MudPhysics._extraPS.referencePS ~= nil then
            mpSafeDelete(MudPhysics._extraPS.referencePS.shape)
            MudPhysics._extraPS.referencePS = nil
        end
        if MudPhysics._extraPS.referenceShape ~= nil then
            mpSafeDelete(MudPhysics._extraPS.referenceShape)
            MudPhysics._extraPS.referenceShape = nil
        end
    end
	
	MudPhysics.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 MudPhysics:consoleCommandMudPhysics(arg)
    if arg == "on" then
        self.enabled = true
    elseif arg == "off" then
        self.enabled = false
    elseif arg == "debug" then
        self.debug = not self.debug
    elseif arg == "refresh" then
        self:refreshMudLayerCache()
    end

    return string.format("MudPhysics enabled=%s debug=%s mudLayers=%d",
        tostring(self.enabled), tostring(self.debug), (self.mudLayerIds ~= nil and #self.mudLayerIds or 0))
end

function MudPhysics:update(dt)
    if MudExtraParticlePresets ~= nil
        and MudExtraParticlePresets.updateRetiredEmitterShapes ~= nil then
        MudExtraParticlePresets.updateRetiredEmitterShapes(dt)
    end

    if not self.enabled then
        return
    end

    if self._pendingTerrainCache then
        local terrainNode = self:getTerrainNode()
        if terrainNode ~= nil then
            local ok, state = self:refreshMudLayerCache()

            if ok and state == "READY" then
                self._pendingTerrainCache = false
            end
        end
    end
end
