-- 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.stuckThreshold         = 1.20    -- Порог st.sink (0..1), после которого считаем, что техника почти/полностью "застряла".
MudPhysics.maxSpeedMudKph         = 10.0    -- Максимальная скорость (км/ч) в грязи при st.sink < stuckThreshold.
MudPhysics.maxSpeedStuckKph       = 4.5     -- Максимальная скорость (км/ч), когда st.sink >= stuckThreshold (режим "почти встал").

-- ---------------------------------------------------------------------------
-- Ограничения зарывания, для разных размеров колёс
-- ---------------------------------------------------------------------------
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       = 14.5    -- Жёсткий предел множителя нагрузки, чтобы не "убить" двигатель полностью.
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       = 6.22    -- как быстро СПАДАЕТ торможение (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

-- ---------------------------------------------------------------------------
-- Логи / отладка
-- ---------------------------------------------------------------------------
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 {}

local function __getWheelAndVehicle(wp)
    if wp == nil then
        return nil, nil
    end

    if wp.wheel ~= nil then
        local wheel = wp.wheel
        local veh = (wheel ~= nil and wheel.vehicle) or wp.vehicle
        return wheel, veh
    end

    if wp.vehicle ~= nil then
        return wp.wheel, wp.vehicle
    end

    if wp.owner ~= nil and wp.owner.vehicle ~= nil then
        return wp.owner, wp.owner.vehicle
    end

    return nil, nil
end

local function __applyVisualRadius(wheel, radius)
    if wheel == nil or wheel.visualWheels == nil then
        return
    end

    for _, vw in ipairs(wheel.visualWheels) do
        if vw ~= nil then
            vw.radius = radius

            if vw.shallowWaterObstacle ~= nil and vw.removeShallowWaterObstacle ~= nil and vw.addShallowWaterObstacle ~= nil then
                vw:removeShallowWaterObstacle()
                vw:addShallowWaterObstacle()
            end
        end
    end
end

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

    local r0 = wp.__mpOrigRadius or wp.__fgOrigRadius or wp.radiusOriginal or 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
    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 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 mpsToKph(v) return v * 3.6 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 mpSafeDelete(node)
    if node ~= nil and node ~= 0 and entityExists(node) then
        delete(node)
    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 mpGetClientVisualSpeedKph(vehicle, fallbackNode)
    if vehicle ~= nil then
        local vMs = (vehicle.getLastSpeed and vehicle:getLastSpeed()) or vehicle.lastSpeedReal or 0
        return math.abs((vMs or 0) * 3.6)
    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
    end

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

    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

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

        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

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

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

                wp.__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.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

    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.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

    vehicle.__mpResetGuard = true

    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

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

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

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

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

                wp.__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
        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]
    if entry ~= nil and entry.ps ~= nil and entry.emitter ~= nil then
        return entry
    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
    }

    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 mpGetSpeedAbsKphFromNode(node, vehicle)

    if vehicle ~= nil and vehicle.isServer ~= true then
        local vMs = (vehicle.getLastSpeed and vehicle:getLastSpeed()) or vehicle.lastSpeedReal or 0
        return math.abs((vMs or 0) * 3.6)
    end

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

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

    if getLinearVelocity ~= nil then
        local vx, vy, vz = getLinearVelocity(node)
        local v = math.sqrt(vx*vx + vy*vy + vz*vz) -- m/s
        return v * 3.6
    end

    return 0
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 function updateWheelExtraPS(vehicle, wheelRef, isActive, wx, wy, wz, dt)
    local entry = 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)
        end
    end
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" })

local function getState(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: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 x, _, z = self:getWheelContactPos(w)
        if 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)
    if not self.motorLoadEnable then
        return
    end

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

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

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

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

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

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

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

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

    st.__mpAppliedMotorLoad = true
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 _, 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
                            local nd = vehicle:getWashableNodeByCustomIndex(w)
                            if nd ~= nil and nd.dirtAmount ~= nil then
                                vehicle:setNodeDirtAmount(nd, clamp((nd.dirtAmount or 0) + wheelAdd, 0, dirtMax), true)
                                w.forceWheelDirtUpdate = true
                            end

                            if w.wheelMudMeshes ~= nil then
                                local ndMud = vehicle:getWashableNodeByCustomIndex(w.wheelMudMeshes)
                                if ndMud ~= nil and ndMud.dirtAmount ~= nil then
                                    vehicle:setNodeDirtAmount(ndMud, clamp((ndMud.dirtAmount or 0) + wheelAdd, 0, dirtMax), true)
                                    w.forceWheelDirtUpdate = true
                                end
                            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 speed = (vehicle.getLastSpeed and vehicle:getLastSpeed()) or vehicle.lastSpeedReal or 0
    local speedKph = mpsToKph(speed)

    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)
    local sinkOut = (1 - effMudRamp) * (speedKph / 25) * (self.sinkOutSpeed or 0.35)

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

    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 effMudRamp > 0.05 then
            local maxKph
            if st.stuck then
                maxKph = self.maxSpeedStuckKph or 3.5
            else
                maxKph = (self.maxSpeedMudKph or 22.0) * (1 - st.sink * 0.75)
            end
            motor:setSpeedLimit(maxKph)
            st.__mpAppliedSpeedLimit = true
        else
            if st.__mpAppliedSpeedLimit then
                motor:setSpeedLimit(math.huge)
                st.__mpAppliedSpeedLimit = false
            end
        end
    end

    self:applyMotorLoad(vehicle, effMudRamp, slip)

    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
            baseRatio = ratioBase * (
                (self.wheelBrakeBase or 0)
                + (self.wheelBrakeFromSink or 0) * st.sink
                + (self.wheelBrakeFromSlip or 0) * slip
            )

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

            if st.stuck then
                baseRatio = baseRatio * 1.25
            end
            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 6.8)

        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 target = baseRatio

                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 * 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 * 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 (помошник для еболы колёсной)
-- =========================================================

local function computeWetnessEffective(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

local function mpHasRealContactForExtra(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 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 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

                    local stPrev = MudPhysics.vehicleState ~= nil and MudPhysics.vehicleState[self.vehicle] or nil
                    if stPrev ~= nil and stPrev.extraPS ~= nil then
                        local entry = stPrev.extraPS[self.wheel]
                        if entry ~= nil and entry.ps ~= nil then
                            entry.ps.isActive = false
                            ParticleUtil.setEmittingState(entry.ps, false)
                        end
                    end

                    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: если техника стоит -> вообще ничего не считаем
                -- =========================================================
                if MudPhysics.onlyProcessWhenMoving == true 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
                                local stF = MudPhysics.vehicleState ~= nil and MudPhysics.vehicleState[self.vehicle] or nil
                                if stF ~= nil and stF.extraPS ~= nil then
                                    local entry = stF.extraPS[self.wheel]
                                    if entry ~= nil and entry.ps ~= nil then
                                        entry.ps.isActive = false
                                        ParticleUtil.setEmittingState(entry.ps, false)
                                    end
                                end
                            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
					if MudPhysics.extraParticlesEnable and not mpIsDedicatedServer() 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
							local stF = MudPhysics.vehicleState ~= nil and MudPhysics.vehicleState[self.vehicle] or nil
							if stF ~= nil and stF.extraPS ~= nil then
								local entry = stF.extraPS[self.wheel]
								if entry ~= nil and entry.ps ~= nil then
									entry.ps.isActive = false
									ParticleUtil.setEmittingState(entry.ps, false)
								end
							end
						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
									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 vSpeed = (self.vehicle.getLastSpeed and self.vehicle:getLastSpeed()) or self.vehicle.lastSpeedReal or 0
											local speedKph = mpsToKph(vSpeed)
											local speed01 = clamp(speedKph / math.max(0.1, 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)
								end

							else
								local stF = MudPhysics.vehicleState ~= nil and MudPhysics.vehicleState[self.vehicle] or nil
								if stF ~= nil and stF.extraPS ~= nil then
									local entry = stF.extraPS[self.wheel]
									if entry ~= nil and entry.ps ~= nil then
										entry.ps.isActive = false
										ParticleUtil.setEmittingState(entry.ps, false)
									end
								end
							end
						end
					end
				end

                local st = getState(self.vehicle)
                local timeMs = g_time or (g_currentMission ~= nil and g_currentMission.time or 0)
                if st.lastTick ~= timeMs then
                    st.lastTick = timeMs

                    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 then
                        if timeMs - st.lastDbgTime > (MudPhysics.debugPrintIntervalMs or 700) then
                            st.lastDbgTime = timeMs
                            print(string.format("[MudPhysics] wet=%.2f mud=%.2f slip=%.2f sink=%.2f stuck=%s motorLoad=%.2f",
                                wetnessEff, mudAvg, slipAvg, st.sink, tostring(st.stuck), st.lastAppliedMotorLoad or 1))
                        end
                    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 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 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 and self.vehicle ~= nil and MudPhysics:isVehicleResetting(self.vehicle) then
					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
					if self.__mpOrigRadius ~= nil then
						self.__mpDesiredRadius = nil
						_G.__MudRadiusCombiner.apply(self, MudPhysics.freezeRadiusEps or 0.0015)
					end
					self.__mpMudDeepFactor = 0
					self.__mpMudBrakeExtra = 0
					return
				end

				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
				local timeMs = g_time or (g_currentMission ~= nil and g_currentMission.time or 0)

				if self.__mpOrigRadius == nil then
					self.__mpOrigRadius = self.radiusOriginal or self.radius
				end
				local r0 = self.__mpOrigRadius
				if r0 == nil then return end

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

                    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 mudHere, _, mudKind = MudPhysics:getMudFactorCached(self.wheel, wx, wz)
				mudHere = mudHere or 0

				local inWater = 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

				
				if g_server ~= nil
					and MudPhysicsWheelExtraPSEvent ~= nil
					and MudPhysics.extraParticlesEnable
					and not (inWater and MudPhysics.waterDisableExtraParticles)
					and (MudPhysics.isMultiplayerSession ~= nil and MudPhysics:isMultiplayerSession())
				then
					local wetSrv = mpGetWetnessServer(groundWetness)
					local wetEff = computeWetnessEffective(wetSrv)
					local wetFinal = math.max(wetSrv or 0, wetEff or 0)

					local hasContact = mpHasRealContactForExtra(self.wheel, wx, (wy or 0), wz)

					local activeSrv = false
					if hasContact then
						local isMud = (mudHere >= (MudPhysics.mudMinToAffect or 0.10))
						if MudPhysics.extraParticleOnlyWetMud == false then
							activeSrv = isMud
						else
							activeSrv = isMud and (wetFinal > (MudPhysics.wetnessThreshold or 0.10))
						end
					end

					if activeSrv then
						local vMs = (self.vehicle.getLastSpeed and self.vehicle:getLastSpeed()) or self.vehicle.lastSpeedReal or 0
						local speedKph = math.abs((vMs or 0) * 3.6)
						local slip = (self.netInfo ~= nil and self.netInfo.slip) or self.slip or 0

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

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

						if speedKph < minKph and slip < (MudPhysics.extraParticleBurstSlipMin or 0.15) then
							activeSrv = false
						end
					end

					local wheelIndex = mpGetWheelIndexCached(self.vehicle, self.wheel)
					if wheelIndex ~= nil then
						self.__mpNetExtraPSSendT = math.max(0, (self.__mpNetExtraPSSendT or 0) - ((dt or 0) * 0.001))

						local last = self.__mpNetExtraPSLast or {}
						local changed = (last.isActive ~= activeSrv)
							or (activeSrv and ((last.wx ~= wx) or (last.wy ~= (wy or 0)) or (last.wz ~= wz)))

						local needRefresh = activeSrv and self.__mpNetExtraPSSendT <= 0

						if changed or needRefresh then
							last.isActive = activeSrv
							last.wx, last.wy, last.wz = wx, (wy or 0), wz
							self.__mpNetExtraPSLast = last
							self.__mpNetExtraPSSendT = activeSrv and 0.10 or 0.25

							local burstNow = false
							if MudPhysics.extraParticleBurstEnable == true and self.netInfo ~= nil then
								local slip = self.netInfo.slip or 0
								burstNow = (slip >= (MudPhysics.extraParticleBurstSlipMin or 0.15))
							end

							MudPhysicsWheelExtraPSEvent.send(self.vehicle, wheelIndex, activeSrv, wx, (wy or 0), wz, burstNow)
						end
					end
				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 vSpeed = (self.vehicle.getLastSpeed and self.vehicle:getLastSpeed()) or self.vehicle.lastSpeedReal or 0
				local speedKph = mpsToKph(vSpeed)

				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
						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)
					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
							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 sinkIn  = (wheelInMudOrWater and effMudWheel > 0.02) and (effMudWheel * (0.30 + slip) * sinkInSpd * (1 - widthBonus)) or 0
				local sinkOut = (1 - effMudWheel) * (speedKph / 25) * sinkOutSpd

				if not wheelInMudOrWater then
					sinkOut = sinkOut + sinkOutSpd * 1.25
				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

				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) * (1 - widthBonus)

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

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