-- This Source Code Form is subject to the terms of the bCDDL, v. 1.1.
-- If a copy of the bCDDL was not distributed with this
-- file, You can obtain one at http://beamng.com/bCDDL-1.1.txt

local M = {}

print("Colin likes boys.")

local max = math.max
local min = math.min
local abs = math.abs
local random = math.random

-- Declare electrics value for afterfire detection
local function onInit()
    electrics.values['afterFireDetector'] = 0  -- Initialize afterFireDetector
    print("Colin likes boys.")
end


local conversion = {
  kelvinToCelsius = -273.15,
  celsiusToKelvin = 273.15,
  avToRPM = 9.549296596425384
}

local parentEngine = nil
local tEnv = 0
--Thermal variables
M.engineBlockTemperature = 0
M.cylinderWallTemperature = 0
M.coolantTemperature = 0
M.oilTemperature = 0
M.exhaustTemperature = 0
M.radiatorFanSpin = 0
M.debugData = {}

M.exhaustEndNodes = {}

local energyCoef = {
  engineBlock = nil,
  cylinderWall = nil,
  oil = nil,
  coolant = nil,
  exhaust = nil
}

local thermalsEnabled = false

local engineBlockMeltingTemperature = 0
local cylinderWallMeltingTemperature = 0
local burnEfficiencyCoef = 0
local engineBlockAirCoolingEfficiency = nil
local engineBlockAirflowCoef = nil
local blockFanMaxAirSpeed = 0
local blockFanRPMCoef = 0

--Radiator
local radiatorFanType = nil
local radiatorFanMaxAirSpeed = 0
local radiatorFanTemperature = 0
local thermostatTemperature = 0
local oilThermostatTemperature = 0
local airRegulatorClosedCoef = 0
local fanAirSpeed = 0
local mechanicalRadiatorFanRPMCoef = 0
local radiatorCoef = 0
local oilRadiatorCoef = 0
local hasCoolantRadiator = false
local hasOilRadiator = false
local electricalRadiatorFanSmoother = nil
local mechanicalFanRPMCoef = 0
local electricRadiatorFanOverrideIgnitionLevel = -1

--Damage
local damageThreshold = {
  headGasket = 0,
  pistonRing = 0,
  connectingRod = 0,
  engineBlockTemperature = 0,
  cylinderWallTemperature = 0
}

M.engineBlockOverheatDamage = 0
M.oilOverheatDamage = 0
M.cylinderWallOverheatDamage = 0
M.headGasketBlown = false
M.pistonRingsDamaged = false
M.connectingRodBearingsDamaged = false
M.engineBlockMelted = false
M.cylinderWallsMelted = false

local fluidReservoirs = {
  coolant = {
    currentMass = 0,
    initialMass = 0,
    invInitialMass = 0
  },
  oil = {
    currentMass = 0,
    massInCylinders = 0,
    minimumSafeMass = 0,
    maximumSafeMass = 0,
    initialMass = 0
  }
}

local fluidLeakRates = {
  coolant = {
    overpressure = 0,
    headGasket = 0,
    radiator = 0,
    overall = 0,
    previousOverall = 0
  },
  oil = {
    oilpan = 0,
    radiator = 0,
    pistonRingDamage = 0,
    gravity = 0,
    combustionToExhaust = 0,
    overall = 0
  }
}

local radiatorDamageDeformGroup = nil
local radiatorDeformThreshold = 0
local radiatorDamage = 0
local lastRadiatorCompatibilityDamage = 0
local oilpanNodeBottom = -1
local oilpanNodeTop = -1
local oilpanMaximumSafeG = 0
local oilpanAccZSmoother = newTemporalSmoothing(10, 30)
local oilpanAccXYSmoother = newTemporalSmoothing(10, 10)
local oilStarvingTimer = 0
local oilStarvingTimerThreshold = 2
local missingOilDamage = 0
local hasOilStarvingDamage = false

--Particles & Sound
local particleTicks = {
  engineSteamParticleTick = 0,
  exhaustSteamParticleTick = 0,
  exhaustOilParticleTick = 0,
  exhaustSmokeParticleTick = 0,
  radiatorSteamParticleTick = 0
}
local knockSoundTick = 0
local particulates = 0
local idleParticulates = 0
local radiatorHissSound = nil
local radiatorFanSound = nil
local radiatorFanVolume = 0
local radiatorFanElectricSoundPlaying = false
local startPreHeated = true

--Thermal constants
local constants = {
  preHeatTemperature = 80,
  oilSpecHeat = 1800,
  coolantSpecHeat = 4000,
  exhaustSpecHeat = 500,
  minimumCoolantMass = 1.5,
  minimumOilMass = 0.25 * 0.87, --L to kg
  coolantTemperatureDamageThreshold = 120,
  maxCoolantTemperature = 130,
  oilTemperatureDamageThreshold = 150,
  exhaustCondensationThresholdEnvTemp = 10,
  exhaustCondensationThresholdBlockTemp = 150
}

--Nodes
local nodes = {
  coolantCap = {},
  radiator = {},
  engine = {},
  exhaust = {},
  exhaustEnds = {}
}
--local invExhaustNodeCount = 0
local exhaustStartNodes = {}
local exhaustBeams = nil
local exhaustTrees = {}

local afterFire = nil

local updateCoolingGFXMethod = nop
local updateExhaustGFXMethod = nop
local updateMechanicsGFXMethod = nop

local heatTickData = {
  debugEnabled = false,
  engineTickPeriodGain = 0,
  engineTickMinRate = 0,
  engineTickMaxRate = 0,
  engineTickMinTemperature = 0,
  engineTickPitch = 0.5,
  engineTickSizeBias = 0,
  engineTickVolume = 0,
  engineTickStartDelay = 3,
  engineTickDelay = 3,
  engineTickBucket = 0,
  engineTickBucketThreshold = 0,
  engineTickEventName = nil,
  lastEngineBlockTemperature = 0,
  engineDeltaTempSmoother = newTemporalSmoothing(10, 10),
  engineNodes = {},
  exhaustTickPeriodGain = 0,
  exhaustTickMinRate = 0,
  exhaustTickMaxRate = 0,
  exhaustTickMinTemperature = 0,
  exhaustTickPitch = 0.5,
  exhaustTickSizeBias = 3,
  exhaustTickVolume = 0,
  exhaustTickStartDelay = 3,
  exhaustTickDelay = 3,
  exhaustTickBucket = 0,
  exhaustTickBucketThreshold = 0,
  exhaustTickEventName = nil,
  lastExhaustTemperature = 0,
  exhaustDeltaTempSmoother = newTemporalSmoothing(10, 10),
  exhaustNodes = {},
  turboTickBucket = 0,
  turboTickBucketThreshold = 0,
  lastTurboTemperature = 0,
  turboNodes = {}
}

local engineThermalData = {
  coolantTemperature = 0,
  --this is to make the tacho display work, oil temp is the best measure of engine temp
  oilTemperature = 0,
  engineBlockTemperature = 0,
  cylinderWallTemperature = 0,
  exhaustTemperature = 0,
  radiatorAirSpeed = 0,
  radiatorAirSpeedEfficiency = 0,
  fanActive = 0,
  thermostatStatus = 0,
  airRegulatorStatus = 0,
  oilThermostatStatus = 0,
  coolantLeakRateOverpressure = 0,
  coolantLeakRateHeadGasket = 0,
  coolantLeakRateRadiator = 0,
  coolantLeakRateOverall = 0,
  coolantMass = 0,
  oilMass = 0,
  miniumSafeOilMass = 0,
  maximumSafeOilMass = 0,
  oilLeakRateOilpan = 0,
  oilLeakRateRadiator = 0,
  oilLeakRateGravity = 0,
  oilLeakRatePistonRingDamage = 0,
  oilLeakRateOverall = 0,
  oilLubricationCoef = 0,
  coolantEfficiency = 0,
  engineEfficiency = 0,
  energyToCylinderWall = 0,
  energyToOil = 0,
  energyToExhaust = 0,
  energyCoolantToAir = 0,
  energyCylinderWallToCoolant = 0,
  energyCoolantToBlock = 0,
  energyCylinderWallToBlock = 0,
  energyCylinderWallToOil = 0,
  energyOilToAir = 0,
  energyOilToBlock = 0,
  energyOilSumpToAir = 0,
  energyBlockToAir = 0,
  energyExhaustToAir = 0,
  engineBlockOverheatDamage = 0,
  oilOverheatDamage = 0,
  cylinderWallOverheatDamage = 0,
  headGasketBlown = 0,
  pistonRingsDamaged = 0,
  connectingRodBearingsDamaged = 0,
  engineBlockMelted = 0,
  cylinderWallsMelted = 0,
  thermostatTemperature = 0,
  oilThermostatTemperature = 0
}
M.debugData.engineThermalData = engineThermalData

local function applyDeformGroupDamageRadiator(damageAmount)
  radiatorDamage = radiatorDamage + damageAmount
end

local function applyDeformGroupDamageOilpan(damageAmount)
  fluidLeakRates.oil.oilpan = min(fluidLeakRates.oil.oilpan + damageAmount * 10, 1) --kg/s
  damageTracker.setDamage("engine", "oilpanLeak", true, true)
end

local function applyDeformGroupDamageOilRadiator(damageAmount)
  fluidLeakRates.oil.radiator = min(fluidLeakRates.oil.radiator + damageAmount * 0.1, 0.1) --kg/s
  damageTracker.setDamage("engine", "oilRadiatorLeak", true, true)
end

local function headGasketBlown()
  damageTracker.setDamage("engine", "headGasketDamaged", true)
  M.headGasketBlown = true
  --without a working headgasket we don't have full compression anymore -> less torque
  parentEngine:scaleOutputTorque(0.8)
  fluidLeakRates.coolant.headGasket = 0.1 --100g/s
end

local function pistonRingsDamaged()
  damageTracker.setDamage("engine", "pistonRingsDamaged", true, true)
  M.pistonRingsDamaged = true
  --Damaged piston rings cause a loss of compression and therefore less torque
  parentEngine:scaleOutputTorque(0.8)
  fluidLeakRates.oil.pistonRingDamage = 0.01
end

local function connectingRodBearingsDamaged()
  damageTracker.setDamage("engine", "rodBearingsDamaged", true, true)
  M.connectingRodBearingsDamaged = true
end

local function engineBlockMelted()
  parentEngine:scaleFriction(10000) --essentially kill the engine
  M.engineBlockMelted = true
  damageTracker.setDamage("engine", "blockMelted", true)
end

local function cylinderWallsMelted()
  parentEngine:scaleFriction(10000) --essentially kill the engine
  M.cylinderWallsMelted = true
  damageTracker.setDamage("engine", "cylinderWallsMelted", true, true)
end

local function setPartConditionRadiator(odometer, integrity, visual)
  local integrityState = integrity
  if type(integrity) == "number" then
    local integrityValue = integrity
    integrityState = {
      radiatorDamage = linearScale(integrityValue, 1, 0, 0, 0.1),
      coolantMass = linearScale(integrityValue, 1, 0, fluidReservoirs.coolant.initialMass, fluidReservoirs.coolant.initialMass * 0.1)
    }
  end
  radiatorDamage = integrityState.radiatorDamage or 0
  fluidReservoirs.coolant.currentMass = integrityState.coolantMass or fluidReservoirs.coolant.initialMass
end

local function setPartConditionExhaust(odometer, integrity, visual)
  local integrityState = integrity
  if type(integrity) == "number" then
    local integrityValue = integrity
    integrityState = {}
  end
end

local function setPartConditionThermals(odometer, integrity, visual)
  local integrityState = integrity
  if type(integrity) == "number" then
    local integrityValue = integrity
    integrityState = {
      headGasketBlown = integrityValue < 0.3,
      pistonRingsDamaged = integrityValue < 0.2,
      connectingRodBearingsDamaged = integrityValue < 0.1,
      engineBlockMelted = false,
      cylinderWallsMelted = false
    }
  end

  integrityState = integrityState or {} --make sure to have something if no data was passed
  if integrityState.headGasketBlown then
    headGasketBlown()
  end
  if integrityState.pistonRingsDamaged then
    pistonRingsDamaged()
  end
  if integrityState.connectingRodBearingsDamaged then
    connectingRodBearingsDamaged()
  end
  if integrityState.engineBlockMelted then
    engineBlockMelted()
  end
  if integrityState.cylinderWallsMelted then
    cylinderWallsMelted()
  end
end

local function getPartConditionRadiator()
  local integrityState = {
    radiatorDamage = radiatorDamage,
    coolantMass = fluidReservoirs.coolant.currentMass
  }

  local radiatorIntegrityValue = linearScale(radiatorDamage, 0, 0.1, 1, 0)
  local coolantIntegrityValue = linearScale(fluidReservoirs.coolant.currentMass, fluidReservoirs.coolant.initialMass, fluidReservoirs.coolant.initialMass * 0.1, 1, 0)

  local integrityValue = min(radiatorIntegrityValue, coolantIntegrityValue)
  return integrityValue, integrityState
end

local function getPartConditionExhaust()
  local integrityState = {}

  local integrityValue = min(1)
  return integrityValue, integrityState
end

local function getPartConditionThermals()
  local integrityState = {
    headGasketBlown = M.headGasketBlown,
    pistonRingsDamaged = M.pistonRingsDamaged,
    connectingRodBearingsDamaged = M.connectingRodBearingsDamaged,
    engineBlockMelted = M.engineBlockMelted,
    cylinderWallsMelted = M.cylinderWallsMelted
  }

  local headGasketBlownIntegrity = integrityState.headGasketBlown and 0.3 or 1
  local pistonRingsDamagedIntegrity = integrityState.pistonRingsDamaged and 0.2 or 1
  local connectingRodBearingsDamagedIntegrity = integrityState.connectingRodBearingsDamaged and 0.1 or 1
  local meltDamageIntegrity = (integrityState.engineBlockMelted or integrityState.cylinderWallsMelted) and 0.0 or 1
  local integrityValue = min(headGasketBlownIntegrity, pistonRingsDamagedIntegrity, connectingRodBearingsDamagedIntegrity, meltDamageIntegrity)
  return integrityValue, integrityState
end

local function emitBigAfterFireParticles(node1, node2, smokeParticleType)
  obj:addParticleByNodesRelative(node1, node2, -15, 61, 0, 1)
  obj:addParticleByNodesRelative(node1, node2, -10, 62, 0, 1)
  obj:addParticleByNodesRelative(node1, node2, -20, 63, 0, 1)
  obj:addParticleByNodesRelative(node1, node2, -8, 64, 0, 1)
  obj:addParticleByNodesRelative(node1, node2, -12, 65, 0, 1)

  obj:addParticleByNodesRelative(node1, node2, -5, smokeParticleType, 0, 1)
  obj:addParticleByNodesRelative(node1, node2, -3, smokeParticleType, 0, 1)
end

local function updateExhaustGFX(dt)
  local absEngineRPM = abs(parentEngine.outputAV1 * conversion.avToRPM)
  local particleAirspeed = electrics.values.airspeed
  local particulateEmission = (particulates * parentEngine.engineLoad) + idleParticulates
  local lightSmokeParticleType = particleAirspeed < 10 and 40 or 41
  local heavySmokeParticleType = particleAirspeed < 10 and 42 or 43
  local condensationParticleType = particleAirspeed < 10 and 46 or 47
  local exhaustGrayParticleType = particleAirspeed < 10 and 44 or 45
  local steamParticleType = particleAirspeed < 10 and 34 or 39
  local afterfire = 0
  local pukeTankParticle1 = 2007

  --exhaust emission
  afterFire.afterFireSoundTimer = max(afterFire.afterFireSoundTimer - dt, 0)
  afterFire.instantAfterFireFuel = afterFire.instantAfterFireFuel + parentEngine.instantAfterFireFuelDelay:popSum(dt) * dt
  afterFire.sustainedAfterFireFuel = afterFire.sustainedAfterFireFuel + parentEngine.sustainedAfterFireFuelDelay:popSum(dt) * dt
  afterFire.shiftAfterFireFuel = afterFire.shiftAfterFireFuel + parentEngine.shiftAfterFireFuel --no * dt here, design already covers the timeframe

  local maxFuel = max(afterFire.instantAfterFireFuel, max(afterFire.sustainedAfterFireFuel, afterFire.shiftAfterFireFuel))
  local reason = maxFuel == afterFire.shiftAfterFireFuel and 2 or (maxFuel == afterFire.sustainedAfterFireFuel and 1 or 0)

  local tmpAfterFireTime = 0
  local emitSmallParticulates = particleTicks.exhaustSmokeParticleTick > 1 and particulateEmission > 0.05 and particulateEmission < 0.3
  local emitLargeParticulates = particleTicks.exhaustSmokeParticleTick > 1 and particulateEmission >= 0.3
  for _, n in pairs(nodes.exhaustEnds) do
    --regular exhaust smoke
    if emitSmallParticulates then
      obj:addParticleByNodesRelative(n.finish, n.start, absEngineRPM * -0.0004 - 3, lightSmokeParticleType, 0, 1)
    end
    if emitLargeParticulates then
      obj:addParticleByNodesRelative(n.finish, n.start, absEngineRPM * -0.0004 - 3, heavySmokeParticleType, 0, 1)
    end

    local exhaustAudioEndFuel = n.afterFireAudioCoef * maxFuel
    local exhaustVisualEndFuel = n.afterFireVisualCoef * maxFuel
    local exhaustNodeInWater = obj:inWater(n.finish)
    if (exhaustVisualEndFuel > 0 or exhaustAudioEndFuel > 0) and not exhaustNodeInWater then
      if afterFire.afterFireSoundTimer <= 0 then
        if reason == 0 then --Single bang
          if exhaustAudioEndFuel > afterFire.audibleThresholdInstant then --Single bang
            obj:playSFXOnceCT(afterFire.instantAudioSample, n.finish, n.afterFireVolumeCoef * afterFire.instantVolumeCoef, 1.0, 1 - n.afterFireMufflingCoef, 0)
            afterfire = 0.5
          -- Audio Debug
          -- print (string.format(" AF Trig Insta %.3f/%.3f Eng InstVolCoef %.2f Exh VolCoef %.2f EngExh Vol TOTL %.2f Exh Color %.2f", parentEngine.instantAfterFireCoef, parentEngine.instantAfterFireCoef * n.afterFireAudioCoef, afterFire.instantVolumeCoef, n.afterFireVolumeCoef, afterFire.instantVolumeCoef * n.afterFireVolumeCoef, 1 - n.afterFireMufflingCoef))
          end

          if exhaustVisualEndFuel > afterFire.visualThresholdInstant then --Single bang
            emitBigAfterFireParticles(n.finish, n.start, exhaustGrayParticleType)
            afterfire = 0.5
          end

          tmpAfterFireTime = max(tmpAfterFireTime, 0.05 + random(100) * 0.001)
        elseif reason == 2 then -- transmission ignition cut sounds
          if exhaustAudioEndFuel > afterFire.audibleThresholdShift then
            obj:playSFXOnceCT(afterFire.shiftAudioSample, n.finish, n.afterFireVolumeCoef * afterFire.shiftVolumeCoef, 1.0, 1 - n.afterFireMufflingCoef, 0)
            afterfire = 0.5
          -- Audio Debug
          -- print (string.format(" AF Trig Shift %.3f/%.3f Eng InstVolCoef %.2f Exh VolCoef %.2f EngExh Vol TOTL %.2f Exh Color %.2f", parentEngine.shiftAfterFireCoef, parentEngine.shiftAfterFireCoef * n.afterFireAudioCoef, afterFire.shiftVolumeCoef, n.afterFireVolumeCoef, afterFire.shiftVolumeCoef * n.afterFireVolumeCoef, 1 - n.afterFireMufflingCoef))
          end

          if exhaustVisualEndFuel > afterFire.visualThresholdShift then
            emitBigAfterFireParticles(n.finish, n.start, exhaustGrayParticleType)
            afterfire = 0.5
          end

          tmpAfterFireTime = max(tmpAfterFireTime, 0.5)
        elseif reason == 1 and parentEngine.instantEngineLoad <= 0 then --popcorn single bang
          if exhaustAudioEndFuel > afterFire.audibleThresholdSustained then --popcorn single bang
            obj:playSFXOnceCT(afterFire.sustainedAudioSample, n.finish, n.afterFireVolumeCoef * afterFire.sustainedVolumeCoef, 1.0, 1 - n.afterFireMufflingCoef, 0)
            
          --print (string.format(" AF Trig Sustd %.3f/%.3f Eng InstVolCoef %.2f Exh VolCoef %.2f EngExh Vol TOTL %.2f Exh Color %.2f", parentEngine.sustainedAfterFireCoef, parentEngine.sustainedAfterFireCoef * n.afterFireAudioCoef, afterFire.sustainedVolumeCoef, n.afterFireVolumeCoef, afterFire.sustainedVolumeCoef * n.afterFireVolumeCoef, 1 - n.afterFireMufflingCoef))
          end

          if exhaustVisualEndFuel > afterFire.visualThresholdSustained then --popcorn single bang
            emitBigAfterFireParticles(n.finish, n.start, exhaustGrayParticleType)
            afterfire = 0.5
          end

          tmpAfterFireTime = max(tmpAfterFireTime, 0.05 + random(100) * 0.001)
        end
      end
    end

    if parentEngine.continuousAfterFireFuel > 0 and not exhaustNodeInWater then
      emitBigAfterFireParticles(n.finish, n.start, exhaustGrayParticleType)
      afterfire = 0.5
    end

    --steam from broken head gasket
    if M.headGasketBlown and particleTicks.exhaustSteamParticleTick > 1 and fluidReservoirs.coolant.currentMass > constants.minimumCoolantMass and not (parentEngine.isDisabled or parentEngine.isStalled) and hasCoolantRadiator then
      --also emit steam from all exhaust ends because we are actually vaporizing coolant in the combustion chamber
      obj:addParticleByNodesRelative(n.finish, n.start, absEngineRPM * -0.0004 - 3, steamParticleType, 0, 1)
    end

    if n.pukeTank1 == 1.1 and particleAirspeed < 20 then
    -- Print the value of pukeTank

    -- also emit steam from all exhaust ends because we are actually vaporizing coolant in the combustion chamber
    obj:addParticleByNodesRelative(n.finish, n.start, absEngineRPM * -0.00009 + 0.12, pukeTankParticle1, 0.0, 1)
end
    if tEnv <= constants.exhaustCondensationThresholdEnvTemp and M.exhaustTemperature <= constants.exhaustCondensationThresholdBlockTemp and particleTicks.exhaustSteamParticleTick > 1 then
      obj:addParticleByNodesRelative(n.finish, n.start, absEngineRPM * -0.0004 - 12.3, condensationParticleType, 0, 1)
    end
  end

  electrics.values.exhaustFlow = clamp(parentEngine.exhaustFlowDelay:popSum(dt) + clamp(maxFuel, 0, 1), 0, 1)
  electrics.values['afterFireDetector'] = afterfire

  afterFire.sustainedAfterFireFuel = max(afterFire.sustainedAfterFireFuel - 1000000 * dt, 0)

  if (afterFire.afterFireSoundTimer <= 0 and tmpAfterFireTime > 0) then
    afterFire.instantAfterFireFuel = 0
    afterFire.sustainedAfterFireFuel = 0
    afterFire.shiftAfterFireFuel = 0
  end
  afterFire.afterFireSoundTimer = tmpAfterFireTime > 0 and tmpAfterFireTime or afterFire.afterFireSoundTimer
end




local function updateAirCoolingGFX(dt)
  tEnv = obj:getEnvTemperature() + conversion.kelvinToCelsius
  --k "spring" values
  local kExhaustToAir = 0.000015
  local kCylinderWallToOil = 60
  local kOilSumpToAir = 200
  local kOilToBlock = 300
  local kCylinderWallToBlock = 2200
  local engineRPM = parentEngine.outputAV1 * conversion.avToRPM
  local absEngineRPM = abs(engineRPM)

  local airSpeedThroughVehicle = abs(obj:getFrontAirflowSpeed())
  local airRegulatorActive = min((M.engineBlockTemperature > thermostatTemperature and not parentEngine.isDisabled) and (M.engineBlockTemperature - thermostatTemperature) * 0.1 + airRegulatorClosedCoef or airRegulatorClosedCoef, 1)
  local oilRadiatorActive = min((hasOilRadiator and M.oilTemperature > oilThermostatTemperature and not parentEngine.isDisabled) and (M.oilTemperature - oilThermostatTemperature) or 0, 1)

  local underWaterBlockCoolingCoef = 1
  --if a node is underwater we want to increase the block to air cooling to simulate water cooling of the block
  for _, v in pairs(nodes.engine) do
    underWaterBlockCoolingCoef = underWaterBlockCoolingCoef + (obj:inWater(v) and 1000 or 0)
  end

  --Step 1: Calculate the "forces" with our "spring" k values
  local currentEngineEfficiency = burnEfficiencyCoef[math.floor(parentEngine.engineLoad * 100)]
  local burnEnergyPerUpdate = parentEngine.engineWorkPerUpdate * currentEngineEfficiency

  local energyToCylinderWall = 0.5 * burnEnergyPerUpdate + 0.5 * parentEngine.pumpingLossPerUpdate
  local energyToExhaust = 0.5 * burnEnergyPerUpdate + 0.5 * parentEngine.pumpingLossPerUpdate
  local energyToOil = parentEngine.frictionLossPerUpdate

  local coolingAirSpeed = airSpeedThroughVehicle * 0.7
  local coolingAirSpeedCoef = max(coolingAirSpeed / (10 + coolingAirSpeed), 0.1)

  local blockFanAirSpeedCoef = 1 + blockFanMaxAirSpeed * min(max(engineRPM * blockFanRPMCoef, 0), 1)
  local blockAirSpeedCoef = coolingAirSpeed * engineBlockAirflowCoef

  local energyOilToBlock = (M.oilTemperature - M.engineBlockTemperature) * kOilToBlock
  local energyOilToAir = (M.oilTemperature - tEnv) * oilRadiatorCoef * coolingAirSpeedCoef * oilRadiatorActive
  local energyOilSumpToAir = (M.oilTemperature - tEnv) * kOilSumpToAir * coolingAirSpeedCoef

  local energyCylinderWallToBlock = (M.cylinderWallTemperature - M.engineBlockTemperature) * kCylinderWallToBlock
  local energyCylinderWallToOil = (M.cylinderWallTemperature - M.oilTemperature) * kCylinderWallToOil

  local energyBlockToAir = (M.engineBlockTemperature - tEnv) * (blockFanAirSpeedCoef + blockAirSpeedCoef) * airRegulatorActive * engineBlockAirCoolingEfficiency * underWaterBlockCoolingCoef

  local exhaustTempDiff = M.exhaustTemperature - tEnv
  local exhaustTempSquared = exhaustTempDiff * exhaustTempDiff
  local energyExhaustToAir = exhaustTempSquared * exhaustTempSquared * kExhaustToAir
  local fireTemperature, fireDistance = fire.getClosestHotNodeTempDistance(parentEngine.engineNodeID)
  local energyFireToBlock = (fireTemperature - M.engineBlockTemperature) * 50 * max(10 - fireDistance, 0)

  --Step 2: The integrator
  M.cylinderWallTemperature = max(M.cylinderWallTemperature + (energyToCylinderWall - (energyCylinderWallToOil + energyCylinderWallToBlock) * dt) * energyCoef.cylinderWall, tEnv)
  M.oilTemperature = max(M.oilTemperature + (energyToOil + (energyCylinderWallToOil - energyOilToAir - energyOilSumpToAir - energyOilToBlock) * dt) * energyCoef.oil, tEnv)
  M.engineBlockTemperature = max(M.engineBlockTemperature + (energyCylinderWallToBlock - energyBlockToAir + energyOilToBlock + energyFireToBlock) * energyCoef.engineBlock * dt, tEnv)
  M.exhaustTemperature = max(M.exhaustTemperature + (energyToExhaust - energyExhaustToAir * dt) * energyCoef.exhaust, tEnv)
  M.coolantTemperature = nil

  local particleAirspeed = electrics.values.airspeed
  local engineRunning = (parentEngine.isDisabled or parentEngine.isStalled or parentEngine.ignitionCoef < 1) and 0 or 1
  particleTicks.exhaustOilParticleTick = particleTicks.exhaustOilParticleTick > 1 and 0 or particleTicks.exhaustOilParticleTick + dt * (0.01 * absEngineRPM + 0.1 * particleAirspeed) * engineRunning
  particleTicks.exhaustSmokeParticleTick = particleTicks.exhaustSmokeParticleTick > 1 and 0 or particleTicks.exhaustSmokeParticleTick + dt * (0.02 + (0.02 * absEngineRPM + 0.02 * particleAirspeed)) * engineRunning

  if M.engineBlockTemperature > damageThreshold.engineBlockTemperature then
    M.engineBlockOverheatDamage = min(M.engineBlockOverheatDamage + parentEngine.engineWorkPerUpdate, damageThreshold.headGasket)
    if M.engineBlockOverheatDamage >= damageThreshold.headGasket and not damageTracker.getDamage("engine", "headGasketDamaged") then
      headGasketBlown()
    end

    if M.engineBlockTemperature > engineBlockMeltingTemperature and not M.engineBlockMelted then
      engineBlockMelted()
    end
  end

  if M.oilTemperature > constants.oilTemperatureDamageThreshold then
    local diff = M.oilTemperature - constants.oilTemperatureDamageThreshold
    --increase engine friction relative to temperature of overheated oil
    local frictionCoef = 1 + diff * dt * 0.005
    parentEngine:scaleFriction(frictionCoef)
    M.oilOverheatDamage = min(M.oilOverheatDamage + parentEngine.engineWorkPerUpdate, damageThreshold.connectingRod)
    if M.oilOverheatDamage >= damageThreshold.connectingRod and not damageTracker.getDamage("engine", "rodBearingsDamaged") then
      connectingRodBearingsDamaged()
    end

    damageTracker.setDamage("engine", "oilOverheating", true, true)
  elseif damageTracker.getDamage("engine", "oilOverheating", true) then
    damageTracker.setDamage("engine", "oilOverheating", false)
  end

  if M.cylinderWallTemperature > damageThreshold.cylinderWallTemperature then
    M.cylinderWallOverheatDamage = min(M.cylinderWallOverheatDamage + parentEngine.engineWorkPerUpdate, damageThreshold.pistonRing)
    --if our cylinder wall gets too hot, the piston rings will be damaged eventually
    if M.cylinderWallOverheatDamage >= damageThreshold.pistonRing and not damageTracker.getDamage("engine", "pistonRingsDamaged") then
      pistonRingsDamaged()
    end

    if M.cylinderWallTemperature > cylinderWallMeltingTemperature and not M.cylinderWallsMelted then
      cylinderWallsMelted()
    end
  end

  if M.connectingRodBearingsDamaged then
    knockSoundTick = knockSoundTick > 1 and 0 or knockSoundTick + dt * absEngineRPM * 0.008333
    if knockSoundTick > 1 then
      --make a knocking sound if the bearings are damaged
      sounds.playSoundOnceFollowNode("event:>Vehicle>Failures>failure_engine_knock", nodes.engine[1], 1)
    end
  end
  engineThermalData.coolantTemperature = M.oilTemperature
  --this is to make the tacho display work, oil temp is the best measure of engine temp
  engineThermalData.oilTemperature = M.oilTemperature
  engineThermalData.engineBlockTemperature = M.engineBlockTemperature
  engineThermalData.cylinderWallTemperature = M.cylinderWallTemperature
  engineThermalData.exhaustTemperature = M.exhaustTemperature
  engineThermalData.radiatorAirSpeed = coolingAirSpeed
  engineThermalData.radiatorAirSpeedEfficiency = coolingAirSpeedCoef
  engineThermalData.fanActive = fanAirSpeed > 0
  engineThermalData.thermostatStatus = 0
  engineThermalData.airRegulatorStatus = airRegulatorActive
  engineThermalData.oilThermostatStatus = oilRadiatorActive
  engineThermalData.coolantMass = 0
  engineThermalData.coolantLeakRateOverpressure = 0
  engineThermalData.coolantLeakRateHeadGasket = 0
  engineThermalData.coolantLeakRateRadiator = 0
  engineThermalData.coolantLeakRateOverall = 0
  engineThermalData.coolantEfficiency = 0
  engineThermalData.engineEfficiency = 1 / (currentEngineEfficiency + 1)
  engineThermalData.energyToCylinderWall = energyToCylinderWall
  engineThermalData.energyToOil = energyToOil
  engineThermalData.energyToExhaust = energyToExhaust
  engineThermalData.energyCoolantToAir = 0
  engineThermalData.energyCylinderWallToCoolant = 0
  engineThermalData.energyCoolantToBlock = 0
  engineThermalData.energyCylinderWallToBlock = energyCylinderWallToBlock * dt
  engineThermalData.energyCylinderWallToOil = energyCylinderWallToOil * dt
  engineThermalData.energyOilToAir = energyOilToAir * dt
  engineThermalData.energyOilToBlock = energyOilToBlock * dt
  engineThermalData.energyOilSumpToAir = energyOilSumpToAir * dt
  engineThermalData.energyBlockToAir = energyBlockToAir * dt
  engineThermalData.energyExhaustToAir = energyExhaustToAir * dt
  engineThermalData.engineBlockOverheatDamage = M.engineBlockOverheatDamage
  engineThermalData.oilOverheatDamage = M.oilOverheatDamage
  engineThermalData.cylinderWallOverheatDamage = M.cylinderWallOverheatDamage
  engineThermalData.headGasketBlown = M.headGasketBlown
  engineThermalData.pistonRingsDamaged = M.pistonRingsDamaged
  engineThermalData.connectingRodBearingsDamaged = M.connectingRodBearingsDamaged
  engineThermalData.engineBlockMelted = M.engineBlockMelted
  engineThermalData.cylinderWallsMelted = M.cylinderWallsMelted
  engineThermalData.thermostatTemperature = thermostatTemperature
  engineThermalData.oilThermostatTemperature = oilThermostatTemperature
  if streams.willSend("engineThermalData") then
    gui.send("engineThermalData", engineThermalData)
  end
end

local function updateCoolantRadiatorDamage()
  --This is a compatibility mode for engines that do not have the updated deformgroup features
  if not parentEngine.deformGroupDamages.radiator then
    local currentRadiatorCompatibilityDamage = beamstate.deformGroupDamage[radiatorDamageDeformGroup] and beamstate.deformGroupDamage[radiatorDamageDeformGroup].damage or 0
    radiatorDamage = radiatorDamage + max(currentRadiatorCompatibilityDamage - lastRadiatorCompatibilityDamage, 0)
    lastRadiatorCompatibilityDamage = currentRadiatorCompatibilityDamage
  end
end

local function updateWaterCoolingGFX(dt)
  tEnv = obj:getEnvTemperature() + conversion.kelvinToCelsius
  --k "spring" values
  local kExhaustToAir = 0.00001
  local kCylinderWallToCoolant = 28000
  local kCylinderWallToOil = 100
  local kOilSumpToAir = 60
  local kOilToBlock = 300
  local kCoolantToBlock = 24000
  local kCylinderWallToBlock = 5000
  local engineRPM = parentEngine.outputAV1 * conversion.avToRPM
  local maxEngineRPM = parentEngine.maxRPM or 8000
  local absEngineRPM = abs(engineRPM)

  local adjustedRadiatorDamage = max(radiatorDamage - radiatorDeformThreshold, 0)

  if hasCoolantRadiator then
    --get radiator damage (if there is any) and calculate coolant leak rate based on that
    updateCoolantRadiatorDamage()

    if adjustedRadiatorDamage > 0 and not damageTracker.getDamage("engine", "radiatorLeak") then
      damageTracker.setDamage("engine", "radiatorLeak", true)
    end
    fluidLeakRates.coolant.radiator = adjustedRadiatorDamage * 10
  end

  local radiatorFanRPM = 0
  if radiatorFanType == "electric" then
    --eletric fans are either on or off, depending on coolant temperature
    local electricFanOverride = electricRadiatorFanOverrideIgnitionLevel >= 0 and electrics.values.ignitionLevel >= electricRadiatorFanOverrideIgnitionLevel
    if M.coolantTemperature >= radiatorFanTemperature or electricFanOverride then
      fanAirSpeed = radiatorFanMaxAirSpeed
      if radiatorFanSound and not radiatorFanElectricSoundPlaying then
        obj:setVolumePitch(radiatorFanSound, radiatorFanVolume, 1)
        obj:playSFX(radiatorFanSound)
        radiatorFanElectricSoundPlaying = true
      end
    elseif M.coolantTemperature <= thermostatTemperature * 1.05 then
      fanAirSpeed = 0
      if radiatorFanSound and radiatorFanElectricSoundPlaying then
        obj:stopSFX(radiatorFanSound)
        radiatorFanElectricSoundPlaying = false
      end
    end
    radiatorFanRPM = electricalRadiatorFanSmoother:getUncapped(fanAirSpeed > 0 and 2500 or 0, dt)
  elseif radiatorFanType == "mechanical" then
    --mechanical fans are tied to the RPM but at some point they won't go any faster (because they are linked with a clutch)
    radiatorFanRPM = engineRPM * mechanicalFanRPMCoef
    fanAirSpeed = radiatorFanMaxAirSpeed * min(max(engineRPM * mechanicalRadiatorFanRPMCoef, 0), 1)
    if radiatorFanSound then
      obj:setVolumePitch(radiatorFanSound, radiatorFanVolume, absEngineRPM / maxEngineRPM)
    end
  end
  M.radiatorFanSpin = (M.radiatorFanSpin + radiatorFanRPM * dt) % 360

  local airSpeedThroughVehicle = abs(obj:getFrontAirflowSpeed())

  --radiator is only actually used above a certain temperature
  local radiatorActive = min((hasCoolantRadiator and M.coolantTemperature > thermostatTemperature and not parentEngine.isDisabled) and M.coolantTemperature - thermostatTemperature or 0, 1)
  local oilRadiatorActive = min((hasOilRadiator and M.oilTemperature > oilThermostatTemperature and not parentEngine.isDisabled) and M.oilTemperature - oilThermostatTemperature or 0, 1)
  --Efficiency of the cooling system drops with decreasing coolant mass and raising temps above damage threshold
  local coolantEmpty = fluidReservoirs.coolant.currentMass <= constants.minimumCoolantMass
  local coolantIsMaxTemp = M.coolantTemperature >= constants.maxCoolantTemperature
  local useRegularCoolantLogic = not (coolantEmpty or coolantIsMaxTemp)
  local coolantEfficiency = useRegularCoolantLogic and (fluidReservoirs.coolant.currentMass * fluidReservoirs.coolant.invInitialMass * max(min((constants.maxCoolantTemperature - M.coolantTemperature) * 0.1, 1), 0)) or 0.01

  local underWaterBlockCoolingCoef = 1
  --if a node is underwater we want to increase the block to air cooling to simulate water cooling of the block
  for _, v in pairs(nodes.engine) do
    underWaterBlockCoolingCoef = underWaterBlockCoolingCoef + (obj:inWater(v) and 100 or 0)
  end

  --Step 1: Calculate the "forces" with our "spring" k values
  local currentEngineEfficiency = burnEfficiencyCoef[math.floor(parentEngine.engineLoad * 100)]
  local burnEnergyPerUpdate = parentEngine.engineWorkPerUpdate * currentEngineEfficiency

  local energyToCylinderWall = 0.5 * burnEnergyPerUpdate + 0.5 * parentEngine.pumpingLossPerUpdate
  local energyToExhaust = 0.5 * burnEnergyPerUpdate + 0.5 * parentEngine.pumpingLossPerUpdate
  local energyToOil = parentEngine.frictionLossPerUpdate

  local radiatorAirSpeed = max(airSpeedThroughVehicle * 0.8, fanAirSpeed) --reduce actual airspeed because the rad blocks part of the air
  local radiatorAirSpeedCoef = max(radiatorAirSpeed / (15 + radiatorAirSpeed), 0.1)

  local blockFanAirSpeedCoef = 1 + blockFanMaxAirSpeed * min(max(engineRPM * blockFanRPMCoef, 0), 1)
  local cylinderWallToCoolantAirCooledCoef = blockFanMaxAirSpeed > 0 and 0 or 1 --kill the energy transfer from wall to coolant on an air cooled engine (no coolant)

  local energyCylinderWallToCoolant = (M.cylinderWallTemperature - M.coolantTemperature) * kCylinderWallToCoolant * coolantEfficiency * cylinderWallToCoolantAirCooledCoef
  local energyCoolantToAir = (M.coolantTemperature - tEnv) * radiatorCoef * radiatorActive * radiatorAirSpeedCoef * coolantEfficiency
  local energyCoolantToBlock = (M.coolantTemperature - M.engineBlockTemperature) * kCoolantToBlock * coolantEfficiency * cylinderWallToCoolantAirCooledCoef
  local energyOilToBlock = (M.oilTemperature - M.engineBlockTemperature) * kOilToBlock
  local energyCylinderWallToBlock = (M.cylinderWallTemperature - M.engineBlockTemperature) * kCylinderWallToBlock

  local energyCylinderWallToOil = (M.cylinderWallTemperature - M.oilTemperature) * kCylinderWallToOil
  local energyOilSumpToAir = (M.oilTemperature - tEnv) * kOilSumpToAir * radiatorAirSpeedCoef
  local energyOilToAir = (M.oilTemperature - tEnv) * oilRadiatorCoef * radiatorAirSpeedCoef * oilRadiatorActive
  local energyBlockToAir = (M.engineBlockTemperature - tEnv) * blockFanAirSpeedCoef * engineBlockAirCoolingEfficiency * underWaterBlockCoolingCoef
  local exhaustTempDiff = M.exhaustTemperature - tEnv
  local exhaustTempSquared = exhaustTempDiff * exhaustTempDiff
  local energyExhaustToAir = exhaustTempSquared * exhaustTempSquared * kExhaustToAir
  local fireTemperature, fireDistance = fire.getClosestHotNodeTempDistance(parentEngine.engineNodeID)
  local energyFireToBlock = max((fireTemperature - M.engineBlockTemperature) * 50 * max(10 - fireDistance, 1), 0)

  --Step 2: The integrator
  M.cylinderWallTemperature = max(M.cylinderWallTemperature + (energyToCylinderWall - (energyCylinderWallToOil + energyCylinderWallToCoolant + energyCylinderWallToBlock) * dt) * energyCoef.cylinderWall, tEnv)
  M.coolantTemperature = min(max(M.coolantTemperature + (energyCylinderWallToCoolant - energyCoolantToAir - energyCoolantToBlock) * energyCoef.coolant * dt, tEnv), constants.maxCoolantTemperature)
  M.oilTemperature = max(M.oilTemperature + (energyToOil + (energyCylinderWallToOil - energyOilToAir - energyOilSumpToAir - energyOilToBlock) * dt) * energyCoef.oil, tEnv)
  M.engineBlockTemperature = max(M.engineBlockTemperature + (energyCoolantToBlock + energyCylinderWallToBlock - energyBlockToAir + energyOilToBlock + energyFireToBlock) * energyCoef.engineBlock * dt, tEnv)
  M.exhaustTemperature = max(M.exhaustTemperature + (energyToExhaust - energyExhaustToAir * dt) * energyCoef.exhaust, tEnv)

  local particleAirspeed = electrics.values.airspeed
  local engineRunning = (parentEngine.isDisabled or parentEngine.isStalled or parentEngine.ignitionCoef < 1) and 0 or 1
  particleTicks.engineSteamParticleTick = particleTicks.engineSteamParticleTick > 1 and 0 or particleTicks.engineSteamParticleTick + dt * 4
  particleTicks.radiatorSteamParticleTick = particleTicks.radiatorSteamParticleTick > 1 and 0 or particleTicks.radiatorSteamParticleTick + dt * (200 * adjustedRadiatorDamage + 2 * particleAirspeed)
  particleTicks.exhaustSteamParticleTick = particleTicks.exhaustSteamParticleTick > 1 and 0 or particleTicks.exhaustSteamParticleTick + dt * (0.01 * absEngineRPM + 0.2 * particleAirspeed) * engineRunning
  particleTicks.exhaustOilParticleTick = particleTicks.exhaustOilParticleTick > 1 and 0 or particleTicks.exhaustOilParticleTick + dt * (0.01 * absEngineRPM + 0.1 * particleAirspeed) * engineRunning
  particleTicks.exhaustSmokeParticleTick = particleTicks.exhaustSmokeParticleTick > 1 and 0 or particleTicks.exhaustSmokeParticleTick + dt * (0.02 + (0.02 * absEngineRPM + 0.02 * particleAirspeed)) * engineRunning

  --airspeed depending particle type selection
  local coolantHeavyParticleType = particleAirspeed < 10 and 35 or 37
  local coolantLightParticleType = particleAirspeed < 10 and 48 or 49

  if M.coolantTemperature > constants.coolantTemperatureDamageThreshold then
    --our coolant is too hot, so our radiator cap is releasing pressure/steam and therefore coolant mass
    fluidLeakRates.coolant.overpressure = 0.01 --10g/s

    if particleTicks.engineSteamParticleTick > 1 then
      --emit steam as long as there is still coolant left
      obj:addParticleByNodesRelative(nodes.coolantCap[1], nodes.coolantCap[2], 0, coolantHeavyParticleType, 0, 1)
    end

    damageTracker.setDamage("engine", "coolantOverheating", true, true)
  elseif damageTracker.getDamage("engine", "coolantOverheating") then
    --if the coolant cools down again, we don't leak anymore because of overpressure
    fluidLeakRates.coolant.overpressure = 0
    damageTracker.setDamage("engine", "coolantOverheating", false)
  end

  if M.engineBlockTemperature > damageThreshold.engineBlockTemperature then
    M.engineBlockOverheatDamage = min(M.engineBlockOverheatDamage + parentEngine.engineWorkPerUpdate, damageThreshold.headGasket)
    if M.engineBlockOverheatDamage >= damageThreshold.headGasket and not damageTracker.getDamage("engine", "headGasketDamaged") then
      headGasketBlown()
      --let's get rid of a bit of coolant immediately
      fluidReservoirs.coolant.currentMass = fluidReservoirs.coolant.currentMass * 0.9

      --implement nice steam "explosion" here
      if #parentEngine.engineBlockNodes >= 2 then
        for i = 1, 10 do
          local rnd = random()
          obj:addParticleByNodesRelative(parentEngine.engineBlockNodes[2], parentEngine.engineBlockNodes[1], i * rnd, 43, 0, 1)
          obj:addParticleByNodesRelative(parentEngine.engineBlockNodes[2], parentEngine.engineBlockNodes[1], i * rnd, 39, 0, 1)
          obj:addParticleByNodesRelative(parentEngine.engineBlockNodes[2], parentEngine.engineBlockNodes[1], -i * rnd, 43, 0, 1)
          obj:addParticleByNodesRelative(parentEngine.engineBlockNodes[2], parentEngine.engineBlockNodes[1], -i * rnd, 39, 0, 1)
        end
      end
    end

    if M.engineBlockTemperature > engineBlockMeltingTemperature and not M.engineBlockMelted then
      engineBlockMelted()
    end
  end

  if M.oilTemperature > constants.oilTemperatureDamageThreshold then
    local diff = M.oilTemperature - constants.oilTemperatureDamageThreshold
    --increase engine friction relative to temperature of overheated oil
    local frictionCoef = 1 + diff * dt * 0.005
    parentEngine:scaleFriction(frictionCoef)
    M.oilOverheatDamage = min(M.oilOverheatDamage + parentEngine.engineWorkPerUpdate, damageThreshold.connectingRod)
    if M.oilOverheatDamage >= damageThreshold.connectingRod and not damageTracker.getDamage("engine", "rodBearingsDamaged") then
      connectingRodBearingsDamaged()
    end

    damageTracker.setDamage("engine", "oilOverheating", true, true)
  elseif damageTracker.getDamage("engine", "oilOverheating", true) then
    damageTracker.setDamage("engine", "oilOverheating", false)
  end

  if M.cylinderWallTemperature > damageThreshold.cylinderWallTemperature then
    M.cylinderWallOverheatDamage = min(M.cylinderWallOverheatDamage + parentEngine.engineWorkPerUpdate, damageThreshold.pistonRing)
    --if our cylinder wall gets too hot, the piston rings will be damaged eventually
    if M.cylinderWallOverheatDamage >= damageThreshold.pistonRing and not damageTracker.getDamage("engine", "pistonRingsDamaged") then
      pistonRingsDamaged()
    end

    if M.cylinderWallTemperature > cylinderWallMeltingTemperature and not M.cylinderWallsMelted then
      cylinderWallsMelted()
    end
  end

  if adjustedRadiatorDamage > 0 and particleTicks.radiatorSteamParticleTick > 1 and M.coolantTemperature >= 70 and hasCoolantRadiator then
    --emit steam from the radiator if it's damaged and we have coolant left
    if #nodes.radiator >= 2 then
      local coolantLeakParticle = M.coolantTemperature > 95 and coolantHeavyParticleType or coolantLightParticleType
      obj:addParticleByNodesRelative(nodes.radiator[1], nodes.radiator[2], 0, coolantLeakParticle, 0, 1)
    end
  end

  if M.headGasketBlown then
    --emit steam from the engine since we can't keep the coolant under control anymore
    if particleTicks.engineSteamParticleTick > 1 and hasCoolantRadiator then
      obj:addParticleByNodesRelative(nodes.engine[2], nodes.engine[1], 0, coolantHeavyParticleType, 0, 1)
    end
  end

  if M.connectingRodBearingsDamaged then
    knockSoundTick = knockSoundTick > 1 and 0 or knockSoundTick + dt * absEngineRPM * 0.008333
    if knockSoundTick > 1 then
      --make a knocking sound if the bearings are damaged
      sounds.playSoundOnceFollowNode("event:>Vehicle>Failures>failure_engine_knock", nodes.engine[1], 1)
    end
  end

  --We can lose coolant in different places, sum up all the rates and calculate the new mass
  fluidLeakRates.coolant.overall = (fluidReservoirs.coolant.currentMass > constants.minimumCoolantMass) and (fluidLeakRates.coolant.overpressure + fluidLeakRates.coolant.headGasket + fluidLeakRates.coolant.radiator) or 0 -- max: 0.01 + 0.1 + 1

  fluidReservoirs.coolant.currentMass = max(fluidReservoirs.coolant.currentMass - fluidLeakRates.coolant.overall * dt, constants.minimumCoolantMass)

  if fluidReservoirs.coolant.currentMass < fluidReservoirs.coolant.initialMass then
    energyCoef.coolant = 1 / (fluidReservoirs.coolant.currentMass * constants.coolantSpecHeat)
  end
  if fluidReservoirs.oil.currentMass < fluidReservoirs.oil.initialMass then
    energyCoef.oil = 1 / (fluidReservoirs.oil.currentMass * constants.oilSpecHeat)
  end

  if radiatorHissSound then
    obj:setVolumePitch(radiatorHissSound, fluidLeakRates.coolant.overall, M.coolantTemperature / constants.maxCoolantTemperature, 0, 0)
    if fluidLeakRates.coolant.previousOverall <= 0 and fluidLeakRates.coolant.overall > 0 then
      obj:playSFX(radiatorHissSound)
    end
  end

  fluidLeakRates.coolant.previousOverall = fluidLeakRates.coolant.overall

  engineThermalData.coolantTemperature = M.coolantTemperature
  engineThermalData.oilTemperature = M.oilTemperature
  engineThermalData.engineBlockTemperature = M.engineBlockTemperature
  engineThermalData.cylinderWallTemperature = M.cylinderWallTemperature
  engineThermalData.exhaustTemperature = M.exhaustTemperature
  engineThermalData.radiatorAirSpeed = radiatorAirSpeed
  engineThermalData.radiatorAirSpeedEfficiency = radiatorAirSpeedCoef
  engineThermalData.fanActive = fanAirSpeed > 0
  engineThermalData.thermostatStatus = radiatorActive
  engineThermalData.airRegulatorStatus = 0
  engineThermalData.oilThermostatStatus = oilRadiatorActive
  engineThermalData.coolantMass = fluidReservoirs.coolant.currentMass
  engineThermalData.coolantLeakRateOverpressure = fluidLeakRates.coolant.overpressure
  engineThermalData.coolantLeakRateHeadGasket = fluidLeakRates.coolant.headGasket
  engineThermalData.coolantLeakRateRadiator = fluidLeakRates.coolant.radiator
  engineThermalData.coolantLeakRateOverall = fluidLeakRates.coolant.overall
  engineThermalData.coolantEfficiency = coolantEfficiency
  engineThermalData.engineEfficiency = 1 / (currentEngineEfficiency + 1)
  engineThermalData.energyToCylinderWall = energyToCylinderWall
  engineThermalData.energyToOil = energyToOil
  engineThermalData.energyToExhaust = energyToExhaust
  engineThermalData.energyCoolantToAir = energyCoolantToAir * dt
  engineThermalData.energyCylinderWallToCoolant = energyCylinderWallToCoolant * dt
  engineThermalData.energyCoolantToBlock = energyCoolantToBlock * dt
  engineThermalData.energyCylinderWallToBlock = energyCylinderWallToBlock * dt
  engineThermalData.energyCylinderWallToOil = energyCylinderWallToOil * dt
  engineThermalData.energyOilToAir = energyOilToAir * dt
  engineThermalData.energyOilToBlock = energyOilToBlock * dt
  engineThermalData.energyOilSumpToAir = energyOilSumpToAir * dt
  engineThermalData.energyBlockToAir = energyBlockToAir * dt
  engineThermalData.energyExhaustToAir = energyExhaustToAir * dt
  engineThermalData.engineBlockOverheatDamage = M.engineBlockOverheatDamage
  engineThermalData.oilOverheatDamage = M.oilOverheatDamage
  engineThermalData.cylinderWallOverheatDamage = M.cylinderWallOverheatDamage
  engineThermalData.headGasketBlown = M.headGasketBlown
  engineThermalData.pistonRingsDamaged = M.pistonRingsDamaged
  engineThermalData.connectingRodBearingsDamaged = M.connectingRodBearingsDamaged
  engineThermalData.engineBlockMelted = M.engineBlockMelted
  engineThermalData.cylinderWallsMelted = M.cylinderWallsMelted
  engineThermalData.thermostatTemperature = thermostatTemperature
  engineThermalData.oilThermostatTemperature = oilThermostatTemperature
  if streams.willSend("engineThermalData") then
    gui.send("engineThermalData", engineThermalData)
  end
end

local function updateMechanicsGFX(dt)
  local oilLubricationCoef = 1
  local maximumSafeGVolumeCoef = 1
  --too little oil
  if fluidReservoirs.oil.currentMass < fluidReservoirs.oil.minimumSafeMass and fluidReservoirs.oil.minimumSafeMass > 0 then
    maximumSafeGVolumeCoef = 1 - (fluidReservoirs.oil.minimumSafeMass - fluidReservoirs.oil.currentMass) / fluidReservoirs.oil.minimumSafeMass
    if fluidReservoirs.oil.currentMass <= constants.minimumOilMass then
      oilLubricationCoef = 0
    end
    if not damageTracker.getDamage("engine", "oilLevelCritical") then
      damageTracker.setDamage("engine", "oilLevelCritical", true, true)
    end
  elseif damageTracker.getDamage("engine", "oilLevelCritical") then
    damageTracker.setDamage("engine", "oilLevelCritical", false)
  end

  local oilStarvingSevernessZ = sensors.gz2
  local oilStarvingSevernessXY = 0
  if hasOilStarvingDamage and oilpanNodeBottom > -1 and oilpanNodeTop > -1 then
    local oilpanForceZ, oilpanForceXY = obj:getNodeForceNonInertial(oilpanNodeBottom, oilpanNodeTop)
    local invNodeWeight = 1 / v.data.nodes[oilpanNodeBottom].nodeWeight
    local gravityLimit = 3 * abs(powertrain.currentGravity)
    local oilpanAccZ = clamp(-oilpanForceZ * invNodeWeight, -gravityLimit, gravityLimit)
    local oilpanAccXY = clamp(oilpanForceXY * invNodeWeight, -gravityLimit, gravityLimit)
    oilpanAccZ = oilpanAccZSmoother:getUncapped(oilpanAccZ, dt)
    oilpanAccXY = oilpanAccXYSmoother:getUncapped(oilpanAccXY, dt)
    oilStarvingSevernessZ = -oilpanAccZ -- use the more accurate oilpan Z acc rather than refnode acc if we have it
    local oilAccRatio = clamp(oilpanAccXY / oilpanAccZ, -10, 10)
    oilStarvingSevernessXY = max(oilAccRatio - oilpanMaximumSafeG * maximumSafeGVolumeCoef, 0) --adjust max safe G based on oil level
  end

  local oilStarvingSeverness = max(oilStarvingSevernessXY * 20, oilStarvingSevernessZ * 0.05)
  local oilStarvingTimerAdjust = oilStarvingSeverness > 0 and 1 or -10
  oilStarvingTimer = clamp(oilStarvingTimer + oilStarvingTimerAdjust * dt, 0, oilStarvingTimerThreshold)

  if oilStarvingTimer >= oilStarvingTimerThreshold * 0.5 then
    oilLubricationCoef = min(oilLubricationCoef, linearScale(oilStarvingSevernessXY, 0, 1, 1, 0)) --handle XY starvation
    oilLubricationCoef = min(oilLubricationCoef, linearScale(oilStarvingSevernessZ, 0, 10, 1, 0)) --handle Z starvation (scales are different, that's why it's two calls)
  end

  if oilStarvingTimer >= oilStarvingTimerThreshold then
    if not damageTracker.getDamage("engine", "starvedOfOil") then
      damageTracker.setDamage("engine", "starvedOfOil", true, true)
    end
  elseif damageTracker.getDamage("engine", "starvedOfOil") then
    damageTracker.setDamage("engine", "starvedOfOil", false, false)
  end

  if oilStarvingSevernessZ > 1 then
    fluidLeakRates.oil.gravity = 0.005
  else
    fluidLeakRates.oil.gravity = 0
  end

  local absEngineRPM = abs(parentEngine.outputAV1 * conversion.avToRPM)
  local engineAVAdjustedPistonRingOilLeakRate = fluidLeakRates.oil.pistonRingDamage * (absEngineRPM > 10 and 1 or 0)

  if engineAVAdjustedPistonRingOilLeakRate + fluidLeakRates.oil.gravity > 0 or fluidReservoirs.oil.massInCylinders > 0 then
    local oilParticleType = electrics.values.airspeed < 10 and 36 or 38

    for _, n in pairs(nodes.exhaustEnds) do
      if particleTicks.exhaustOilParticleTick > 1 and not parentEngine.isDisabled then
        --emit blue smoke from all exhaust ends because we are burning oil
        obj:addParticleByNodesRelative(n.finish, n.start, absEngineRPM * -0.0004 - 2, oilParticleType, 0, 1)
      end
    end
  end

  fluidLeakRates.oil.combustionToExhaust = (not parentEngine.isDisabled and not parentEngine.isStalled) and absEngineRPM * 0.00001 or 0

  fluidLeakRates.oil.overall = fluidLeakRates.oil.oilpan + fluidLeakRates.oil.radiator + engineAVAdjustedPistonRingOilLeakRate + fluidLeakRates.oil.gravity
  fluidReservoirs.oil.currentMass = clamp(fluidReservoirs.oil.currentMass - fluidLeakRates.oil.overall * dt, constants.minimumOilMass, fluidReservoirs.oil.initialMass)
  fluidReservoirs.oil.massInCylinders = clamp(fluidReservoirs.oil.massInCylinders + (engineAVAdjustedPistonRingOilLeakRate + fluidLeakRates.oil.gravity - fluidLeakRates.oil.combustionToExhaust) * dt, 0, 0.5)

  --too much oil
  if fluidReservoirs.oil.currentMass > fluidReservoirs.oil.maximumSafeMass and fluidReservoirs.oil.maximumSafeMass > 0 then
    oilLubricationCoef = min(oilLubricationCoef, linearScale(fluidReservoirs.oil.currentMass, fluidReservoirs.oil.maximumSafeMass, fluidReservoirs.oil.maximumSafeMass * 1.1, 1, 0.8))
    if not damageTracker.getDamage("engine", "oilLevelTooHigh") then
      damageTracker.setDamage("engine", "oilLevelTooHigh", true, true)
    end
  elseif damageTracker.getDamage("engine", "oilLevelTooHigh") then
    damageTracker.setDamage("engine", "oilLevelTooHigh", false)
  end

  if oilLubricationCoef < 1 and not (parentEngine.isDisabled or parentEngine.isStalled or parentEngine.ignitionCoef < 1) then
    missingOilDamage = missingOilDamage + 0.00001 * dt * absEngineRPM * (1 - oilLubricationCoef)
    parentEngine:scaleFrictionInitial(1 + missingOilDamage)
  end

  engineThermalData.oilStarvingSevernessZ = oilStarvingSevernessZ
  engineThermalData.oilStarvingSevernessXY = oilStarvingSevernessXY
  engineThermalData.maximumSafeG = oilpanMaximumSafeG * maximumSafeGVolumeCoef
  engineThermalData.oilMass = fluidReservoirs.oil.currentMass
  engineThermalData.miniumSafeOilMass = fluidReservoirs.oil.minimumSafeMass
  engineThermalData.maximumSafeOilMass = fluidReservoirs.oil.maximumSafeMass
  engineThermalData.oilLeakRateOilpan = fluidLeakRates.oil.oilpan
  engineThermalData.oilLeakRateRadiator = fluidLeakRates.oil.radiator
  engineThermalData.oilLeakRateGravity = fluidLeakRates.oil.gravity
  engineThermalData.oilLeakRatePistonRingDamage = engineAVAdjustedPistonRingOilLeakRate
  engineThermalData.oilLeakRateOverall = fluidLeakRates.oil.overall
  engineThermalData.oilLubricationCoef = oilLubricationCoef
  engineThermalData.missingOilDamage = missingOilDamage
end

local function updateHeatTicksGFX(dt)
  local engineRunning = parentEngine.outputAV1 > parentEngine.starterMaxAV * 0.8

  if engineRunning then
    -- no ticks should happen if the engine is running. keep the delays at the "start delay"
    -- so the ticks don't start occurring immediately after the engine turns off.

    heatTickData.exhaustTickDelay = heatTickData.exhaustTickStartDelay
    heatTickData.engineTickDelay = heatTickData.engineTickStartDelay
  else
    local invDt = 1 / dt
    -- exhaust ticks
    local adjustedExhaustTemperature = M.exhaustTemperature - heatTickData.exhaustTickMinTemperature
    local deltaTExhaust = heatTickData.exhaustDeltaTempSmoother:get(heatTickData.lastExhaustTemperature - adjustedExhaustTemperature, dt)
    local deltaTExhaustRate = deltaTExhaust * invDt

    heatTickData.exhaustTickDelay = max(heatTickData.exhaustTickDelay - dt, 0)

    if deltaTExhaustRate <= heatTickData.exhaustTickMinRate then
      heatTickData.exhaustTickDelay = heatTickData.exhaustTickStartDelay
    end

    deltaTExhaustRate = min(max(deltaTExhaustRate, heatTickData.exhaustTickMinRate), heatTickData.exhaustTickMaxRate)
    heatTickData.exhaustTickBucket = max(heatTickData.exhaustTickBucket + deltaTExhaustRate * dt, 0)

    if heatTickData.exhaustTickBucket > heatTickData.exhaustTickBucketThreshold and adjustedExhaustTemperature > 0 and heatTickData.exhaustTickDelay <= 0 then
      local node = nodes.exhaust[random(#nodes.exhaust)]
      local tickSize = linearScale(randomGauss3(), 0, 3, 0, 1) ^ heatTickData.exhaustTickSizeBias
      sounds.playSoundOnceFollowNode(heatTickData.exhaustTickEventName, node, heatTickData.exhaustTickVolume, heatTickData.exhaustTickPitch, tickSize)
      heatTickData.exhaustTickBucket = 0
      heatTickData.exhaustTickBucketThreshold = heatTickData.exhaustTickPeriodGain * linearScale(randomGauss3(), 0, 3, 0.25, 1.75)
    end

    heatTickData.lastExhaustTemperature = adjustedExhaustTemperature

    -- engine ticks

    local adjustedEngineBlockTemperature = M.engineBlockTemperature - heatTickData.engineTickMinTemperature
    local deltaTEngine = heatTickData.engineDeltaTempSmoother:get(heatTickData.lastEngineBlockTemperature - adjustedEngineBlockTemperature, dt)
    local deltaTEngineRate = deltaTEngine * invDt

    heatTickData.engineTickDelay = max(heatTickData.engineTickDelay - dt, 0)

    if deltaTEngineRate <= heatTickData.engineTickMinRate then
      heatTickData.engineTickDelay = heatTickData.engineTickStartDelay
    end

    deltaTEngineRate = min(max(deltaTEngineRate, heatTickData.engineTickMinRate), heatTickData.engineTickMaxRate)
    heatTickData.engineTickBucket = max(heatTickData.engineTickBucket + deltaTEngineRate * dt, 0)

    if heatTickData.engineTickBucket > heatTickData.engineTickBucketThreshold and adjustedEngineBlockTemperature > 0 and heatTickData.engineTickDelay <= 0 then
      local node = nodes.engine[random(#nodes.engine)]
      local tickSize = linearScale(randomGauss3(), 0, 3, 0, 1) ^ heatTickData.engineTickSizeBias
      sounds.playSoundOnceFollowNode(heatTickData.engineTickEventName, node, heatTickData.engineTickVolume, heatTickData.engineTickPitch, tickSize)
      heatTickData.engineTickBucket = 0
      heatTickData.engineTickBucketThreshold = heatTickData.engineTickPeriodGain * linearScale(randomGauss3(), 0, 3, 0.25, 1.75)
    end

    heatTickData.lastEngineBlockTemperature = adjustedEngineBlockTemperature

    if heatTickData.debugEnabled then
      streams.drawGraph("adjEngBlkTemp", {value = adjustedEngineBlockTemperature, min = 0, max = 150})
      streams.drawGraph("engineTickDelay", {value = heatTickData.engineTickDelay, min = 0, max = 1})
      streams.drawGraph("engineTickBucket", {value = heatTickData.engineTickBucket / max(1e-5, heatTickData.engineTickBucketThreshold), min = 0, max = 1})
      streams.drawGraph("deltaTEngineRate", {value = deltaTEngine * invDt, min = -1, max = 5})
      streams.drawGraph("exhaustTickBucket", {value = heatTickData.exhaustTickBucket / max(1e-5, heatTickData.exhaustTickBucketThreshold), min = 0, max = 1})
      streams.drawGraph("deltaTExhaustRate", {value = deltaTExhaust * invDt, min = -1, max = 5})
    end
  end
end

local function getExhaustEndNodes(startNode, exhaustTree)
  local branch = exhaustTree.children[startNode]
  local endNodes = {}
  local allConnectedNodes = {}

  for k, child in pairs(branch.children) do
    --if at this point something broke away or we reached the original end of the branch
    if (child.childrenCount ~= child.initialChildrenCount or child.initialChildrenCount == 0) and not child.isStartNode then
      --save the nodes as exit nodes
      table.insert(
        endNodes,
        {
          start = child.previous,
          finish = child.cid,
          afterFireAudioCoef = child.afterFireAudioCoef,
          afterFireVolumeCoef = child.afterFireVolumeCoef,
          afterFireMufflingCoef = child.afterFireMufflingCoef,
          afterFireVisualCoef = child.afterFireVisualCoef,
          exhaustAudioOpennessCoef = child.exhaustAudioOpennessCoef,
          exhaustAudioGainChange = child.exhaustAudioGainChange,
          pukeTank1 = child.pukeTank1
        }
      )
    end
    table.insert(allConnectedNodes, child.cid)
    --if we have children left and we are not broken
    if child.childrenCount > 0 and not child.isBroken then
      --continue to search for more exit nodes in this branch
      local childEndNodes, childConnectedNodes = getExhaustEndNodes(k, branch)
      arrayConcat(endNodes, childEndNodes)
      arrayConcat(allConnectedNodes, childConnectedNodes)
    end
  end

  return endNodes, allConnectedNodes
end

local function parseExhaustTree(currentBranch, currentExhaustBeams, startNodeLookup)
  --copy table to not mess with the original one
  local beams = shallowcopy(currentExhaustBeams)
  local currentBeamKey, currentBeam = next(beams, nil)
  currentBranch.childrenCount = 0
  currentBranch.children = {}

  if not currentBranch.previous then
    local nodeData = v.data.nodes[currentBranch.cid]
    currentBranch.afterFireAudioCoef = (nodeData.afterFireAudioCoef or 1)
    currentBranch.afterFireVolumeCoef = (nodeData.afterFireVolumeCoef or 1)
    currentBranch.afterFireMufflingCoef = (nodeData.afterFireMufflingCoef or 1)
    currentBranch.afterFireVisualCoef = (nodeData.afterFireVisualCoef or 1)
    currentBranch.exhaustAudioOpennessCoef = (nodeData.exhaustAudioOpennessCoef or 1)
    currentBranch.exhaustAudioGainChange = (nodeData.exhaustAudioGainChange or 0)
    currentBranch.pukeTank1 = (nodeData.pukeTank1 or 1)
  end

  while currentBeam ~= nil do
    --if your node connects to the current beam (via node1 or node2)
    if currentBranch.cid == currentBeam.id1 then
      beams[currentBeamKey] = nil --beam is handled, ignore further down the line
      --build child branch based on current beam
      local nodeData = v.data.nodes[currentBeam.id2]
      local node = {
        cid = currentBeam.id2,
        previous = currentBranch.cid,
        beam = currentBeam.cid,
        level = currentBranch.level + 1,
        afterFireAudioCoef = currentBranch.afterFireAudioCoef * (nodeData.afterFireAudioCoef or 1),
        afterFireVolumeCoef = currentBranch.afterFireVolumeCoef * (nodeData.afterFireVolumeCoef or 1),
        afterFireMufflingCoef = currentBranch.afterFireMufflingCoef * (nodeData.afterFireMufflingCoef or 1),
        afterFireVisualCoef = currentBranch.afterFireVisualCoef * (nodeData.afterFireVisualCoef or 1),
        exhaustAudioOpennessCoef = currentBranch.exhaustAudioOpennessCoef * (nodeData.exhaustAudioMufflingCoef or 1),
        exhaustAudioGainChange = currentBranch.exhaustAudioGainChange + (nodeData.exhaustAudioGainChange or 0),
        pukeTank1 = currentBranch.pukeTank1 * (nodeData.pukeTank1 or 1)
      }
      currentBranch.children[currentBeam.id2] = parseExhaustTree(node, beams, startNodeLookup)
      currentBranch.childrenCount = currentBranch.childrenCount + 1
    elseif currentBranch.cid == currentBeam.id2 then
      --same as above but with switched id1 <-> id2
      beams[currentBeamKey] = nil
      local nodeData = v.data.nodes[currentBeam.id1]
      local node = {
        cid = currentBeam.id1,
        previous = currentBranch.cid,
        beam = currentBeam.cid,
        level = currentBranch.level + 1,
        afterFireAudioCoef = currentBranch.afterFireAudioCoef * (nodeData.afterFireAudioCoef or 1),
        afterFireVolumeCoef = currentBranch.afterFireVolumeCoef * (nodeData.afterFireVolumeCoef or 1),
        afterFireMufflingCoef = currentBranch.afterFireMufflingCoef * (nodeData.afterFireMufflingCoef or 1),
        afterFireVisualCoef = currentBranch.afterFireVisualCoef * (nodeData.afterFireVisualCoef or 1),
        exhaustAudioOpennessCoef = currentBranch.exhaustAudioOpennessCoef * (nodeData.exhaustAudioMufflingCoef or 1),
        exhaustAudioGainChange = currentBranch.exhaustAudioGainChange + (nodeData.exhaustAudioGainChange or 0),
        pukeTank1 = currentBranch.pukeTank1 * (nodeData.pukeTank1 or 1)
      }
      currentBranch.children[currentBeam.id1] = parseExhaustTree(node, beams, startNodeLookup)
      currentBranch.childrenCount = currentBranch.childrenCount + 1
    end

    currentBeamKey, currentBeam = next(beams, currentBeamKey)
  end

  currentBranch.initialChildrenCount = currentBranch.childrenCount
  currentBranch.isStartNode = startNodeLookup[currentBranch.cid] and true or false
  return currentBranch
end

local function buildExhaustTree()
  exhaustStartNodes = {}
  exhaustBeams = {}
  local exhaustBeamCache = {}
  local startNodeLookup = {}

  --search for the exhaust start node
  for _, n in pairs(v.data.nodes) do
    if n.isExhaust and (type(n.isExhaust) == "boolean" or n.isExhaust == parentEngine.name) then
      table.insert(exhaustStartNodes, n)
      startNodeLookup[n.cid] = true
    end
  end

  if #exhaustStartNodes <= 0 then
    log("E", "engine.buildExhaustTree", "No exhaust start node(s) specified")
    return false
  end

  --find all exhaust beams
  for _, b in pairs(v.data.beams) do
    if b.isExhaust and (type(b.isExhaust) == "boolean" or b.isExhaust == parentEngine.name) then
      --one table for immediate use
      table.insert(exhaustBeamCache, b)
      --one table for look ups when a beam breaks
      exhaustBeams[b.cid] = true
    end
  end

  exhaustTrees = {}
  for _, n in ipairs(exhaustStartNodes) do
    --build exhaust tree recursively
    local exhaustTree = {children = {}, startCid = n.cid}
    exhaustTree.children[n.cid] = parseExhaustTree({cid = n.cid, level = 0}, exhaustBeamCache, startNodeLookup)
    table.insert(exhaustTrees, exhaustTree)
  end

  local tmpExhaustEndNodes = {}
  local tmpExhaustConnectedNodes = {}
  for _, t in ipairs(exhaustTrees) do
    --find initial exhaust end points
    local treeEndNodes, allTreeNodes = getExhaustEndNodes(t.startCid, t)
    tmpExhaustEndNodes = arrayConcat(tmpExhaustEndNodes, treeEndNodes)
    tmpExhaustConnectedNodes = arrayConcat(tmpExhaustConnectedNodes, allTreeNodes)
  end
  --dump(tmpExhaustEndNodes)

  nodes.exhaustEnds = {}
  local exhaustEndNodeDeDuplicate = {}
  for _, v in ipairs(tmpExhaustEndNodes) do
    if not exhaustEndNodeDeDuplicate[v.finish] then
      table.insert(nodes.exhaustEnds, v)
      exhaustEndNodeDeDuplicate[v.finish] = true
    end
  end

  nodes.exhaust = {}
  local exhaustConnectedNodeDeDuplicate = {}
  for _, v in ipairs(tmpExhaustConnectedNodes) do
    if not exhaustConnectedNodeDeDuplicate[v] then
      table.insert(nodes.exhaust, v)
      exhaustConnectedNodeDeDuplicate[v] = true
    end
  end

  --dump(nodes.exhaustEnds)
  M.exhaustEndNodes = nodes.exhaustEnds

  if #nodes.exhaustEnds <= 0 then
    log("E", "engine.buildExhaustTree", "No exhaust end nodes found")
    return false
  end

  --dump(exhaustTrees)
  --print(afterFire.exhaustMaxLevel)

  return true
end

local function exhaustBeamBroken(id, exhaustTree)
  for _, v in pairs(exhaustTree.children) do
    --if the broken beam matches one of our tree beams
    if v and v.beam == id then
      --break off this branch
      v.isBroken = true
      exhaustTree.childrenCount = exhaustTree.childrenCount - 1
    elseif v and v.children then
      exhaustBeamBroken(id, v)
    end
  end
end

local function beamBroke(id)
  if exhaustBeams and exhaustBeams[id] then
    exhaustBeams[id] = false
    local tmpExhaustEndNodes = {}
    local tmpExhaustConnectedNodes = {}
    for _, t in ipairs(exhaustTrees) do
      --break off a tree branch
      exhaustBeamBroken(id, t)
      --and find the new exit nodes
      local treeEndNodes, allTreeNodes = getExhaustEndNodes(t.startCid, t)
      tmpExhaustEndNodes = arrayConcat(tmpExhaustEndNodes, treeEndNodes)
      tmpExhaustConnectedNodes = arrayConcat(tmpExhaustConnectedNodes, allTreeNodes)
    end
    --dump(tmpExhaustNodes)

    nodes.exhaustEnds = {}
    local exhaustEndNodeDeDuplicate = {}
    for _, v in ipairs(tmpExhaustEndNodes) do
      if not exhaustEndNodeDeDuplicate[v.finish] then
        table.insert(nodes.exhaustEnds, v)
        exhaustEndNodeDeDuplicate[v.finish] = true
      end
    end

    nodes.exhaust = {}
    local exhaustConnectedNodeDeDuplicate = {}
    for _, v in ipairs(tmpExhaustConnectedNodes) do
      if not exhaustConnectedNodeDeDuplicate[v] then
        table.insert(nodes.exhaust, v)
        exhaustConnectedNodeDeDuplicate[v] = true
      end
    end

    --dump(nodes.exhaustEnds)

    parentEngine:exhaustEndNodesChanged(nodes.exhaustEnds)

    if not damageTracker.getDamage("engine", "exhaustBroken") then
      damageTracker.setDamage("engine", "exhaustBroken", true)
      guihooks.message("vehicle.combustionEngine.exhaustDamaged", 4, "vehicle.damage.exhaust")
    end
  end
end

local function resetExhaustTree(exhaustTree)
  for _, v in pairs(exhaustTree.children) do
    --if one of the children are already broken
    if v then
      if v.isBroken then
        --repair this branch
        v.isBroken = false
        exhaustTree.childrenCount = exhaustTree.childrenCount + 1
      end
      if v.children then
        resetExhaustTree(v)
      end
    end
  end
end

local function reset(jbeamData)
  tEnv = obj:getEnvTemperature() + conversion.kelvinToCelsius
  --default temperatures, can be adjusted to fit whatever our goal is (starting up with cold vs warm car)
  local startingTemperature = startPreHeated and constants.preHeatTemperature or tEnv
  M.engineBlockTemperature = startingTemperature
  M.cylinderWallTemperature = startingTemperature
  M.oilTemperature = startingTemperature
  M.coolantTemperature = startingTemperature
  M.exhaustTemperature = startingTemperature

  if not thermalsEnabled then
    --disable the whole thing unless stated otherwise
    --log("D", "engine.initThermals", "Engine thermals are disabled since they are missing in JBeam")
    return
  end

  fanAirSpeed = 0
  radiatorFanElectricSoundPlaying = false
  M.engineBlockOverheatDamage = 0
  M.oilOverheatDamage = 0
  M.cylinderWallOverheatDamage = 0
  M.headGasketBlown = jbeamData.headGasketBlownOverride or false
  M.pistonRingsDamaged = jbeamData.pistonRingsDamagedOverride or false
  M.connectingRodBearingsDamaged = jbeamData.connectingRodBearingsDamagedOverride or false
  radiatorDamage = 0
  M.cylinderWallsMelted = false
  M.engineBlockMelted = false
  M.radiatorFanSpin = 0

  fluidLeakRates.coolant.overpressure = 0
  fluidLeakRates.coolant.headGasket = 0
  fluidLeakRates.coolant.radiator = 0
  fluidLeakRates.coolant.overall = 0
  fluidLeakRates.coolant.previousOverall = 0
  fluidLeakRates.oil.oilpan = 0
  fluidLeakRates.oil.radiator = 0
  fluidLeakRates.oil.gravity = 0
  fluidLeakRates.oil.pistonRingDamage = 0
  fluidLeakRates.oil.combustionToExhaust = 0
  fluidLeakRates.oil.overall = 0

  fluidReservoirs.coolant.currentMass = fluidReservoirs.coolant.initialMass
  fluidReservoirs.oil.currentMass = fluidReservoirs.oil.initialMass
  fluidReservoirs.oil.massInCylinders = 0

  oilStarvingTimer = 0
  missingOilDamage = 0

  oilpanAccXYSmoother:reset()
  oilpanAccZSmoother:set(-powertrain.currentGravity)

  particleTicks.engineSteamParticleTick = 0
  particleTicks.exhaustSteamParticleTick = 0
  particleTicks.exhaustOilParticleTick = 0
  particleTicks.exhaustSmokeParticleTick = 0
  knockSoundTick = 0

  afterFire.afterFireSoundTimer = 0
  afterFire.instantAfterFireFuel = 0
  afterFire.sustainedAfterFireFuel = 0
  afterFire.shiftAfterFireFuel = 0

  electricalRadiatorFanSmoother:reset()

  for k, _ in pairs(exhaustBeams) do
    exhaustBeams[k] = true
  end

  local tmpExhaustEndNodes = {}
  local tmpExhaustConnectedNodes = {}
  for _, t in ipairs(exhaustTrees) do
    --break off a tree branch
    resetExhaustTree(t)
    --and find the new exit nodes
    local treeEndNodes, allTreeNodes = getExhaustEndNodes(t.startCid, t)
    tmpExhaustEndNodes = arrayConcat(tmpExhaustEndNodes, treeEndNodes)
    tmpExhaustConnectedNodes = arrayConcat(tmpExhaustConnectedNodes, allTreeNodes)
  end

  nodes.exhaustEnds = {}
  local exhaustNodeDeDuplicate = {}
  for _, v in ipairs(tmpExhaustEndNodes) do
    if not exhaustNodeDeDuplicate[v.finish] then
      table.insert(nodes.exhaustEnds, v)
      exhaustNodeDeDuplicate[v.finish] = true
    end
  end

  nodes.exhaust = {}
  local exhaustConnectedNodeDeDuplicate = {}
  for _, v in ipairs(tmpExhaustConnectedNodes) do
    if not exhaustConnectedNodeDeDuplicate[v] then
      table.insert(nodes.exhaust, v)
      exhaustConnectedNodeDeDuplicate[v] = true
    end
  end

  --dump(tmpExhaustEndNodes)
  M.exhaustEndNodes = nodes.exhaustEnds
  --invExhaustNodeCount = #nodes.exhaustEnds > 0 and 1 / sqrt(#nodes.exhaustEnds) or 0

  if hasCoolantRadiator then
    damageTracker.setDamage("engine", "radiatorLeak", false)
  end
  damageTracker.setDamage("engine", "coolantOverheating", false)
  damageTracker.setDamage("engine", "oilOverheating", false)
  damageTracker.setDamage("engine", "oilRadiatorLeak", false)
  damageTracker.setDamage("engine", "oilpanLeak", false)
  damageTracker.setDamage("engine", "headGasketDamaged", M.headGasketBlown)
  damageTracker.setDamage("engine", "rodBearingsDamaged", M.connectingRodBearingsDamaged)
  damageTracker.setDamage("engine", "pistonRingsDamaged", M.pistonRingsDamaged)
  damageTracker.setDamage("engine", "cylinderWallsMelted", false)
  damageTracker.setDamage("engine", "blockMelted", false)
  damageTracker.setDamage("engine", "starvedOfOil", false)
  damageTracker.setDamage("engine", "oilLevelCritical", false)
  damageTracker.setDamage("engine", "oilLevelTooHigh", false)
  damageTracker.setDamage("engine", "exhaustBroken", false)
end

local function initThermals(jbeamData)
  thermalsEnabled = false
  tEnv = obj:getEnvTemperature() + conversion.kelvinToCelsius
  startPreHeated = settings.getValue("startThermalsPreHeated")
  --default temperatures, can be adjusted to fit whatever our goal is (starting up with cold vs warm car)
  local startingTemperature = startPreHeated and constants.preHeatTemperature or tEnv
  M.engineBlockTemperature = startingTemperature
  M.cylinderWallTemperature = startingTemperature
  M.oilTemperature = startingTemperature
  M.coolantTemperature = startingTemperature
  M.exhaustTemperature = startingTemperature
  if not jbeamData.thermalsEnabled then
    --disable the whole thing unless stated otherwise
    --log("D", "engine.initThermals", "Engine thermals are disabled since they are missing in JBeam")
    return
  end

  heatTickData.lastEngineBlockTemperature = M.engineBlockTemperature
  heatTickData.lastExhaustTemperature = M.exhaustTemperature

  fanAirSpeed = 0
  M.engineBlockOverheatDamage = 0
  M.oilOverheatDamage = 0
  M.cylinderWallOverheatDamage = 0
  M.headGasketBlown = jbeamData.headGasketBlownOverride or false
  M.pistonRingsDamaged = jbeamData.pistonRingsDamagedOverride or false
  M.connectingRodBearingsDamaged = jbeamData.connectingRodBearingsDamagedOverride or false
  radiatorDamage = 0
  M.cylinderWallsMelted = false
  M.engineBlockMelted = false
  M.radiatorFanSpin = 0

  oilStarvingTimer = 0
  missingOilDamage = 0
  particleTicks.engineSteamParticleTick = 0
  particleTicks.exhaustSteamParticleTick = 0
  particleTicks.exhaustOilParticleTick = 0
  particleTicks.exhaustSmokeParticleTick = 0
  knockSoundTick = 0

  particulates = jbeamData.particulates or 0
  idleParticulates = jbeamData.idleParticulates or 0

  afterFire = {
    afterFireSoundTimer = 0,
    afterFireDecayTimer = 0,
    instantAfterFireFuel = 0,
    sustainedAfterFireFuel = 0,
    shiftAfterFireFuel = 0,
    instantAudioSample = jbeamData.instantAfterFireSound or "event:>Vehicle>Afterfire>01_Single_EQ1",
    sustainedAudioSample = jbeamData.sustainedAfterFireSound or "event:>Vehicle>Afterfire>01_Multi_EQ1",
    shiftAudioSample = jbeamData.shiftAfterFireSound or "event:>Vehicle>Afterfire>01_Shift_EQ1",
    instantVolumeCoef = jbeamData.instantAfterFireVolumeCoef or 1,
    sustainedVolumeCoef = jbeamData.sustainedAfterFireVolumeCoef or 1,
    shiftVolumeCoef = jbeamData.shiftAfterFireVolumeCoef or 4,
    pukeTank1 = jbeamData.pukeTank1 or 1,
    audibleThresholdInstant = jbeamData.afterFireAudibleThresholdInstant or 500000,
    audibleThresholdSustained = jbeamData.afterFireAudibleThresholdSustained or 40000,
    audibleThresholdShift = jbeamData.afterFireAudibleThresholdShift or 250000,
    visualThresholdInstant = jbeamData.afterFireVisualThresholdInstant or 500000,
    visualThresholdSustained = jbeamData.afterFireVisualThresholdSustained or 150000,
    visualThresholdShift = jbeamData.afterFireVisualThresholdShift or 1000000
  }

  -- Audio Debug
  -- print (string.format("instantAfterFire coef %.2f / sustainedAfterFire coef %.2f / shiftVolumeCoef %.2f", jbeamData.instantAfterFireVolumeCoef, jbeamData.sustainedAfterFireVolumeCoef, jbeamData.shiftAfterFireVolumeCoef))
  -- print (string.format("audibleThresholdInstant %.2f / audibleThresholdSustained %.2f / audibleThresholdShift %.2f", jbeamData.afterFireAudibleThresholdInstant, jbeamData.afterFireAudibleThresholdSustained, jbeamData.afterFireAudibleThresholdShift))

  fluidReservoirs.coolant.currentMass = max(jbeamData.coolantVolume or 0, constants.minimumCoolantMass) --can't have 0 coolant to protect against div by 0
  fluidReservoirs.coolant.initialMass = fluidReservoirs.coolant.currentMass
  fluidReservoirs.coolant.invInitialMass = 1 / fluidReservoirs.coolant.currentMass
  fluidReservoirs.oil.currentMass = (jbeamData.oilVolume or 5) * 0.87
  fluidReservoirs.oil.initialMass = fluidReservoirs.oil.currentMass
  fluidReservoirs.oil.minimumSafeMass = jbeamData.oilMinimumSafeVolume and (jbeamData.oilMinimumSafeVolume * 0.87) or fluidReservoirs.oil.currentMass - 0.87 --1L extra by default
  fluidReservoirs.oil.maximumSafeMass = jbeamData.oilMaximumSafeVolume and (jbeamData.oilMaximumSafeVolume * 0.87) or fluidReservoirs.oil.currentMass + 0.87 --1L extra by default
  fluidReservoirs.oil.massInCylinders = 0

  fluidLeakRates.coolant.overpressure = 0
  fluidLeakRates.coolant.headGasket = 0
  fluidLeakRates.coolant.radiator = 0
  fluidLeakRates.coolant.overall = 0
  fluidLeakRates.coolant.previousOverall = 0
  fluidLeakRates.oil.oilpan = 0
  fluidLeakRates.oil.radiator = 0
  fluidLeakRates.oil.gravity = 0
  fluidLeakRates.oil.pistonRingDamage = 0
  fluidLeakRates.oil.combustionToExhaust = 0
  fluidLeakRates.oil.overall = 0

  if jbeamData.oilpanNodes_nodes then
    oilpanNodeBottom = jbeamData.oilpanNodes_nodes[1] or -1
    oilpanNodeTop = jbeamData.oilpanNodes_nodes[2] or -1
  end
  oilpanMaximumSafeG = jbeamData.oilpanMaximumSafeG or 1.2
  oilpanAccZSmoother:set(-powertrain.currentGravity)

  burnEfficiencyCoef = {}
  for k, v in pairs(parentEngine.invBurnEfficiencyTable) do
    burnEfficiencyCoef[k] = v - 1
  end
  mechanicalRadiatorFanRPMCoef = 1 / (parentEngine.maxRPM * 0.7)

  local engineBlockMaterial = jbeamData.engineBlockMaterial or "iron"
  local engineBlockSpecHeat
  local cylinderWallSpecHeat
  if engineBlockMaterial == "iron" then
    engineBlockSpecHeat = 450
    cylinderWallSpecHeat = 450
    engineBlockMeltingTemperature = 1100
    cylinderWallMeltingTemperature = 1200
  elseif engineBlockMaterial == "aluminium" or engineBlockMaterial == "aluminum" then
    engineBlockSpecHeat = 910
    cylinderWallSpecHeat = 910
    engineBlockMeltingTemperature = 660
    cylinderWallMeltingTemperature = 700
  else
    log("E", "engine.initThermals", "Unknown engine block material specified: " .. engineBlockMaterial)
    log("E", "engine.initThermals", "Engine thermals are disabled due to above error")
    return
  end

  engineBlockAirCoolingEfficiency = jbeamData.engineBlockAirCoolingEfficiency or 50
  engineBlockAirflowCoef = jbeamData.engineBlockAirflowCoef or 1
  blockFanMaxAirSpeed = jbeamData.blockFanMaxAirSpeed or 0
  blockFanRPMCoef = 1 / parentEngine.maxRPM
  radiatorFanType = jbeamData.radiatorFanType
  electricRadiatorFanOverrideIgnitionLevel = jbeamData.electricRadiatorFanOverrideIgnitionLevel or -1
  local radiatorArea = jbeamData.radiatorArea or 0
  local isAirCooledOnly = jbeamData.isAirCooledOnly or false
  radiatorFanMaxAirSpeed = jbeamData.radiatorFanMaxAirSpeed or 0
  mechanicalFanRPMCoef = jbeamData.mechanicalFanRPMCoef or 0.5
  local radiatorEffectiveness = jbeamData.radiatorEffectiveness or 0
  local radiatorFanTemperatureDesired = jbeamData.radiatorFanTemperature or 110
  thermostatTemperature = jbeamData.thermostatTemperature or 90
  radiatorFanTemperature = max(radiatorFanTemperatureDesired, thermostatTemperature * 1.03 + 2) --make sure that rad fan temp and thermostat fan work correctly together
  if radiatorFanTemperature > radiatorFanTemperatureDesired then
    log("W", "engine.initThermals", string.format("Increased desired radiator fan temperature from '%dC' to '%dC' to prevent fan trigger issues with thermostat at '%dC'.", radiatorFanTemperatureDesired, radiatorFanTemperature, thermostatTemperature))
  end

  airRegulatorClosedCoef = jbeamData.airRegulatorClosedCoef or 0.1
  oilThermostatTemperature = jbeamData.oilThermostatTemperature or 110
  local oilRadiatorArea = jbeamData.oilRadiatorArea or 0
  local oilRadiatorEffectiveness = jbeamData.oilRadiatorEffectiveness or 0
  damageThreshold.cylinderWallTemperature = jbeamData.cylinderWallTemperatureDamageThreshold or 160
  damageThreshold.headGasket = jbeamData.headGasketDamageThreshold or 2000000
  damageThreshold.pistonRing = jbeamData.pistonRingDamageThreshold or 2000000
  damageThreshold.connectingRod = jbeamData.connectingRodDamageThreshold or 2000000
  damageThreshold.engineBlockTemperature = jbeamData.engineBlockTemperatureDamageThreshold or 140
  radiatorDeformThreshold = jbeamData.radiatorDeformThreshold or 0.015
  hasCoolantRadiator = radiatorArea > 0 and radiatorEffectiveness > 0
  hasOilRadiator = oilRadiatorArea > 0 and oilRadiatorEffectiveness > 0
  hasOilStarvingDamage = (jbeamData.hasOilStarvingDamage == nil or jbeamData.hasOilStarvingDamage)

  if radiatorFanType and radiatorFanType ~= "electric" and radiatorFanType ~= "mechanical" then
    log("E", "engine.initThermals", "Unknown radiator fan type specified: " .. radiatorFanType)
  end
  electricalRadiatorFanSmoother = newTemporalSmoothing(500, 1000)

  local engineBlockMass = 0

  if not jbeamData.engineBlock or not jbeamData.engineBlock._engineGroup_nodes then -- little hack to make it not fail while the jbeam parsing is broken
    log("E", "engine.initThermals", "No engineBlock node group specified")
    log("E", "engine.initThermals", "Engine thermals are disabled due to above error")
    return
  end
  for _, n in pairs(jbeamData.engineBlock._engineGroup_nodes) do
    engineBlockMass = engineBlockMass + v.data.nodes[n].nodeWeight
  end

  local cylinderWallMass = math.max(engineBlockMass / 100, 4)
  local exhaustMass = math.max(engineBlockMass / 10, 5)

  energyCoef.coolant = 1 / (fluidReservoirs.coolant.currentMass * constants.coolantSpecHeat)
  energyCoef.oil = 1 / (fluidReservoirs.oil.currentMass * constants.oilSpecHeat)
  energyCoef.cylinderWall = 1 / (cylinderWallMass * cylinderWallSpecHeat)
  energyCoef.engineBlock = 1 / (engineBlockMass * engineBlockSpecHeat)
  energyCoef.exhaust = 1 / (constants.exhaustSpecHeat * exhaustMass)
  radiatorCoef = radiatorEffectiveness * radiatorArea
  oilRadiatorCoef = oilRadiatorEffectiveness * oilRadiatorArea
  radiatorDamageDeformGroup = jbeamData.radiatorDamageDeformGroup or "radiator_damage"

  nodes.coolantCap = {}
  nodes.radiator = {}
  nodes.engine = {}

  if hasCoolantRadiator then
    if not jbeamData.radiator then
      log("E", "engine.initThermals", "No radiator node group specified")
      log("E", "engine.initThermals", "Engine thermals are disabled due to above error")
      return
    else
      arrayConcat(nodes.radiator, jbeamData.radiator._engineGroup_nodes or {})
    end
  end

  arrayConcat(nodes.coolantCap, jbeamData.engineBlock._engineGroup_nodes or {})
  arrayConcat(nodes.engine, jbeamData.engineBlock._engineGroup_nodes or {})

  if #nodes.coolantCap < 2 then
    log("D", "engine.initThermals", "Wrong number of coolant cap nodes found. Should be at least 2, is: " .. #nodes.coolantCap)
  end

  if #nodes.engine < 2 then
    log("E", "engine.initThermals", "Wrong number of engine nodes found. Should be at least 2, is: " .. #nodes.engine)
    log("E", "engine.initThermals", "Engine thermals are disabled due to above error")
    return
  end

  if hasCoolantRadiator and #nodes.radiator < 2 then
    log("D", "engine.initThermals", "Wrong number of radiator nodes found. Should be at least 2, is: " .. #nodes.radiator)
  end

  if not buildExhaustTree() then
    log("E", "engine.initThermals", "Building the exhaust tree failed, please look above for the actual reason")
    log("E", "engine.initThermals", "Engine thermals are disabled due to above error")
    return
  end

  if hasCoolantRadiator then
    damageTracker.setDamage("engine", "radiatorLeak", false)
  end
  damageTracker.setDamage("engine", "coolantOverheating", false)
  damageTracker.setDamage("engine", "oilOverheating", false)
  damageTracker.setDamage("engine", "oilRadiatorLeak", false)
  damageTracker.setDamage("engine", "oilpanLeak", false)
  damageTracker.setDamage("engine", "headGasketDamaged", M.headGasketBlown)
  damageTracker.setDamage("engine", "rodBearingsDamaged", M.connectingRodBearingsDamaged)
  damageTracker.setDamage("engine", "pistonRingsDamaged", M.pistonRingsDamaged)
  damageTracker.setDamage("engine", "cylinderWallsMelted", false)
  damageTracker.setDamage("engine", "blockMelted", false)
  damageTracker.setDamage("engine", "starvedOfOil", false)
  damageTracker.setDamage("engine", "oilLevelCritical", false)
  damageTracker.setDamage("engine", "oilLevelTooHigh", false)
  damageTracker.setDamage("engine", "exhaustBroken", false)

  if isAirCooledOnly then
    updateCoolingGFXMethod = updateAirCoolingGFX
  else
    updateCoolingGFXMethod = updateWaterCoolingGFX
  end
  updateExhaustGFXMethod = updateExhaustGFX
  updateMechanicsGFXMethod = updateMechanicsGFX

  thermalsEnabled = true
end

local function initSounds(jbeamData)
  if hasCoolantRadiator then
    local hissSample = jbeamData.radiatorHissLoopEvent or "event:>Vehicle>Failures>failure_radiator"
    radiatorHissSound = obj:createSFXSource2(hissSample, "AudioDefaultLoop3D", "radiatorHiss", nodes.radiator[1] or nodes.engine[1] or parentEngine.engineNodeID, 0)
  end

  local radiatorFanSample = jbeamData.radiatorFanLoopEvent or "event:>Vehicle>Cooling Fan>Mechanical_03"
  radiatorFanVolume = jbeamData.radiatorFanVolume or 0.5
  if radiatorFanType == "mechanical" then
    radiatorFanSound = obj:createSFXSource2(radiatorFanSample, "AudioDefaultLoop3D", "radiatorFan", nodes.radiator[1] or nodes.engine[1] or parentEngine.engineNodeID, 1)
  elseif radiatorFanType == "electric" then
    radiatorFanSound = obj:createSFXSource2(radiatorFanSample, "AudioDefaultLoop3D", "radiatorFan", nodes.radiator[1] or nodes.engine[1] or parentEngine.engineNodeID, 0)
  end

  -- engine cooldown tick parameters
  heatTickData.engineTickPeriodGain = jbeamData.engineTickPeriodGain or 0.04 -- average random delay (arbitrary unit, not seconds) between ticks; the smaller the number, the faster the triggering
  heatTickData.engineTickMinRate = jbeamData.engineTickMinRate or 0 -- engine cooling rate (°C/s) below which no cooldown sounds will play at all
  heatTickData.engineTickMaxRate = jbeamData.engineTickMaxRate or 0.3 -- engine cooling rate above which ticks will not get any more frequent
  heatTickData.engineTickMinTemperature = jbeamData.engineTickMinTemperature or 80 -- minimum engine temperature below which no cooldown sounds will play at all
  heatTickData.engineTickPitch = jbeamData.engineTickPitch or 0.5 -- overall pitch of ticks; 0 = -12 semitones, 1 = +12 semitones, 0.5 = default, e.g. no pitch change
  heatTickData.engineTickSizeBias = jbeamData.engineTickSizeBias or 1.2 -- exponential scale to affect tick "size" (color parameter) probability. larger values result in "lower"-sounding ticks more often. set this to 0 for debugging only the large ticks.
  heatTickData.engineTickVolume = jbeamData.engineTickVolume or 0.5 -- overall volume of ticks; 0 = silent, 0.5 = default, 1 = +14 dB
  heatTickData.engineTickStartDelay = jbeamData.engineTickStartDelay or 2 -- duration of initial delay before cooling ticks can be heard after switching off engine
  heatTickData.engineTickEventName = jbeamData.engineTickEventName or "event:>Engine>Cooldown>Crackles_Engine_V1"

  -- exhaust cooldown tick parameters
  heatTickData.exhaustTickPeriodGain = jbeamData.exhaustTickPeriodGain or 0.64
  heatTickData.exhaustTickMinRate = jbeamData.exhaustTickMinRate or 0.2
  heatTickData.exhaustTickMaxRate = jbeamData.exhaustTickMaxRate or 0.3
  heatTickData.exhaustTickMinTemperature = jbeamData.exhaustTickMinTemperature or 240
  heatTickData.exhaustTickPitch = jbeamData.exhaustTickPitch or 0.5
  heatTickData.exhaustTickSizeBias = jbeamData.exhaustTickSizeBias or 2
  heatTickData.exhaustTickVolume = jbeamData.exhaustTickVolume or 0.5
  heatTickData.exhaustTickStartDelay = jbeamData.exhaustTickStartDelay or 3
  heatTickData.exhaustTickEventName = jbeamData.exhaustTickEventName or "event:>Engine>Cooldown>Crackles_Exhaust_V1"
end

local function resetSounds()
  if radiatorHissSound then
    obj:stopSFX(radiatorHissSound)
  end
  if radiatorFanSound then
    obj:stopSFX(radiatorFanSound)
    if radiatorFanType == "mechanical" then
      obj:playSFX(radiatorFanSound)
    end
  end
end

local function updateGFX(dt)
  updateCoolingGFXMethod(dt)
  updateExhaustGFXMethod(dt)
  updateMechanicsGFXMethod(dt)
  updateHeatTicksGFX(dt)
end

local function init(engine, engineJbeamData)
    parentEngine = engine
    onInit()  -- Call onInit to set up electrics values
    initThermals(engineJbeamData)
end

M.init = init
M.initSounds = initSounds
M.resetSounds = resetSounds
M.reset = reset
M.updateGFX = updateGFX
M.beamBroke = beamBroke

M.applyDeformGroupDamageRadiator = applyDeformGroupDamageRadiator
M.applyDeformGroupDamageOilpan = applyDeformGroupDamageOilpan
M.applyDeformGroupDamageOilRadiator = applyDeformGroupDamageOilRadiator
M.setPartConditionRadiator = setPartConditionRadiator
M.setPartConditionExhaust = setPartConditionExhaust
M.setPartConditionThermals = setPartConditionThermals
M.getPartConditionRadiator = getPartConditionRadiator
M.getPartConditionExhaust = getPartConditionExhaust
M.getPartConditionThermals = getPartConditionThermals

return M
