-- OE ProTune ECU (BeamNG) - shared vehicle controller / ECU simulation adapter
--
-- A PCLink-style training ECU layered on top of any BeamNG combustion engine
-- (naturally aspirated OR turbocharged). It reads the real engine state
-- (rpm, load, torque, coolant/oil temp, boost) and simulates the ECU layer on
-- top -- lambda, knock, injector duty, fuel pressure, intake air temp, fault
-- memory, engine damage -- so that tuning the fuel / ignition / boost / lambda
-- tables has believable consequences. Effects are applied through proven CSP-
-- equivalent BeamNG hooks:
--   * power      -> engine.intakeAirDensityCoef multiplier (updateGFX wrap)
--   * rev limit  -> engine:setTempRevLimiter
--   * boost      -> turbo:setWastegateOffset (turbo only)
--   * blow up    -> engine:lockUp + damageTracker
--   * pops       -> instant/sustained afterFireCoef
--
-- This is a training physics layer, not real Link firmware. HP/torque come from
-- the engine's own torque output; the simulated channels teach the cause/effect
-- of tuning without risking a real engine.
--
-- Opt-in per car (optional):
--   "controller": [["fileName"], ["oeProTune", {"carName":"My Car"}]]
-- Most cars are picked up automatically by extensions/auto/oeProTuneAutoload.lua.

local M = {}
M.type = "auxiliary"
M.relevantDevice = nil
M.defaultOrder = 500

local min = math.min
local max = math.max
local abs = math.abs
local floor = math.floor
local sqrt = math.sqrt
local random = math.random
local log = math.log
-- the REAL BeamNG logger - `log` above shadows it with math.log for the pop-timer math, so any
-- log(level, tag, msg) call in this file MUST go through syslog (calling log("I", ...) crashes
-- with "number expected, got string" and kills the whole calling chain)
local syslog = rawget(_G, "log")
-- init-log marker stamped on the "controller init" line. NOTE: vehicle VMs start with an unseeded
-- math.random, so this is NOT unique across vehicles (every fresh VM draws the same first number) -
-- fine for grepping "did a controller init happen", useless as an instance discriminator.
local instTag = string.format("%04x", math.random(0, 65535))
local rpmToAV = math.pi / 30
local avToRPM = 30 / math.pi
local PSI_TO_KPA = 6.894757
local KPA_TO_PSI = 1 / PSI_TO_KPA

-- ---------------------------------------------------------------------------
-- state
-- ---------------------------------------------------------------------------

local engine = nil
local turbo = nil
local active = false
local controlling = false   -- user pressed Connect: the tuner actively controls the engine
local wasControlling = false
local blockOverride = nil   -- UI-supplied block strength (from the fitted block PART NAME, which
                            -- is reliable even on custom-Lua mods); overrides the torque-based guess
-- HARDWARE-NEEDS-TUNING (V1.5): bolt-ons add air/flow/compression the stock tune does not feed,
-- so the car runs off (lean under boost, rich on big injectors, lumpy idle on cams/ITBs, lean
-- on a stroker) until YOU tune for it. Levels are classified from the fitted parts by the UI and
-- pushed via setHardware. All effects are self-regulating: the player's own fuel/timing/injector
-- tuning cancels them, and 0 across the board (a stock car) means zero penalty.
local hw = { turbo = 0, cam = 0, itb = 0, inj = 0, injAbs = 0, itbAbs = 0, displ = 0, intake = 0 }   -- V3.0: injAbs = ABSOLUTE fitted injector cc (inj is baseline-gated to upgrades only); sizes the fuel-pump headroom. V3.0.7: itbAbs = ABSOLUTE fitted ITBs, same reasoning - drives the manifold-pressure model
local engSpec = { cyl = 0, displL = 0 }   -- detected from the engine part name (V2 engine-aware injector scaling)
local dieselOverride = nil   -- UI-supplied diesel flag from the engine PART NAME (covers modded diesels that don't tag requiredEnergyType); OR'd with the controller's own detection
local rollingHold = false    -- rolling anti-lag button held (set by the bound OE ProTune input action via setRollingHold)
local rollingHoldRpm = nil   -- the rpm captured when you PRESSED the rolling button (the rev it holds at)
local rollingWasActive = false  -- edge detect so we capture the hold rpm only on the press, not every frame
local justInited = true     -- set on every spawn/respawn (e.g. a part change); UI re-applies the tune when it sees this
local frSeq = 0              -- V2.9: bumps on every factoryReset; rides in publishState so the UI accepts exactly the
                             -- POST-reset settings broadcast (a pre-reset echo in flight used to eat the one-shot flag)
local engineClassKnown = false  -- false until the UI has told us our engine family (V8 vs not) via setEngineClass.
                                -- Published so the UI re-detects + re-pushes whenever a fresh controller (e.g. a
                                -- just-switched-to car) hasn't been told yet -> the right 2-step sound always lands.
local carName = "BeamNG Vehicle"
local base = {}
local engineWrapped = false
local simTime = 0
local telemetryTimer = 0
local remoteDash = false    -- Remote Dash: the GE server poked setRemoteDash(true) and wants 10 Hz telemetry pushes
local remoteTimer = 0
local remoteEmit            -- forward decl (defined with the telemetry publishers; blown/knock/dyno sites above them use it too)
local stateDirty = true
-- Calibration identity/versioning (V3 remote studio). The epoch changes whenever this controller is
-- freshly initialized; revision advances exactly once for each accepted calibration mutation.
-- storedRevision is advanced only by the trusted in-game persistence path through markStored (never
-- by a remote browser command). Helpers live as FIELDS on H (not chunk locals) - this chunk sits
-- close to LuaJIT's 200-local ceiling and one more `local` per helper would eventually kill the
-- whole controller at compile time.
local H = {
  epochSerial = 0, epoch = "uninitialized", revision = 0, storedRevision = 0,
  changeSource = "local", mutationSource = "local"
}
-- Fixed manual-clutch hardware packages. Ratings are deliberately absolute, never derived from
-- current engine output: OE never adds more than 2200 Nm, and an extreme build can still overpower
-- the available capacity unless the vehicle genuinely fits stronger hardware. Tier 1 is untouched.
H.clutchPackageRatings = { [1] = 0, [2] = 500, [3] = 800, [4] = 1300, [5] = 2200 }

-- map grid (rows = MAP/load axis, cols = RPM axis)
local NUM_ROWS, NUM_COLS = 6, 7
local rpmAxis = {}
local mapAxis = {}
H.alphaNAxis = {}
-- table definitions: Fuel Main is a 1.0-centered multiplier, ignition is a
-- degree trim (0 = stock), boost is a kPa target, and lambda is a target.
-- V2.9.7: the definitions live in lua/common/oeProTuneShared.lua, required by this controller AND
-- the remote GE extension's typed boundary, so the two validation layers can never drift again.
local mapMeta = require("oeProTuneShared").mapMeta
-- V3.0.7 grid resolution: axis length limits live on H (NOT a chunk local - this file sits at 195 of
-- LuaJIT's 200-local ceiling and a "too many local variables" error means the controller never loads
-- and the ECU simply does not appear on the car). 195 is AST-measured, not grepped: `local a, b`
-- costs two slots and `local function f` costs one but is not a LocalStatement, so both
-- `grep -c '^local '` (194) and the older 193 in this comment undercount. HEADROOM IS 5.
-- H itself is declared further down, so this is
-- assigned next to the other H.* shared-file pulls at the settings block; see H.axisLimits there.
local maps = { fuel = {}, ignition = {}, boost = {}, lambda = {} }

-- tunable settings (mirrors the Assetto tune block)
local settings = {}

-- fuel-type properties: stoich (for AFR display + injector demand), knock
-- resistance (E85's octane lets you run more timing/boost before knock), and a
-- small cooler-charge power bonus. Selectable on the Fuel Setup page.
-- APPEND-ONLY: saved tunes store fuelType by NUMBER, so existing indexes must never move.
-- stoich drives injector demand (14.7/stoich -> methanol needs ~2.3x the injector, like real life),
-- knockResist scales knock potential (lower = more timing/boost headroom), power = charge-cooling /
-- octane bonus, egtCool = how much cooler this fuel runs the exhaust (methanol's high latent heat).
local FUEL_PROPS = {
  -- stoich values VERIFIED (2026-07-01): methanol 6.45 from combustion chemistry (2CH3OH + 3O2, 23.2%
  -- O2-by-mass air -> 6.46); E85 9.81 and E30 12.9 from the same math on ethanol (9.0) volume blends;
  -- C16 14.70 from VP Racing's published spec sheet ((R+M)/2 >118, MON 116, 0% oxygenates, leaded).
  [1] = { name = "Pump Gas",      stoich = 14.7, knockResist = 1.00, power = 1.000, egtCool = 0 },
  [2] = { name = "E85",           stoich = 9.8,  knockResist = 0.55, power = 1.030, egtCool = 30 },
  [3] = { name = "E30",           stoich = 12.9, knockResist = 0.78, power = 1.015, egtCool = 10 },  -- was 12.4 since V1.5 - blend chemistry says 12.9
  [4] = { name = "Race Gas C16",  stoich = 14.7, knockResist = 0.60, power = 1.005, egtCool = 0 },   -- leaded >118-octane drag fuel: big knock headroom, normal fuel volume
  [5] = { name = "Methanol M1",   stoich = 6.45, knockResist = 0.40, power = 1.050, egtCool = 90 }   -- the xFWD staple: most knock-resistant in practice (charge cooling), needs ~2.3x the fuel
}
local function fuelProps() return FUEL_PROPS[settings.fuelType or 1] or FUEL_PROPS[1] end

-- live model state
local model = {
  coef = 1, knockLevel = 0, knockRetard = 0, knockCount = 0, knockTimer = 0,
  -- V3.0.9 valve float: severity 0..1 across the float band, the power multiplier it produces, and the
  -- mechanical wear it leaves behind. floatWear is cumulative and only a vehicle reset clears it.
  floatSeverity = 0, floatPowerCut = 1, floatWear = 0, floatStatus = "OK",
  damage = 0, damageRate = 0, damageReason = "OK", damagePower = 100,
  blown = false,
  iatC = 28, ectC = 85, oilC = 90, egtC = 400,
  afr = 30, smoke = 0, isDiesel = false, dieselStatus = "Off",   -- diesel mode (#4): compression-ignition, tuned to a smoke limit not lambda
  injectorDuty = 0, fuelPressureKpa = 300, fuelDiffPressureKpa = 300, injPulseMs = 0,
  lambdaTarget = 1, lambdaActual = 1, lambdaTrue = 1,
  -- lambdaUncorrected is the sensor-facing mixture BEFORE closed-loop trims. It lets the logger
  -- infer a VE correction from the same information a real ECU/wideband has, without reading trueVE.
  lambdaUncorrected = 1, lambdaBaseTrue = 1, adaptiveFuelTrim = 0,
  stft = 0, ltft = 0, cllStatus = "Off", closedLoop = false,
  boostTargetKpa = 100, boostBaseTargetKpa = 100, boostRequestedTargetKpa = 100,
  boostGearTargetTrimKpa = 0, boostSpeedTargetTrimKpa = 0, boostTargetTrimAppliedKpa = 0,
  boostKpa = 0, boostError = 0, wastegateDuty = 0, boostBaseWastegateDuty = 0,
  boostFeedforwardDuty = 0, boostGearDutyTrimPct = 0, boostSpeedDutyTrimPct = 0,
  boostDutyTrimPct = 0, boostPidDutyTrim = 0, boostStatus = "Idle", boostShapeStatus = "Off",
  boostILimitLo = 0, overboostTimer = 0,
  boostPTermPsi = 0, boostDTermPsi = 0, boostITermPsi = 0, boostBiasPsi = 0,
  boostProtGraceTimer = 0, boostProtGraceKpa = 0,
  boostLimitReason = "Base target", boostSpeedKmh = 0, boostRawSpeedKmh = 0,
  boostSpeedSource = "BeamNG ground speed", boostGear = 0, boostGearValid = false,
  boostHardwareFloorKpa = 100, boostTargetBelowSpring = false,
  wgOffset = 0, boostIntegral = 0, prevBoostPSI = 0,
  ignitionAdvance = 18, effIgnTrim = 0, effFuelTrim = 0, iatTimingTrim = 0,
  misfireStatus = "OK", misfireCount = 0, misfireSeverity = 0, misfireCut = 1,
  misfireTimer = 0, misfireCooldown = 0, leanFaultTimer = 0,
  startupTrim = 0, crankTrim = 0, postStartTrim = 0, warmupTrim = 0,
  transientTrim = 0, tpsDelta = 0, prevTps = 0, accelActive = false, decelCut = false,
  idleStatus = "Off", idleActive = false, idleValvePos = 35, idleError = 0, idleIgnTrim = 0,
  antilagStatus = "Off", antilagHeat = 0, antilagActive = false,
  vvtTarget = 0, vvtActual = 0, vvtStatus = "Off",
  ethrottleTarget = 0, ethrottleStatus = "Cable", ethrottleErrTimer = 0, ethrottleFault = false, ethrottleResp = 1,
  triggerStatus = "OK",
  cylTrimAvg = 0, cylTrimSpread = 0,
  limiterStatus = "Inactive", limiterActive = false, protectionStatus = "Off", launchActive = false, flatShiftActive = false, rollingActive = false,
  faults = {}, faultCount = 0, limpMode = false,
  engineTorqueNm = 0, wheelTorqueNm = 0, enginePowerHp = 0, wheelPowerHp = 0, torqueDelta = 0,
  auxFanOn = false, auxFuelPumpOn = false, auxShiftLightOn = false, auxCheckEngineOn = false,
  rawMapKpa = 101.325, rawTps = 0, mapKpa = 101.325, baroKpa = 101.325,
  mapGaugePsi = 0, vacuumInHg = 0, tps = 0, rpm = 0, load = 0
}

local lastAppliedLimiterRPM = -1
local hardCutOn = false       -- hard-cut rev limiter: latched true while bouncing off the main limit (hysteresis)
local hcLastGear = 0          -- V3.1: previous gear, so a shift can release the latch (see applyLimiter)
local lastWastegateOffset = nil

-- Visual exhaust state is kept separate from the fuel model.  Fractional
-- budgets make Smoke Density linear below each profile's safety ceiling, while
-- the rotating tip keeps dual/quad exhausts from multiplying total smoke.
local smokeFx = {
  acc = { haze = 0, fuel = 0, coal = 0 },
  pending = { fuel = 0, coal = 0 },
  tipCursor = 0, kindCursor = 0, flameTipCursor = 0, flameTipIndex = 1, flameVariant = 0,
  -- Flame jets run from updateGFX, so emitting once per call made density scale
  -- with the player's frame rate.  Keep the clocks on this existing state
  -- table (rather than adding more chunk-level locals; this controller is close
  -- to LuaJIT's 200-local limit) and release sustained licks at a fixed 30 Hz.
  flameInterval = 1 / 30, popFlameTimer = 0, rotFlameTimer = 0, flamePhase = 0,
  nativeSuppressTimer = 0, manualFlameThisFrame = false,
  heat = 0.2, coldHaze = 0, spoolPuff = 0, compPuff = 0, sootLoad = 0,
  engineWasRunning = false, prevFlatShift = false,
  mpSeq = 0, mpSendTimer = 1, mpContinuousTimer = 0, mpEventLife = 0,
  mpRecvSeq = -1, remoteKind = 0, remoteVariant = 0, remoteTimer = 0, remoteInterval = 0, remoteClock = 0
}
function smokeFx.reset()
  smokeFx.acc.haze, smokeFx.acc.fuel, smokeFx.acc.coal = 0, 0, 0
  smokeFx.pending.fuel, smokeFx.pending.coal = 0, 0
  smokeFx.tipCursor, smokeFx.kindCursor = 0, 0
  smokeFx.flameTipCursor, smokeFx.flameTipIndex = 0, 1
  smokeFx.flameVariant, smokeFx.remoteVariant = 0, 0
  smokeFx.coldHaze, smokeFx.spoolPuff, smokeFx.compPuff = 0, 0, 0
  smokeFx.prevFlatShift = false
end

-- dyno: sample net output torque vs rpm during a WOT pull
local dyno = { state = "idle", rows = {}, lastRPM = 0, tqSum = 0, tqCount = 0, auto = false, startGear = 0, startRpm = 0 }
local peakHoldTimer = 0   -- sustained-throttle gate for the Build Card's auto ECU-peak capture

-- ---------------------------------------------------------------------------
-- helpers
-- ---------------------------------------------------------------------------

local function clamp(v, lo, hi)
  if v < lo then return lo end
  if v > hi then return hi end
  return v
end

function H.finiteNumber(v)
  v = tonumber(v)
  if v == nil or v ~= v or v == math.huge or v == -math.huge then return nil end
  return v
end

function H.integerNumber(v)
  v = H.finiteNumber(v)
  if v == nil or v ~= floor(v) then return nil end
  return v
end

-- Backwards-compatible boolean coercion for the existing 0/1 direct API, while also accepting
-- real booleans from the typed remote dispatcher. Non-finite values are rejected, never treated false.
function H.booleanValue(v)
  if type(v) == "boolean" then return v end
  v = H.finiteNumber(v)
  if v == nil then return nil end
  return v >= 0.5
end

-- Return the JBeam part that actually DECLARED this combustion-engine device. BeamNG stamps
-- assembled rows with partPath (0.35+); partOrigin is the deprecated but still widely populated
-- fallback. Unlike torqueCurve/idle/redline/aspiration, this identity does not change when an
-- exhaust, wheel, ECU, turbo, injector or internal-strength part rebuilds the vehicle.
--
-- Keep this on H rather than as another chunk-local helper: this controller is close to LuaJIT's
-- top-level local limit. The array fallbacks also cover older/unprocessed powertrain row shapes.
-- V3.0.3: pick a row's identity, preferring partPath but NOT letting an empty-string partPath
-- shadow a populated partOrigin ("" is truthy in Lua, so `a or b` was the wrong operator here).
-- jbeam's slotOptionReset row writes "" into both fields, so this case is real, not theoretical.
function H.rowPartIdentity(row, pathLabel, originLabel)
  local path, origin = row.partPath, row.partOrigin
  if type(path) == "string" and path ~= "" then return path, pathLabel end
  if type(origin) == "string" and origin ~= "" then return origin, originLabel end
  return nil, nil
end

function H.engineDevicePartIdentity(engineName, engineDevice)
  local wantedName = tostring(engineName or "")
  local rows = v and v.data and v.data.powertrain
  local identity, source = nil, nil
  local function consider(row)
    if type(row) ~= "table" then return end
    local rowName = row.name or row[2]
    local rowType = tostring(row.type or row[1] or ""):lower()
    if tostring(rowName or "") ~= wantedName then return end
    if not rowType:find("combustionengine", 1, true) then return end
    local value, label = H.rowPartIdentity(row, "partPath", "partOrigin")
    -- LAST match wins, matching powertrain.lua's own device construction: it walks the same rows
    -- and the last definition of a device name is the one actually instantiated. Vehicles have a
    -- single mainEngine row, so this is identical to first-wins everywhere except the duplicate
    -- case - where first-wins would have identified a row the game itself discarded.
    if value then identity, source = value, label end
  end
  if type(rows) == "table" then
    -- NEVER use ipairs() here. v.data.powertrain is a SPARSE array: jbeam's tableSchema advances its
    -- row counter for the inline slot-options row (the row that CARRIES partPath) but does not emit
    -- it, so index 1 is a hole on essentially every vehicle and ipairs() stops before it sees a
    -- single row. BeamNG's own powertrain.lua reads this table with pairs()/tableSize() for exactly
    -- that reason. Sorting the numeric keys keeps the walk deterministic - which pairs() alone is
    -- not - while still tolerating the holes.
    local order = {}
    for key in pairs(rows) do
      if type(key) == "number" then order[#order + 1] = key end
    end
    table.sort(order)
    for i = 1, #order do consider(rows[order[i]]) end
    if not identity then
      for key, row in pairs(rows) do
        if type(key) ~= "number" then consider(row) end
      end
    end
  end
  if identity then return identity, source end

  -- NOT A SAFETY NET ON CURRENT BEAMNG - verified dead on 0.38.6. powertrain.lua keeps each
  -- device's merged jbeam table in a module-local `deviceJbeamData` (declared at its line 51,
  -- written at :522) and never exports it or assigns `device.jbeamData`, so this branch cannot
  -- fire for a stock powertrain device. It is kept only for a custom device implementation that
  -- sets the field itself. Treat the powertrain-row walk above as the ONLY real source: when it
  -- fails, identity silently degrades to the old performance fingerprint and the whole bolt-on
  -- fix goes inert, which is exactly the 3.0.3 regression. The init log line reports
  -- source=legacy when that happens - check it after ANY change to the walk.
  local jbeam = engineDevice and engineDevice.jbeamData
  if type(jbeam) == "table" then
    local value, label = H.rowPartIdentity(jbeam, "devicePartPath", "devicePartOrigin")
    if value then return value, label end
  end
  return nil, "legacy"
end

function H.engineIdentityToken(value)
  value = tostring(value or ""):gsub("[^%w%._%-%/]", "_")
  if value == "" then return nil end
  return value:sub(1, 180)
end

function H.calibrationDirty()
  return H.revision ~= H.storedRevision
end

function H.markCalibrationChanged(source)
  H.revision = H.revision + 1
  H.changeSource = source or H.mutationSource or "local"
  stateDirty = true
  return H.revision
end

function H.beginCalibrationEpoch()
  H.epochSerial = H.epochSerial + 1
  local osLib = rawget(_G, "os")
  local wall, clock = 0, 0
  if osLib and osLib.time then
    local ok, value = pcall(osLib.time)
    if ok and type(value) == "number" then wall = value end
  end
  if osLib and osLib.clock then
    local ok, value = pcall(osLib.clock)
    if ok and type(value) == "number" then clock = floor(value * 1000000 + 0.5) end
  end
  local vehId = (obj and obj.getID and obj:getID()) or 0
  H.epoch = table.concat({ tostring(vehId), tostring(wall), tostring(clock), instTag, tostring(H.epochSerial) }, "-")
  H.revision = 0
  H.storedRevision = 0
  H.changeSource = "local"
  H.mutationSource = "local"
  H.mapBankMutationResult = nil
end

function H.markStored(revision, epoch)
  if not active then return false, "inactive" end
  if type(revision) == "table" then
    epoch, revision = revision.epoch or revision.calibrationEpoch, revision.revision or revision.calibrationRevision
  elseif revision ~= nil and tostring(revision) == H.epoch and epoch ~= nil then
    revision, epoch = epoch, revision   -- tolerate epoch/revision argument order from older callers
  end
  if epoch ~= nil and tostring(epoch) ~= H.epoch then return false, "epoch_mismatch" end
  if revision == nil then
    revision = H.revision
  else
    revision = H.integerNumber(revision)
    if revision == nil then return false, "invalid_revision" end
  end
  if revision < 0 or revision > H.revision then return false, "revision_out_of_range" end
  H.storedRevision = revision
  stateDirty = true
  return true
end

local function approach(cur, target, rate, dt)
  local amount = clamp((rate or 0) * (dt or 0), 0, 1)
  return cur + (target - cur) * amount
end

local function boolNum(v) return v and 1 or 0 end

local function ev(key, default)
  local x = electrics.values[key]
  if x == nil then return default end
  return x
end

-- V2.9.7 HOTFIX: boolean-safe "is this channel on" reader for lamp/active electrics. Stock
-- controllers publish some of these as BOOLEANS (esc.lua: escActive = isActing) and others as
-- numbers - ordering a boolean against a number is a FATAL vehicle-Lua error (the Vivace/Sunburst
-- "Error loading vehicle" report). On H, not a chunk local (194/200 budget stays untouched).
function H.evOn(key)
  local v = electrics.values[key]
  if v == true then return true end
  if v == false or v == nil then return false end
  return (tonumber(v) or 0) > 0.5
end

-- V3.0.5 ownership helpers. The transmission and compression-brake fields below are shared BeamNG
-- device state, not private OE variables. Write them only while the matching feature is active and
-- restore the exact fitted/native value once when OE releases ownership.
function H.releaseShiftFirmness()
  if H.shiftFirmnessOwned and H.gearbox and type(base.stockGearChangeTime) == "number"
      and H.gearbox.minimumGearChangeTime ~= nil then
    H.gearbox.minimumGearChangeTime = base.stockGearChangeTime
  end
  H.shiftFirmnessOwned = false
end

function H.updateShiftFirmness()
  if not (H.gearbox and type(base.stockGearChangeTime) == "number"
      and H.gearbox.minimumGearChangeTime ~= nil) then
    H.shiftFirmnessOwned = false
    model.shiftTimeMs = 0
    return
  end
  local firm = clamp(settings.shiftFirmness or 0, 0, 100) / 100
  if firm > 0 then
    H.gearbox.minimumGearChangeTime = base.stockGearChangeTime * (1 - firm * 0.75)
    H.shiftFirmnessOwned = true
  else
    H.releaseShiftFirmness()
  end
  model.shiftTimeMs = (H.gearbox.minimumGearChangeTime or base.stockGearChangeTime) * 1000
end

function H.releaseTorqueLimit()
  if H.torqueLimitOwned and engine and engine.maxTorqueLimit ~= nil then
    local restore = H.torqueLimitRestoreValue
    if restore == nil then restore = H.stockMaxTorqueLimit end
    if restore ~= nil then engine.maxTorqueLimit = restore end
  end
  H.torqueLimitOwned = false
  H.torqueLimitRestoreValue = nil
end

function H.updateTorqueLimit()
  if not (engine and engine.maxTorqueLimit ~= nil) then
    H.torqueLimitOwned = false
    model.torqueLimitStatus = "N/A"
    return
  end
  local limitNm = clamp(settings.torqueLimitNm or 0, 0, 6000)
  local gearNow = floor(ev("gearIndex", 0) or 0)
  local lowGearOnly = settings.torqueLimitLowGears == true
  if limitNm > 0 and (not lowGearOnly or (gearNow >= 1 and gearNow <= 2)) then
    if not H.torqueLimitOwned then H.torqueLimitRestoreValue = engine.maxTorqueLimit end
    engine.maxTorqueLimit = limitNm
    H.torqueLimitOwned = true
    model.torqueLimitStatus = "Limiting " .. floor(limitNm) .. " Nm" .. (lowGearOnly and " (gears 1-2)" or "")
  else
    H.releaseTorqueLimit()
    model.torqueLimitStatus = limitNm > 0 and "Armed (higher gear)" or "Off"
  end
end

-- Manual clutch ownership mirrors the other transmission helpers: OE only writes lockTorque while
-- a fixed package is selected, remembers the exact value it displaced, and restores that value on
-- disconnect/reset/tier 1. BeamNG's own frictionClutch thermals, wear and permanent damage continue
-- to reduce the effective capacity; this changes only the package's finite static torque rating.
function H.setClutchLockTorque(record, value)
  local device = record and record.device
  value = tonumber(value)
  if not (device and value and value > 0) then return end
  if abs((tonumber(device.lockTorque) or 0) - value) <= 0.01 then return end
  device.lockTorque = value
  if device.calculateInertia then pcall(function() device:calculateInertia() end) end
end

function H.releaseClutchPackage()
  for _, record in ipairs(H.clutches or {}) do
    if record.owned and type(record.restoreLockTorque) == "number" then
      H.setClutchLockTorque(record, record.restoreLockTorque)
    end
    record.owned = false
    record.restoreLockTorque = nil
  end
  H.clutchPackageTier = 1
end

function H.updateClutchPackage()
  local tier = floor(clamp(tonumber(settings.clutchUpgrade) or 1, 1, 5))
  local rating = H.clutchPackageRatings[tier] or 0
  model.clutchUpgradeRatingNm = rating
  if tier <= 1 or not H.clutches or #H.clutches == 0 then
    H.releaseClutchPackage()
    return
  end

  for _, record in ipairs(H.clutches) do
    local device = record.device
    if device and type(device.lockTorque) == "number" then
      if not record.owned then record.restoreLockTorque = device.lockTorque end
      -- Never downgrade a clutch already stronger than the selected package. Most importantly, never
      -- derive this target from engine torque: 500/800/1300/2200 Nm remain real finite ceilings.
      local target = max(tonumber(record.restoreLockTorque) or 0, rating)
      H.setClutchLockTorque(record, target)
      record.owned = true
    end
  end
  H.clutchPackageTier = tier
end

function H.updateClutchTelemetry()
  if not H.clutches or #H.clutches == 0 then
    model.clutchFittedCapacityNm, model.clutchStaticCapacityNm = 0, 0
    model.clutchCapacityNm, model.clutchDemandNm, model.clutchHeadroomNm = 0, 0, 0
    model.clutchSlipRpm, model.clutchTempC, model.clutchEfficiencyPct = 0, 0, 0
    model.clutchStatus = "Not fitted (automatic / DCT / CVT)"
    return
  end

  local weakestStatic, weakestEffective = math.huge, math.huge
  local maxSlip, maxTemp, minEfficiency, minEngagement = 0, -273.15, 1, 1
  local permanentlyDamaged, thermalWarning = false, false
  for _, record in ipairs(H.clutches) do
    local device = record.device
    if device then
      local static = max(0, tonumber(device.lockTorque) or 0)
      local thermal = clamp(tonumber(device.thermalEfficiency) or 1, 0, 1)
      local damage = clamp(tonumber(device.damageLockTorqueCoef) or 1, 0, 1)
      local wear = clamp(tonumber(device.wearLockTorqueCoef) or 1, 0, 1)
      weakestStatic = min(weakestStatic, static)
      weakestEffective = min(weakestEffective, static * thermal * damage * wear)
      maxSlip = max(maxSlip, abs((tonumber(device.inputAV) or 0) - (tonumber(device.outputAV1) or 0)) * avToRPM)
      maxTemp = max(maxTemp, tonumber(device.clutchTemperature) or -273.15)
      minEfficiency = min(minEfficiency, thermal * damage * wear)
      minEngagement = min(minEngagement, clamp(tonumber(device.clutchRatio) or 1, 0, 1))
      permanentlyDamaged = permanentlyDamaged or device.clutchPermanentlyDamaged == true
      thermalWarning = thermalWarning or thermal < 0.98
        or (type(device.clutchWarningTemp) == "number" and (tonumber(device.clutchTemperature) or -273.15) >= device.clutchWarningTemp)
    end
  end
  if weakestStatic == math.huge then weakestStatic = 0 end
  if weakestEffective == math.huge then weakestEffective = 0 end

  local demand = abs(tonumber(engine and engine.outputTorque1) or tonumber(model.engineTorqueNm) or 0)
  local headroom = weakestEffective - demand
  local loaded = (model.rawTps or model.tps or 0) >= 20 and minEngagement >= 0.85
  local selectedTier = floor(clamp(tonumber(settings.clutchUpgrade) or 1, 1, 5))
  local selectedRating = H.clutchPackageRatings[selectedTier] or 0
  model.clutchFittedCapacityNm = base.fittedClutchCapacityNm or 0
  model.clutchStaticCapacityNm = weakestStatic
  model.clutchCapacityNm = weakestEffective
  model.clutchDemandNm = demand
  model.clutchHeadroomNm = headroom
  model.clutchSlipRpm = maxSlip
  model.clutchTempC = maxTemp
  model.clutchEfficiencyPct = minEfficiency * 100
  model.clutchUpgradeRatingNm = selectedRating

  if permanentlyDamaged then
    model.clutchStatus = "FAILED - permanently damaged"
  elseif thermalWarning then
    model.clutchStatus = "OVERHEATING - capacity reduced"
  elseif loaded and maxSlip >= 150 and demand >= weakestEffective * 0.95 then
    model.clutchStatus = "SLIPPING - torque exceeds capacity"
  elseif loaded and maxSlip >= 150 then
    model.clutchStatus = "SLIPPING - check heat / engagement"
  elseif minEfficiency < 0.98 then
    model.clutchStatus = "Worn / damaged - capacity reduced"
  elseif minEngagement < 0.85 then
    model.clutchStatus = "Disengaged / pedal in"
  elseif (model.rpm or 0) < 500 then
    model.clutchStatus = "Engine off"
  elseif selectedTier > 1 and (base.fittedClutchCapacityNm or 0) >= selectedRating then
    model.clutchStatus = "Ready - fitted clutch already stronger"
  elseif selectedTier > 1 then
    model.clutchStatus = "Ready - fixed " .. floor(selectedRating) .. " Nm package"
  else
    model.clutchStatus = "Ready - fitted clutch"
  end
end

function H.releaseCompressionBrake()
  if not H.compBrakeOwned then return end
  if engine and engine.setCompressionBrakeCoef then
    local key = ((base and base.engineName) or "mainEngine") .. "_compressionBrake_actual"
    local actual = tonumber(ev(key, 0)) or 0
    if actual ~= actual then actual = 0 end
    pcall(function() engine:setCompressionBrakeCoef(clamp(actual, 0, 1)) end)
  end
  H.compBrakeOwned = false
end

function H.updateCompressionBrake(isDiesel, rpm, sensedTps)
  if not (engine and engine.setCompressionBrakeCoef and base.hasCompBrake) then
    H.releaseCompressionBrake()
    model.compBrakeStatus = base.hasCompBrake and "N/A" or "Not fitted"
    return
  end
  if not isDiesel then
    H.releaseCompressionBrake()
    model.compBrakeStatus = "N/A (petrol)"
    return
  end
  if settings.compBrakeEnabled ~= true then
    H.releaseCompressionBrake()
    local key = (base.engineName or "mainEngine") .. "_compressionBrake_setting"
    local nativeSetting = tonumber(ev(key, 0)) or 0
    model.compBrakeStatus = nativeSetting > 0
      and ("Stock control " .. floor(clamp(nativeSetting, 0, 1) * 100 + 0.5) .. "%") or "Off"
    return
  end

  local level = clamp((settings.compBrakeLevel or 50) / 100, 0, 1)
  local scale = 1
  local curve = engine.compressionBrakeCurve
  if curve and base.baseTorqueNm and base.baseTorqueNm > 1 then
    local curveNm = curve[floor(max(rpm or 0, 0))] or 0
    local capNm = base.baseTorqueNm * 1.2
    if curveNm > capNm then scale = capNm / curveNm end
  end
  local want = level * scale
  local ok = pcall(function() engine:setCompressionBrakeCoef(want) end)
  H.compBrakeOwned = ok == true
  model.compBrakeStatus = not ok and "Unavailable"
    or ((sensedTps or 0) < 1 and (rpm or 0) > (base.idleRPM or 800) + 200
      and ("Braking " .. floor(want * 100 + 0.5) .. "%") or "Armed")
end

function H.engineTorqueAtRpm(rpm)
  local curve = engine and engine.torqueCurve
  local fallback = max(1, (base and base.baseTorqueNm) or 300)
  if type(curve) ~= "table" then return fallback end
  local upper = tonumber(engine.maxAvailableRPM) or tonumber(base.maxRPM) or 10000
  if upper < 500 then upper = 10000 end
  local query = floor(clamp(tonumber(rpm) or (base.idleRPM or 800), 0, upper) + 0.5)
  local torque = tonumber(curve[query])
  if torque and torque > 0 then return torque end
  -- BeamNG's createCurve tables are normally integer-dense. The short search is a safe fallback for
  -- custom engines that publish sparse points without turning a missing cell back into peak torque.
  for offset = 1, 500 do
    local below = tonumber(curve[query - offset])
    if below and below > 0 then return below end
    local above = tonumber(curve[query + offset])
    if above and above > 0 then return above end
  end
  return fallback
end

function H.converterStallForCoef(coef, initialRpm)
  coef = tonumber(coef)
  if not (H.converter and coef and coef > 0) then return 0 end
  local stiff = tonumber(H.converter.converterStiffness) or 10
  local kf = 0.004 * stiff / (1 + stiff)
  if kf <= 0 then return 0 end
  local lower = max(500, tonumber(base.idleRPM) or 800)
  local upper = tonumber(engine and engine.maxAvailableRPM) or tonumber(base.revLimitCeilingRPM)
    or tonumber(base.maxRPM) or 10000
  upper = max(lower, min(10000, upper))
  local solved = clamp(tonumber(initialRpm) or 2000, lower, upper)
  for _ = 1, 12 do
    local torque = H.engineTorqueAtRpm(solved)
    local nextRpm = clamp(sqrt(max(1, torque) / (kf * coef)) / rpmToAV, lower, upper)
    if abs(nextRpm - solved) < 0.25 then solved = nextRpm; break end
    solved = (solved + nextRpm) * 0.5
  end
  return solved
end

function H.releaseConverterKFactor()
  if H.converterKFactorOwned and H.converter and type(base.stockKFactorCoef) == "number" then
    H.converter.kFactorCoef = base.stockKFactorCoef
  end
  H.converterKFactorOwned = false
  H.converterStallActive = false
end

function H.updateConverterStall()
  if not (H.converter and type(base.stockKFactorCoef) == "number") then
    H.converterKFactorOwned = false
    H.converterStallActive = false
    model.converterStall = 0
    return
  end
  local wantStall = clamp(settings.converterStall or 0, 0, 8000)
  if wantStall >= 1200 then
    local stiff = tonumber(H.converter.converterStiffness) or 10
    local kf = 0.004 * stiff / (1 + stiff)
    local upper = tonumber(engine and engine.maxAvailableRPM) or tonumber(base.revLimitCeilingRPM)
      or tonumber(base.maxRPM) or 10000
    local lower = max(500, tonumber(base.idleRPM) or 800)
    local targetRpm = clamp(wantStall, lower, max(lower, min(10000, upper)))
    local targetAV = targetRpm * rpmToAV
    local torqueAtTarget = H.engineTorqueAtRpm(targetRpm)
    local requestedCoef = torqueAtTarget / max(1e-9, kf * targetAV * targetAV)
    H.converter.kFactorCoef = clamp(requestedCoef, base.stockKFactorCoef * 0.02, base.stockKFactorCoef * 4)
    H.converterKFactorOwned = true
    H.converterStallActive = true
    model.converterStall = H.converterStallForCoef(H.converter.kFactorCoef, targetRpm)
  else
    H.releaseConverterKFactor()
    model.converterStall = H.converterStallForCoef(base.stockKFactorCoef, 2000)
  end
end

function H.releaseConverterLockup()
  if H.converterLockupOwned and H.converter and base.stockLockupTorque ~= nil then
    H.converter.lockupClutchTorque = base.stockLockupTorque
  end
  H.converterLockupOwned = false
  model.transFadePct = 0
end

function H.updateConverterLockupFade()
  if not (H.converterStallActive and H.converter and type(base.stockLockupTorque) == "number") then
    H.releaseConverterLockup()
    return
  end
  local fade = clamp(1 - max(0, (model.transTempC or 40) - 120) / 60 * 0.7, 0.3, 1)
  H.converter.lockupClutchTorque = base.stockLockupTorque * fade
  H.converterLockupOwned = true
  model.transFadePct = (1 - fade) * 100
end

local function hasTurbo()
  if engine and engine.turbocharger and engine.turbocharger.isExisting then return true end
  if engine and engine.supercharger and engine.supercharger.isExisting then return true end   -- blowers count as forced induction (boost/fuel/knock tuning applies)
  -- electric fallback uses the game's COMBINED channels ("boost" = turboBoost + superchargerBoost,
  -- electrics.lua:431) so custom-Lua turbos AND blowers both register
  return (ev("boostMax", 0) or 0) > 0 or (ev("boost", 0) or 0) > 0.1
end

-- diesel mode (#4): compression ignition, no spark/lambda/gas-knock, tuned to a SMOKE LIMIT.
-- base.isDiesel is auto-detected at init from the engine device's required energy type (authoritative
-- for vanilla BeamNG diesels); dieselOverride is the UI's name-based detection for modded diesels that
-- don't tag the energy type. Either one true = diesel.
local function effDiesel() return base.isDiesel == true or dieselOverride == true end

-- ---------------------------------------------------------------------------
-- map tables
-- ---------------------------------------------------------------------------

-- ---------------------------------------------------------------------------
-- V3.0.7 factory axis generators (selectable grid resolution)
--
-- These replace the hardcoded 6-row / 4-step loops buildAxes used to carry inline. The HARD
-- requirement on all three is that at the factory grid (6 rows, 7 cols) they reproduce the old
-- literals EXACTLY, for every topKpa - that identity is the entire reason existing tunes are
-- untouched by this release. Verified across rows 4..20 and topKpa 220..1000: identical at 6, and
-- strictly increasing everywhere (R.setAxisValues rejects a non-increasing axis, and a factory
-- reset that produced one would brick the ECU rather than degrade it).
--
-- Fields on H, never chunk locals: this file sits at 193 of LuaJIT's 200-local ceiling.
-- ---------------------------------------------------------------------------

-- RPM: unchanged behaviour, just lifted out of buildAxes. Linear from idle to the fitted top,
-- rounded to 100 rpm. Already scaled with NUM_COLS before this release, so nothing to preserve.
-- NOTE (deferred): real tuning software clusters RPM breakpoints around idle and cruise and spreads
-- them up top. That is a better axis, but changing the SPACING would move every existing tune's
-- factory breakpoints, so it is deliberately not bundled with the resolution change.
function H.factoryRpmAxis(cols, lo, hi)
  -- REVIEW FIX 2026-08-04. Two defects, one cause: this rounded to 100 rpm unconditionally and,
  -- unlike H.factoryMapAxis, never enforced strict increase. On an engine whose whole rev range is
  -- narrower than ~1500 rpm - a low-rev industrial diesel - 16 columns put the breakpoints 93 rpm
  -- apart and the rounding collapsed neighbours: ..., 1400, 1500, 1500, 1600, ...
  -- That axis is invalid. R.setAxisValues rejects a non-increasing axis, but buildAxes writes
  -- rpmAxis directly without passing that gate, so it would have been adopted live and then failed
  -- on reload. Every engine tested (800-3500 up to 900-7900) has spacing well over 100 and is
  -- byte-identical either way; only the broken case moves.
  local step = ((hi - lo) / max(1, cols - 1)) < 100 and 10 or 100
  local out = {}
  for c = 1, cols do
    out[c] = floor((lo + (hi - lo) * (c - 1) / (cols - 1)) / step + 0.5) * step
  end
  return H.ensureIncreasing(out)
end

-- MAP rows. The old turbo layout was two fixed vacuum anchors {20, 60} plus four rows spanning
-- 100 kPa to the top target. Scaling that naively would put every added row in boost and none in
-- the idle/cruise region where the Mixture Map and the fuel lessons actually operate, so the
-- vacuum share is held at ~1/3 of the rows instead: 6 rows -> 2 vacuum (the old literal), 16 rows
-- -> 5. Boost rows are rounded to 5 kPa as before; topKpa is clamped >= 220 upstream, so the
-- smallest possible step is ~8.5 kPa and the rounding cannot collide.
function H.factoryMapAxis(rows, topKpa, hasTurbo)
  local out = {}
  if hasTurbo then
    local vacRows = max(2, floor(rows / 3 + 0.5))
    local boostRows = rows - vacRows
    for i = 0, vacRows - 1 do
      out[#out + 1] = floor(20 + (60 - 20) * i / (vacRows - 1) + 0.5)
    end
    for i = 0, boostRows - 1 do
      out[#out + 1] = floor((100 + (topKpa - 100) * i / (boostRows - 1)) / 5 + 0.5) * 5
    end
  else
    -- NA: the old literal {20, 35, 50, 65, 80, 100} is linear 20->80 over the first (rows-1) points
    -- with atmospheric appended, NOT linear 20->100 - so it is generated that way. Rounded to whole
    -- kPa rather than to 5, because 5 kPa steps collide once the 60 kPa span carries >12 rows.
    for i = 0, rows - 2 do
      out[#out + 1] = floor(20 + (80 - 20) * i / (rows - 2) + 0.5)
    end
    out[#out + 1] = 100
  end
  return H.ensureIncreasing(out)
end

-- Alpha-N TPS breakpoints: index-resample the canonical curve to the row count. Identical maths to
-- H.resampleAxisByIndex (which handles the refit path); both read H.ALPHA_FACTORY so the two can
-- never disagree about what "factory Alpha-N" means.
H.ALPHA_FACTORY = { 0, 2, 5, 12, 30, 100 }
function H.factoryAlphaAxis(rows)
  local src = H.ALPHA_FACTORY
  local out = {}
  for r = 1, rows do
    local pos = 1 + (r - 1) * (#src - 1) / max(1, rows - 1)
    local i = floor(pos)
    local j = min(#src, i + 1)
    local f = pos - i
    out[r] = src[i] + (src[j] - src[i]) * f
  end
  return out
end

-- Last-resort monotonicity guard. The generators above are proven strictly increasing over the
-- supported range; this exists so that a future cap raise, a new rounding step or an unusual
-- topKpa can never hand R.setAxisValues an axis it will reject - which on the factory-reset path
-- would leave the car with no usable calibration at all.
function H.ensureIncreasing(axis)
  for i = 2, #axis do
    if axis[i] <= axis[i - 1] then axis[i] = axis[i - 1] + 1 end
  end
  return axis
end

local function buildAxes()
  rpmAxis = {}
  local lo = max(800, (base.idleRPM or 800))
  -- V3.0 (Liqurdz): fit the RPM axis to the RAISED rev limit too, not just the stock limiter,
  -- so a higher rev target is covered without opening Axis Setup by hand. Capped at the car's
  -- mechanical ceiling. settings/revLimit are nil at first init -> falls back to the stock range.
  local hi = max(lo + 1000, base.revLimiterRPM or 7000, settings.revLimit or 0)
  hi = min(hi, base.revLimitCeilingRPM or hi)
  rpmAxis = H.factoryRpmAxis(NUM_COLS, lo, hi)
  -- MAP axis adapts to induction type AND, for turbo cars, to the car's REAL max boost so a
  -- high-boost build can tune its whole range (the old fixed 220 kPa ceiling pinned a 32 psi car to
  -- one top row). V3.0.7: roughly the first third of the rows stay in vacuum and the rest span
  -- atmospheric (100) up to the top target - at the factory 6 rows that is the original 2 + 4 split.
  -- V3.0: also fit to the highest BOOST TARGET, not just the learned max boost, so the cells reach
  -- what you're tuning for. maps.boost is empty at first init -> falls back to the learned max.
  if base.hasTurbo then
    local btTop = 0
    if maps.boost then
      for r = 1, #maps.boost do local row = maps.boost[r]; for c = 1, #row do if row[c] > btTop then btTop = row[c] end end end
    end
    -- V2.9 fix: chasing the boost table IS the auto-fit feature - with 'autoFitAxes' OFF, Reset Axes
    -- must hand back the true factory breakpoints, not an axis refitted to the tune's targets.
    if settings.autoFitAxes == false then btTop = 0 end
    local topKpa = clamp(max(220, base.turboMaxKpa or 0, btTop), 220, mapMeta.boost.max)   -- V3.0: ceiling follows the shared meta (1000 kPa, was a hardcoded 600)
    -- New factory calibrations cover the modeled closed-throttle vacuum region.
    -- Saved tunes keep their own axes, including the legacy 40/80 kPa anchors.
    mapAxis = H.factoryMapAxis(NUM_ROWS, topKpa, true)
  else
    mapAxis = H.factoryMapAxis(NUM_ROWS, 100, false)
  end
  -- Alpha-N uses the same table row count but its own TPS breakpoints. Dense
  -- resolution near closed throttle makes idle/cruise tuning useful while the
  -- last rows retain room for high-load and WOT calibration.
  H.alphaNAxis = H.factoryAlphaAxis(#mapAxis)
  NUM_ROWS = #mapAxis
  NUM_COLS = #rpmAxis
end

-- factory tune: zero fuel/ign trims, mild boost target, safe lambda
-- Seed the boost target from the factory turbo's OWN max boost (base.turboMaxKpa, read at init from
-- the turbo device) so a stock config makes the boost it was BUILT for instead of being capped to a
-- fixed default. Was hardcoded ~185 kPa (~12 psi), so a 20 psi config loaded a 12 psi target and the
-- wastegate PID actively held the car down to 12. Safe fallback if the turbo max wasn't read.
local function stockBoostTarget()
  local fb = (base and base.turboMaxKpa) or 0
  if fb < 110 then fb = 185 end   -- legacy 100 kPa reference + ~12 psi fallback
  return fb
end

-- TRUE volumetric efficiency of THIS engine (V2.8 speed density). Synthesized from the engine's own
-- torque curve NORMALIZED - torque shape is a physically legitimate VE proxy, so every engine
-- genuinely breathes differently with zero hand-authoring - plus a mild boost lift (dynamic charge
-- effects raise effective VE under pressure). This is the hidden "reality" the player's VE Table is
-- a MODEL of: wherever their table disagrees with this curve, lambda drifts off target exactly like
-- a real speed-density ECU running a wrong VE cell. Tuning VE until lambda tracks target everywhere
-- IS the real craft (PCLink Modelled fuel workflow).
local function trueVE(rpm, mapKpa)
  local shape = 0.85
  if engine and type(engine.torqueCurve) == "table" and (base.baseTorqueNm or 0) > 1 then
    local tq = engine.torqueCurve[floor(clamp(rpm or 800, 100, 20000))]
    if type(tq) == "number" and tq > 0 then shape = clamp(tq / base.baseTorqueNm, 0.30, 1.0) end
  end
  local ve = 62 + 38 * shape                                             -- NA band: ~62% off-cam to ~100% at the torque peak
  ve = ve * (1 + clamp(((mapKpa or (model.baroKpa or 101.325)) - (model.baroKpa or 101.325)) / 100, 0, 2) * 0.05) -- boost: up to ~+10% effective VE at 2 bar over
  return clamp(ve, 30, 130)
end

local function defaultCell(mapName, rpm, mapKpa)
  if mapName == "fuel" then
    if mapKpa >= 260 then return 1.06 end
    if mapKpa >= 180 then return 1.04 end
    if mapKpa >= 140 then return 1.02 end
    return 1.0
  elseif mapName == "ignition" then
    -- pull timing under boost so a stock turbo config isn't knock-limited out of the box. The steps
    -- above 220 kPa (~18 psi) keep a high-boost factory build (e.g. 32 psi) safe out of the box -
    -- conservative, not optimal, so the player tunes power back IN on good fuel.
    if mapKpa >= 300 then return -12 end
    if mapKpa >= 260 then return -10 end
    if mapKpa >= 220 then return -8 end
    if mapKpa >= 200 then return -6 end
    if mapKpa >= 160 then return -4 end
    if mapKpa >= 120 then return -2 end
    return 0
  elseif mapName == "boost" then
    if not base.hasTurbo then return 100 end
    return stockBoostTarget()
  elseif mapName == "wgduty" then
    return 50   -- neutral: the Base duty slider alone decides, exactly pre-2.8 behavior
  elseif mapName == "ve" then
    -- GOLDEN RULE: the VE Table ships MATCHED to the engine's true VE, so speed density changes
    -- nothing for existing tunes or anyone who ignores the page - imperfection is opt-in (edit it,
    -- or a lesson mis-seeds it on purpose). Old saves/codes without a 've' map get this same
    -- matched seed on load.
    return trueVE(rpm, mapKpa)
  else -- lambda
    -- richen progressively with boost; the rich charge cools combustion and resists knock, so a
    -- high-boost factory build runs as safe as the fuel allows before you lean it out for power.
    if mapKpa >= 280 then return 0.76 end
    if mapKpa >= 220 then return 0.80 end
    if mapKpa >= 180 then return 0.82 end
    if mapKpa >= 140 then return 0.88 end
    if mapKpa >= 100 then return 0.90 end   -- WOT / low-boost: 0.90 (was 0.95, read a touch lean on stock NA)
    return 1.0
  end
end

-- true while the Boost Target table is still the untouched factory seed. While factory, the closed
-- loop does NOT chase the table (the wastegate keeps its stock spring behavior) and learnBoost may
-- repaint the seed to the car's real boost. The flag drops the moment the PLAYER tunes boost: a cell
-- edit that actually changes a value, a non-stock preset, or a loaded tune. V2.6 fix for BOTH
-- community boost bugs (Liqurdz's big-turbo ~25psi floor "no matter what's in the boost table" +
-- Orlando's stock-Vivace 185->279 target runaway w/ Overboost+LimpMode + black smoke): both came
-- from the ECU chasing / rewriting auto-seeded targets the player never asked for.
local boostTableFactory = true

local function buildMaps()
  for name, meta in pairs(mapMeta) do
    maps[name] = {}
    for r = 1, NUM_ROWS do
      maps[name][r] = {}
      for c = 1, NUM_COLS do
        -- REVIEW FIX 2026-07-27: seed from the row's own load meaning (see H.mapSeedLoad), not from
        -- the pressure axis, so Alpha-N rows are not handed the boost rows' calibration.
        maps[name][r][c] = clamp(defaultCell(name, rpmAxis[c], H.mapSeedLoad(name, r)), meta.min, meta.max)
      end
    end
  end
  boostTableFactory = true
end

-- Learn the car's REAL max boost from live MAP. Some mods (custom/ALS boost controllers, boost-by-gear,
-- "variable boost") never report their max boost at init, so base.turboMaxKpa fell back to ~185 kPa while
-- the car actually makes far more -> the factory boost target sits way under actual -> a PERMANENT phantom
-- overboost -> (with protection on) limp mode, so the car barely runs. Grow the reference to the observed
-- peak; if the boost target table is still a FLAT factory seed (player hasn't shaped a boost curve),
-- re-seed it to match the real boost so OE ProTune stops fighting a car running its own factory boost.
-- Once the player tunes a boost curve it's no longer flat, so their target is respected (overboost real).
local boostLearnLevel = 0   -- lowest MAP held during the current above-reference streak (the SUSTAINED level, not the spike peak)
local boostLearnTimer = 0   -- how long MAP has been continuously above the current reference

local function learnBoost(mapKpa, dt)
  if not (base.hasTurbo and maps.boost and maps.boost[1]) then return end
  -- Hardware ceiling fields intentionally retain their legacy 100 kPa reference.
  -- Compare gauge boost so a climb/descent cannot look like a turbo change.
  local gaugeKpa = max(0, mapKpa - (model.baroKpa or 101.325))
  local refGaugeKpa = max(0, (base.turboMaxKpa or 100) - 100)
  if gaugeKpa <= refGaugeKpa then
    boostLearnTimer = 0
    return
  end
  -- SUSTAINED-ONLY learning: a single-frame spike (wastegate overshoot, gear-change load blip, sensor
  -- noise) must not become the car's "factory max boost" - that let the reference (and a still-flat
  -- factory boost table) ratchet upward on every overshoot, which also made the overboost fault
  -- impossible to trip on an untuned table (the target rose to meet the spike in the same frame).
  -- Require the boost to HOLD above the reference for 0.5s and learn the LOWEST level held during that
  -- streak (the genuinely sustained boost, not the spike peak). The unread-boost cars this learner
  -- exists for (custom/ALS controllers) hold real boost for whole pulls, so they still learn fine.
  if boostLearnTimer <= 0 then boostLearnLevel = gaugeKpa else boostLearnLevel = min(boostLearnLevel, gaugeKpa) end
  boostLearnTimer = boostLearnTimer + (dt or 0)
  if boostLearnTimer < 0.5 then return end
  boostLearnTimer = 0   -- streak consumed; boost still climbing keeps learning upward in 0.5s steps
  local learned = 100 + boostLearnLevel
  base.turboMaxKpa = learned
  -- only unread-boost cars let the CONTROLLER reference follow the learned peak; read cars keep the init
  -- read frozen so base duty <-> boost stays calibrated like V2 (see init: base.turboRefKpa / turboMaxRead)
  if not base.turboMaxRead then base.turboRefKpa = learned end
  -- Re-seed ONLY the untouched factory table (V2.6): this loop used to fire on ANY flat table -
  -- including a flat low table the player typed for baseline pulls - silently overwriting their tune
  -- and, worse, feeding back on itself (learn -> re-seed -> chase -> overshoot -> learn: the Vivace
  -- 185->279 runaway). A factory table is never CHASED by the closed loop (see the passthrough in
  -- updateBoostController), so this repaint is now display-only: the table the player starts editing
  -- from reflects the boost the car really makes. The flat check stays as a belt.
  if not boostTableFactory then return end
  -- Target shaping is an explicit request to control the stock boost baseline. Keep learning the
  -- hardware ceiling above, but do not repaint that baseline while gear/speed control is active:
  -- otherwise a held-low target can learn its own overshoot and ratchet the table upward.
  if floor(settings.boostShapeMode or 1) ~= 1 or floor(settings.boostMode or 2) == 3 then return end
  local v0 = maps.boost[1][1]
  for r = 1, NUM_ROWS do
    for c = 1, NUM_COLS do
      if abs((maps.boost[r][c] or v0) - v0) > 0.5 then return end   -- shaped boost curve: leave it alone
    end
  end
  if learned > v0 + 10 then   -- flat factory target sits well below the boost the car makes -> re-seed flat to match
    local activeFactoryBank = H.mapBanks and H.mapBanks[H.activeMapBank]
    local keepFactoryBankClean = not H.workingDirty and activeFactoryBank and activeFactoryBank.boostTableFactory == true
      and H.mapBankCandidateMatchesWorking and H.mapBankCandidateMatchesWorking(activeFactoryBank)
    local tgt = clamp(stockBoostTarget(), mapMeta.boost.min, mapMeta.boost.max)
    for r = 1, NUM_ROWS do for c = 1, NUM_COLS do maps.boost[r][c] = tgt end end
    -- This is an automatic display-only repaint of the untouched factory seed, not a player edit.
    -- When the working copy was clean, repaint the independent active factory-bank body too so the
    -- clean-bank invariant remains true and future hotkeys stay available. A pre-existing real draft
    -- remains dirty, but its already-queued discard/switch is still never cancelled by this learner.
    if keepFactoryBankClean then
      for r = 1, NUM_ROWS do
        for c = 1, NUM_COLS do activeFactoryBank.maps.boost[r][c] = tgt end
      end
      H.mapBankSequence = H.mapBankSequence + 1
      stateDirty = true
    else
      H.markWorkingChanged(true)
    end
    H.markCalibrationChanged()
  end
end

local function nearestIndex(axis, value)
  local bestI, bestD = 1, math.huge
  for i = 1, #axis do
    local d = abs((value or 0) - axis[i])
    if d < bestD then bestI, bestD = i, d end
  end
  return bestI
end

function H.mapUsesAlphaN(mapName)
  if effDiesel() or floor(settings.loadSource or 1) ~= 2 then return false end
  return mapName == "fuel" or mapName == "ignition" or mapName == "lambda" or mapName == "ve"
end

function H.mapLoadAxis(mapName)
  return H.mapUsesAlphaN(mapName) and H.alphaNAxis or mapAxis
end

function H.mapLoadValue(mapName, mapKpa, tps)
  if H.mapUsesAlphaN(mapName) then return clamp(tps or model.tps or 0, 0, 100) end
  return mapKpa
end

-- REVIEW FIX 2026-07-27. The load value a FACTORY SEED should use for row r of a given table.
-- Every seeding site (buildMaps, the per-table Reset, every preset) used to hand defaultCell the
-- pressure breakpoint mapAxis[r] unconditionally. In Alpha-N the rows of Fuel/Ignition/Lambda/VE are
-- THROTTLE-ANGLE sites, so on a turbo car that wrote the boost rows' values onto light-throttle rows:
-- a freshly reset Lambda table asked for ~12:1 AFR and 4 deg of retard at a 30% cruise.
--
-- Instead, ramp each throttle breakpoint across the SAME pressure span the MAP axis covers, from the
-- closed-plate vacuum anchor to whatever load this car can actually reach. Two properties matter and
-- both are load-bearing:
--
--   * the row fraction is normalised against the axis's own top breakpoint, so the highest-throttle
--     row always lands exactly on mapAxis[NUM_ROWS] - the WOT row keeps the boost-safe lambda, retard
--     and enrichment defaultCell and the presets author for a boosted engine. An earlier revision of
--     this fix capped the seed at barometric on the theory that "throttle angle cannot see boost, so
--     the seed should be boost-free". That is the honest answer about what the SENSOR sees and the
--     unsafe answer about what the CYLINDER experiences: it shipped 0.90 lambda with zero retard at
--     full boost, which the mod's own knock model puts over the knock threshold before the player
--     touches anything. A TPS row on a boosted engine must be seeded for the load that row can
--     actually see. It also restores trueVE's boost term at WOT, so the "VE table ships MATCHED to
--     the engine" golden rule holds at the row every WOT sample lands on.
--   * it is deterministic - anchored to the fixed MAP breakpoints, never to live barometric pressure -
--     so the same Reset produces the same table at any altitude, exactly like MAP mode.
--
-- The intermediate rows still map to the vacuum region (30% TPS -> ~80 kPa on a stock turbo), which is
-- the part the original bug got wrong, so a light-throttle cruise is no longer seeded from the boost
-- rows. Linear is a deliberate simplification of a nonlinear plate/airflow relationship; it is
-- monotonic, and the two endpoints are exact.
-- Boost Target and WG Duty are MAP-indexed in either mode, so they always get mapAxis[r].
function H.mapSeedLoad(mapName, r)
  if not H.mapUsesAlphaN(mapName) then return mapAxis[r] end
  local lo = mapAxis[1] or 20
  local hi = mapAxis[NUM_ROWS] or 100
  local top = H.alphaNAxis[NUM_ROWS] or 100
  return lo + (hi - lo) * clamp((H.alphaNAxis[r] or 0) / max(1, top), 0, 1)
end

local function lookupMap(mapName, rpm, mapKpa, tps)
  -- Bilinear over RPM (columns) and the selected load source (rows).
  -- Boost Target/WG Duty always remain MAP indexed, even in Alpha-N mode.
  local m = maps[mapName]
  local rowAxis = H.mapLoadAxis(mapName)
  local rowValue = H.mapLoadValue(mapName, mapKpa, tps)
  local c = 1
  while c < NUM_COLS - 1 and rpm > rpmAxis[c + 1] do c = c + 1 end
  local cDen = max(1, rpmAxis[c + 1] - rpmAxis[c])
  local cF = clamp((rpm - rpmAxis[c]) / cDen, 0, 1)
  local r = 1
  while r < NUM_ROWS - 1 and rowValue > rowAxis[r + 1] do r = r + 1 end
  local rDen = max(0.000001, rowAxis[r + 1] - rowAxis[r])
  local rF = clamp((rowValue - rowAxis[r]) / rDen, 0, 1)
  local top = m[r][c] + (m[r][c + 1] - m[r][c]) * cF
  local bot = m[r + 1][c] + (m[r + 1][c + 1] - m[r + 1][c]) * cF
  return top + (bot - top) * rF
end

local function liveCell(mapName)
  mapName = mapName or "fuel"
  return nearestIndex(H.mapLoadAxis(mapName), H.mapLoadValue(mapName, model.mapKpa, model.tps)), nearestIndex(rpmAxis, model.rpm)
end

-- ---------------------------------------------------------------------------
-- the ECU model (ported from the Assetto oep_ecu_adapter)
-- ---------------------------------------------------------------------------

local function updateThermal(dt, rpm, tps, boostKpa, load)
  -- read the REAL engine temps from BeamNG; simulate intake air temp
  model.ectC = ev("watertemp", model.ectC) or model.ectC
  model.oilC = ev("oiltemp", model.oilC) or model.oilC
  local speedKmh = (ev("wheelspeed", 0) or 0) * 3.6
  local iatTarget = 28 + boostKpa * 0.11 + load * 7 + max(0, rpm - 5500) * 0.0015 - min(speedKmh, 180) * 0.025
  model.iatC = approach(model.iatC, clamp(iatTarget, 14, 110), tps > 35 and 0.75 or 0.22, dt)
  local sensedEct = model.ectC + (settings.sensorEctBias or 0)
  model.auxFanOn = sensedEct >= (settings.auxFanOnTemp or 96)
end

local function updateStartupFuel(rpm, coolant)
  local endTemp = settings.warmupEndTemp or 70
  local coldSpan = max(1, endTemp - 20)
  local coldFactor = clamp((endTemp - (coolant or model.ectC)) / coldSpan, 0, 1)
  model.crankTrim = (rpm < 650 and simTime < 20) and (settings.crankEnrich or 25) or 0
  model.postStartTrim = max(0, (settings.postStartEnrich or 8) * (1 - simTime / max(1, settings.postStartDecay or 12)))
  model.warmupTrim = (settings.warmupEnrich or 10) * coldFactor
  model.startupTrim = model.crankTrim + model.postStartTrim + model.warmupTrim
  return model.startupTrim
end

local accelHoldTime = 0   -- how long the accel enrichment has been continuously active (spool taper)
local function updateTransientFuel(dt, tps, rpm)
  local safeDt = max(0.001, dt)
  model.tpsDelta = (tps - model.prevTps) / safeDt
  model.prevTps = tps
  -- Enrich from throttle rate OR rising load/boost, so a sustained tip-in and
  -- (on turbos) the SPOOL get fuel too - not just the instant the pedal moves.
  -- This is the on-throttle transient a tuner couldn't reach before.
  local mapNow = model.mapKpa or 100
  local mapDelta = (mapNow - (model.prevMap or mapNow)) / safeDt   -- kPa/s, MAP climbing
  model.prevMap = mapNow
  local tpsAccel = clamp(max(0, model.tpsDelta) / 260, 0, 1)
  -- load-rate enrichment is for on-throttle spool only; gate it behind real throttle
  -- so idle/coast MAP wobble doesn't dump fuel and make lambda flicker rich
  local loadAccel = (tps > 12) and clamp(max(0, mapDelta) / 220, 0, 1) or 0
  local accelAmount = max(tpsAccel, loadAccel) * (settings.accelEnrich or 7)
  local wetting = clamp(settings.wallWetting or 0.35, 0, 1)
  model.accelActive = accelAmount > 0.2
  model.decelCut = (settings.decelFuelCut == true) and tps < 2.5 and rpm > 1800 and model.tpsDelta < -18
  if model.decelCut then
    accelHoldTime = max(0, accelHoldTime - safeDt * 2)
    -- tunable lift-off fuel amount (negative = cut, less negative = softer cut)
    model.transientTrim = approach(model.transientTrim, settings.decelFuel or -28, 24, safeDt)
  elseif model.accelActive then
    -- SUSTAINED-SPOOL TAPER (V2.6): the accel shot is a SHOT, not a faucet. On a turbo, MAP climbs
    -- for the WHOLE spool, which held this enrichment through entire pulls and parked actual lambda
    -- ~0.07-0.09 under target (log-proven: stock-config pull sat at 0.71 vs an 0.80 target = black
    -- smoke + "why is my stock car rich"). Full shot on tip-in, fades over ~1.2s of continuous
    -- enrichment; the tunable wall-wetting tail then decays exactly as before.
    accelHoldTime = accelHoldTime + safeDt
    model.transientTrim = max(model.transientTrim * wetting, accelAmount * clamp(1 - accelHoldTime / 1.2, 0, 1))
  else
    -- recover the shot budget quickly but not instantly, so MAP-rate flicker mid-pull can't re-arm it
    accelHoldTime = max(0, accelHoldTime - safeDt * 2)
    model.transientTrim = approach(model.transientTrim, 0, max(0.05, settings.accelDecay or 0.55) * 18, safeDt)
  end
  return model.transientTrim
end

local function updateFuelModel(dt, rpm, tps, boostKpa, load, lambdaTarget, fuelTrim, extraTrim, sensedEct, sensedMap)
  local equationMode = settings.fuelEquationMode or 1
  local masterFuel = clamp(settings.masterFuel or 8, 3, 20)
  local injFlow = clamp(settings.injectorFlow or 600, 200, 6000)
  local injDead = clamp(settings.injectorDeadtime or 1, 0.2, 2.5)

  -- closed loop lambda gating per mode + lockouts
  local cllMode = settings.cllMode or 2
  local cllAllowed = sensedEct >= (settings.cllEctLockout or 60)
    and tps < (settings.cllTpsLockout or 60)
    and sensedMap < (settings.cllMapLockout or 150)
    and rpm > 1200
  local wasClosedLoop = model.closedLoop
  model.closedLoop = false
  if cllMode == 1 then
    model.cllStatus = "Off"
  elseif not cllAllowed then
    if sensedEct < (settings.cllEctLockout or 60) then model.cllStatus = "Lockout: ECT"
    elseif tps >= (settings.cllTpsLockout or 60) then model.cllStatus = "Lockout: TPS"
    elseif sensedMap >= (settings.cllMapLockout or 150) then model.cllStatus = "Lockout: MAP"
    else model.cllStatus = "Lockout: RPM" end
  elseif cllMode == 2 and lambdaTarget < 0.98 then
    model.cllStatus = "Target rich, holding"
  else
    model.closedLoop = true
    model.cllStatus = "Active"
  end

  local iatLean = max(0, model.iatC - 38) * 0.0011
  -- V2.8 launch fuel (real launch-fuel behavior): extra enrichment while a limiter event owns the
  -- engine - 2-step, rolling anti-lag, hard-cut bounce. Richer = bigger bangs + cooler EGT during
  -- the hold, exactly why real launch tables exist. Default 0 = identical to pre-2.8.
  local launchEventFuel = (model.launchActive or model.rollingActive or model.hardCutActive) and (settings.launchFuel or 0) or 0
  local commandedTrim = fuelTrim + (extraTrim or 0) + launchEventFuel
  -- Fuel corrections are pulse-width multipliers, not absolute lambda offsets.
  -- +10% fuel is factor 1.10 and therefore divides lambda by 1.10. This keeps
  -- Fuel Main, STFT/LTFT, and Mixture Map on the same real-ECU ratio math.
  local commandedFuelFactor = clamp(1 + commandedTrim / 100, 0.15, 4.0)
  model.commandedFuelFactor = commandedFuelFactor
  -- hardware-needs-tuning demands: extra air the stock tune does not feed (-> lean), and a
  -- physical injector that doesn't match the ECU's flow setting (-> rich/lean). Self-cancels as
  -- the player adds fuel / sets the right injector flow.
  -- hardware wants a richer mixture under boost/load. Only the SHORTFALL versus what your Lambda Target
  -- already asks for goes lean -- so a tune that targets rich enough (including the stock boost defaults)
  -- runs ON target, and ONLY a tune left too lean under boost actually runs lean until you richen it.
  -- (Before, this was a flat penalty ON TOP of the target, which made the target unfixable -> turbo cars
  -- ran lean and knocked even when correctly targeted rich. That was wrong.)
  local hwRichNeeded = 0
  if hw.turbo > 0 then hwRichNeeded = hwRichNeeded + max(0, boostKpa / 100) * 0.20 end
  if hw.displ > 0 then hwRichNeeded = hwRichNeeded + max(0, load) * 0.06 * hw.displ end
  if hw.cam > 0 then hwRichNeeded = hwRichNeeded + clamp((rpm - 2500) / 4000, 0, 1) * 0.04 * hw.cam end
  if hw.intake > 0 then hwRichNeeded = hwRichNeeded + max(0, load) * 0.02 * hw.intake end
  local hwLean = max(0, hwRichNeeded - max(0, 1.0 - lambdaTarget))                             -- only the un-targeted shortfall
  local injFactor = (hw.inj > 0) and clamp(injFlow / hw.inj, 0.6, 1.4) or 1                    -- ECU-set vs physical injector
  model.injFactor = injFactor   -- published so the UI can EXPLAIN the mismatch instead of players chasing a mystery rich/lean
  local cycleMs = 120000 / clamp(rpm, 700, 10000)
  -- SPEED DENSITY (V2.8, Modelled mode only): the ECU fuels from ITS model of the engine - the
  -- player's VE Table - while reality breathes per trueVE. Injector duty follows the TABLE
  -- (tableVE/realVE: matched = exactly the old duty), and lambda deviates by realVE/tableVE
  -- (table too LOW = underfueled = LEAN there, too HIGH = rich) - a wrong VE cell behaves exactly
  -- like a real standalone. Traditional mode and a matched table are bit-identical to pre-2.8.
  local veRatio = 1
  model.veTableCell, model.veTrueCell = nil, nil
  if equationMode == 2 and maps.ve then
    local rpmNow = model.rpm or 800
    local tableVE = clamp(lookupMap("ve", rpmNow, sensedMap, tps), 30, 130)
    local realVE = trueVE(rpmNow, sensedMap)
    veRatio = clamp(realVE / max(tableVE, 1), 0.6, 1.6)
    model.veTableCell, model.veTrueCell = tableVE, realVE
  end
  local airDemand = equationMode == 2 and ((sensedMap / 100) / max(veRatio, 0.01)) or (0.30 + load * 0.85)
  -- Injector demand follows the adaptive factor that was active on the previous
  -- frame. LTFT and residual STFT multiply, just as separate real ECU fuel
  -- corrections do. Open loop retains the trainer's limited quarter-LTFT carry.
  local ADAPTIVE_MIN_FACTOR, ADAPTIVE_MAX_FACTOR = 0.838, 1.162
  local priorLtftFactor = clamp(1 + (model.ltft or 0) / 100, 0.5, 1.5)
  local priorStftFactor = clamp(1 + (model.stft or 0) / 100, 0.5, 1.5)
  local priorAdaptiveFactor = wasClosedLoop
    and clamp(priorLtftFactor * priorStftFactor, ADAPTIVE_MIN_FACTOR, ADAPTIVE_MAX_FACTOR)
    or clamp(1 + ((model.ltft or 0) * 0.25) / 100, ADAPTIVE_MIN_FACTOR, ADAPTIVE_MAX_FACTOR)
  local fuelDemand = airDemand * (1 / clamp(lambdaTarget, 0.68, 1.1))
    * commandedFuelFactor * priorAdaptiveFactor
  -- E85 (lower stoich) needs ~50% more fuel mass for the same lambda -> higher duty,
  -- which teaches injector sizing (you can run out of injector on a stock setup)
  -- 600 reference, fuel-stoich-scaled, and per-cylinder-displacement scaled (V2 engine-aware): a bigger
  -- cylinder needs more fuel per injection so each injector works harder. Ref 0.5 L/cyl (~a 2.0L I4) = 1.0;
  -- a 6.7 V8 (~0.84 L/cyl) ~1.7x = realistic per-injector demand on big engines. Neutral if undetected.
  local dispFactor = 1
  if engSpec.cyl > 0 and engSpec.displL > 0 then dispFactor = clamp((engSpec.displL / engSpec.cyl) / 0.5, 0.6, 2.2) end
  model.injPulseMs = injDead + max(0, masterFuel * fuelDemand * (600 / injFlow) * (14.7 / fuelProps().stoich) * dispFactor)
  model.injectorDuty = clamp(model.injPulseMs / cycleMs * 100, 0, 130)
  -- FUEL PRESSURE, realistic direction (V2_3): with a manifold-referenced 1:1 regulator the RAIL
  -- (gauge) pressure RISES with boost so the DIFFERENTIAL across the injectors holds ~300 kPa constant.
  -- What collapses when the pump runs out of headroom is the DIFFERENTIAL - high injector duty, and
  -- having to push against a boosted rail, both eat pump capacity. (The old model showed one "fuel
  -- pressure" FALLING with boost - backwards for a 1:1 reg; tuners noticed.) All protection logic
  -- (fault / limp / lean-out) keys off the differential, same thresholds as before.
  -- V3.0: the pump's boost headroom scales with the FITTED injectors (hw.inj, same physical-parts
  -- signal injFactor reads above) - a car built with 1200cc+ injectors realistically carries the
  -- pump/lines to match, so its differential holds at the 100+ psi the raised ceiling allows. A
  -- stock fuel system still collapses (and faults) if you push silly boost through it - that is
  -- real fit-a-bigger-pump behavior, not a tuning knob. 600cc reference keeps the old 150 kPa knee
  -- exactly, so every existing car below ~600cc injectors behaves identically. Neutral if undetected.
  -- injAbs covers factory-fitted big injectors (drag configs), which the baseline-gated hw.inj reports as 0.
  local pumpKneeKpa = 150 * clamp(max(hw.inj or 0, hw.injAbs or 0) / 600, 1, 4)
  local pumpDroop = max(0, model.injectorDuty - 88) * 3.7 + max(0, boostKpa - pumpKneeKpa) * 0.10
  model.fuelDiffPressureKpa = clamp(300 - pumpDroop, 190, 330)
  model.fuelPressureKpa = clamp(model.fuelDiffPressureKpa + boostKpa, 190, mapMeta.boost.max + 330)   -- gauge rail pressure (what a rail gauge shows). V3.0: ceiling sized to the model's own max (330 max diff + max gauge boost); the old 950 pinned the rail readout above ~105 psi while the differential said otherwise

  local masterShift = (8 - masterFuel) * 0.012
  local lambdaBias = settings.sensorLambdaBias or 0
  local leanOut = max(0, model.injectorDuty - 95) * 0.004 + max(0, 250 - model.fuelDiffPressureKpa) * 0.00055

  -- One physical pre-feedback mixture. Modelled VE belongs here, not only in the trim controller:
  --   lambdaBase ~= targetLambda * (realVE / tableVE)
  -- A VE cell that is too low therefore runs lean in open loop; a high cell runs rich. With veRatio=1
  -- this preserves the Traditional-mode baseline. Commanded/startup/transient fuel divides the
  -- mixture by its pulse-width factor; adaptive STFT/LTFT is deliberately excluded here so it
  -- cannot be counted twice.
  local baseLambdaRaw = ((lambdaTarget * veRatio
    + masterShift
    + max(0, load - 1) * 0.02
    + iatLean
    + leanOut
    + hwLean) * injFactor) / commandedFuelFactor
  local lambdaBaseTrue = clamp(baseLambdaRaw, 0.5, 1.5)
  local lambdaUncorrected = clamp(baseLambdaRaw + lambdaBias, 0.5, 1.5)
  model.lambdaBaseTrue = lambdaBaseTrue
  model.lambdaUncorrected = lambdaUncorrected

  -- Genuine ratio-based closed-loop feedback with a fast/slow split. Work from
  -- the mixture produced by the PREVIOUS factor. LTFT learns the required total
  -- factor while STFT supplies only its residual, so STFT returns toward zero
  -- instead of stacking a second copy. Total authority remains +/-16.2%.
  local requiredAdaptiveFactor = priorAdaptiveFactor
  if model.closedLoop then
    local sensedWithPriorTrim = clamp(baseLambdaRaw / max(priorAdaptiveFactor, 0.01) + lambdaBias, 0.5, 1.5)
    requiredAdaptiveFactor = clamp(
      priorAdaptiveFactor * (sensedWithPriorTrim / max(lambdaTarget, 0.01)),
      ADAPTIVE_MIN_FACTOR, ADAPTIVE_MAX_FACTOR)
    local targetLtft = (requiredAdaptiveFactor - 1) * 100
    model.ltft = approach(model.ltft, targetLtft, 0.05, dt)
    local learnedFactor = clamp(1 + model.ltft / 100, 0.5, 1.5)
    local targetStftRaw = (requiredAdaptiveFactor / max(learnedFactor, 0.01) - 1) * 100

    if not wasClosedLoop then
      -- LTFT is global in this trainer, not a load-indexed learn table. On an
      -- open->closed edge, restoring a large LTFT from another region before
      -- STFT can oppose it creates a false rich/lean spike. Preserve as much
      -- LTFT as the +/-12% residual can safely cancel, then initialize STFT to
      -- that residual so the first closed-loop frame stays on the measured need.
      if targetStftRaw > 12 then
        learnedFactor = clamp(requiredAdaptiveFactor / 1.12, ADAPTIVE_MIN_FACTOR, ADAPTIVE_MAX_FACTOR)
        model.ltft = (learnedFactor - 1) * 100
      elseif targetStftRaw < -12 then
        learnedFactor = clamp(requiredAdaptiveFactor / 0.88, ADAPTIVE_MIN_FACTOR, ADAPTIVE_MAX_FACTOR)
        model.ltft = (learnedFactor - 1) * 100
      end
      targetStftRaw = (requiredAdaptiveFactor / max(learnedFactor, 0.01) - 1) * 100
      model.stft = clamp(targetStftRaw, -12, 12)
    else
      local targetStft = clamp(targetStftRaw, -12, 12)
      model.stft = approach(model.stft, targetStft, 3.2, dt)

      -- Slow LTFT movement plus lagging STFT must not cross the required
      -- factor. Detect a real side change relative to the previous factor;
      -- this is symmetric for lean and rich corrections.
      local proposedFactor = learnedFactor * clamp(1 + model.stft / 100, 0.5, 1.5)
      if (priorAdaptiveFactor - requiredAdaptiveFactor) * (proposedFactor - requiredAdaptiveFactor) < 0 then
        model.stft = targetStft
      end
    end
  else
    model.stft = approach(model.stft, 0, 1.8, dt)
    model.ltft = approach(model.ltft, 0, 0.01, dt)
  end

  local adaptiveFactor = model.closedLoop
    and clamp((1 + model.ltft / 100) * (1 + model.stft / 100), ADAPTIVE_MIN_FACTOR, ADAPTIVE_MAX_FACTOR)
    or clamp(1 + (model.ltft * 0.25) / 100, ADAPTIVE_MIN_FACTOR, ADAPTIVE_MAX_FACTOR)
  local adaptive = (adaptiveFactor - 1) * 100
  -- Apply adaptive fuel exactly once to the same base mixture used by feedback.
  local lambdaTrue = clamp(baseLambdaRaw / max(adaptiveFactor, 0.01), 0.5, 1.5)
  local lambdaActual = clamp(lambdaTrue + lambdaBias, 0.5, 1.5)
  model.adaptiveFuelTrim = adaptive
  model.lambdaTrue = lambdaTrue
  model.lambdaActual = lambdaActual
  return lambdaActual, adaptive, lambdaTrue
end

-- Boost control: open/closed-loop, base duty, P/I gains and gear trim drive a
-- time-varying wastegate offset, so the boost RESPONSE feel changes with the tune.
-- (offset biases the turbo's regulated boost; we ramp it like a real solenoid.)
local boostMaxedTimer = 0   -- sustains the "turbo physically maxed" detection so a one-frame dip doesn't flicker it
local boostFloorTimer = 0   -- same sustain for the opposite case: wastegate pegged OPEN, boost still above target

-- PCLink-style target shaping. The Boost Target table remains the base aim; gear and vehicle-speed
-- tables add pressure trims, while their wastegate trims multiply the feed-forward duty. Keeping
-- these helpers on H avoids consuming LuaJIT's nearly exhausted top-level local slots.
H.boostSpeedAxisKmh = { 0, 30, 60, 100, 160, 240 }
H.boostRampTargetKpa = nil
H.boostFilteredSpeedKmh = nil
H.boostHeldGearTargetTrim = nil
H.boostHeldGearDutyTrim = nil
H.boostGearTargetHoldTimer = 0
H.boostGearDutyHoldTimer = 0
H.boostLastGearTargetRequest = nil
H.boostLastGearDutyRequest = nil

function H.readBoostGroundSpeed(dt)
  local raw = H.finiteNumber(ev("airspeed", nil))
  local source = "BeamNG ground speed"
  if raw == nil and obj and obj.getGroundSpeed then
    local ok, value = pcall(function() return obj:getGroundSpeed() end)
    if ok then raw = H.finiteNumber(value) end
    source = "Vehicle ground speed"
  end
  if raw == nil then
    raw = H.finiteNumber(ev("wheelspeed", nil))
    source = "Wheel-speed fallback"
  end
  local valid = raw ~= nil
  local rawKmh = valid and abs(raw) * 3.6 or 0
  if H.boostFilteredSpeedKmh == nil then H.boostFilteredSpeedKmh = rawKmh end
  -- A speed increase can release boost, so filter it; a decrease is applied immediately. The
  -- normal source is true ground speed, while this also makes the wheel-speed fallback fail safer.
  if rawKmh <= H.boostFilteredSpeedKmh then H.boostFilteredSpeedKmh = rawKmh
  else H.boostFilteredSpeedKmh = min(rawKmh, H.boostFilteredSpeedKmh + 300 * (dt or 0)) end
  model.boostRawSpeedKmh = rawKmh
  model.boostSpeedKmh = H.boostFilteredSpeedKmh
  model.boostSpeedSource = valid and source or "Invalid - using 0 km/h"
  return H.boostFilteredSpeedKmh, valid
end

-- Fail-safe visual speed.  Ground speed is preferred by readBoostGroundSpeed,
-- but a few mod cars publish an airspeed channel that exists yet stays at zero,
-- preventing that nil-only fallback from running.  For particle selection the
-- safe failure direction is the SHORT moving flame, so take the greatest valid
-- reading from the normal result, the vehicle API and wheel speed.  Wheelspin
-- can only make the flame shorter; it can never create a giant plume.
function H.flameGroundSpeedKmh()
  local speedKmh = abs(H.finiteNumber(model.boostRawSpeedKmh) or 0)
  if obj and obj.getGroundSpeed then
    local ok, value = pcall(function() return obj:getGroundSpeed() end)
    value = ok and H.finiteNumber(value) or nil
    if value ~= nil then speedKmh = max(speedKmh, abs(value) * 3.6) end
  end
  local wheelSpeed = H.finiteNumber(ev("wheelspeed", nil))
  if wheelSpeed ~= nil then speedKmh = max(speedKmh, abs(wheelSpeed) * 3.6) end
  return speedKmh
end

-- BeamMP marks locally-owned and replicated vehicles in the vehicle VM.  Remote
-- cars must replay the owner's compact effects packet instead of deriving a
-- second, unsynchronised set of bangs from their local simulation.
function H.isBeamMpRemote()
  return type(v) == "table" and v.mpVehicleType == "R"
end

function H.boostSpeedTrim(suffix, speedKmh)
  local axis = H.boostSpeedAxisKmh
  if speedKmh <= axis[1] then return settings["boostSpeed1" .. suffix] or 0 end
  for i = 2, #axis do
    if speedKmh <= axis[i] then
      local lo, hi = axis[i - 1], axis[i]
      local a = settings["boostSpeed" .. (i - 1) .. suffix] or 0
      local b = settings["boostSpeed" .. i .. suffix] or 0
      local t = (speedKmh - lo) / max(hi - lo, 0.001)
      return a + (b - a) * t
    end
  end
  return settings["boostSpeed" .. #axis .. suffix] or 0
end

function H.boostGearRequests(baseTargetKpa, legacy)
  local rawGear = floor(H.finiteNumber(ev("gearIndex", 0)) or 0)
  local valid = rawGear > 0
  local gear = valid and clamp(rawGear, 1, 8) or 1   -- R/N/unknown deliberately fail safe to G1
  local targetTrim, dutyTrim = 0, 0
  if legacy then
    local gauge = max(0, baseTargetKpa - (model.baroKpa or 101.325))
    local mul = gear <= 1 and 0.6 or (gear == 2 and 0.8 or 1.0)
    targetTrim = gauge * (mul - 1)
  else
    targetTrim = settings["boostGear" .. gear .. "TargetTrimKpa"] or 0
    dutyTrim = settings["boostGear" .. gear .. "DutyTrimPct"] or 0
  end
  model.boostGear = valid and gear or 0
  model.boostGearValid = valid
  return targetTrim, dutyTrim, gear, valid
end

function H.holdRisingGearCorrection(value, kind, dt)
  local heldKey = kind == "target" and "boostHeldGearTargetTrim" or "boostHeldGearDutyTrim"
  local timerKey = kind == "target" and "boostGearTargetHoldTimer" or "boostGearDutyHoldTimer"
  local requestKey = kind == "target" and "boostLastGearTargetRequest" or "boostLastGearDutyRequest"
  if H[heldKey] == nil then H[heldKey], H[requestKey] = value, value; H[timerKey] = 0; return value end
  if value < H[heldKey] then
    -- A lower target / lower duty is the safety direction and must never wait for a shift timer.
    H[heldKey], H[requestKey], H[timerKey] = value, value, 0
    return value
  end
  if value > H[heldKey] then
    if H[requestKey] ~= value then
      H[requestKey] = value
      H[timerKey] = clamp(settings.boostShiftHoldMs or 250, 0, 1000) * 0.001
    end
    H[timerKey] = max(0, H[timerKey] - (dt or 0))
    if H[timerKey] <= 0 then H[heldKey] = value end
  else
    H[requestKey], H[timerKey] = value, 0
  end
  return H[heldKey]
end

function H.shapeBoostTarget(dt, baseTargetKpa)
  baseTargetKpa = H.finiteNumber(baseTargetKpa) or (model.baroKpa or 101.325)
  local baro = model.baroKpa or 101.325
  local mode = floor(settings.boostMode or 2)
  local shapeMode = clamp(floor(settings.boostShapeMode or 1), 1, 4)
  local legacy = mode == 3 and shapeMode == 1
  local closedLoop = mode ~= 1
  local speedKmh, speedValid = H.readBoostGroundSpeed(dt)
  local gearTarget, gearDuty, gear, gearValid = H.boostGearRequests(baseTargetKpa, legacy)
  local useGear = legacy or shapeMode == 2 or shapeMode == 4
  local useSpeed = shapeMode == 3 or shapeMode == 4

  if not useGear then gearTarget, gearDuty = 0, 0 end
  if useGear and not legacy then
    gearTarget = H.holdRisingGearCorrection(gearTarget, "target", dt)
    gearDuty = H.holdRisingGearCorrection(gearDuty, "duty", dt)
  end
  local speedTarget = useSpeed and H.boostSpeedTrim("TargetTrimKpa", speedKmh) or 0
  local speedDuty = useSpeed and H.boostSpeedTrim("DutyTrimPct", speedKmh) or 0
  if useSpeed and not speedValid then
    speedTarget = settings.boostSpeed1TargetTrimKpa or 0
    speedDuty = settings.boostSpeed1DutyTrimPct or 0
  end

  model.boostBaseTargetKpa = baseTargetKpa
  model.boostGearTargetTrimKpa, model.boostSpeedTargetTrimKpa = gearTarget, speedTarget
  model.boostGearDutyTrimPct, model.boostSpeedDutyTrimPct = gearDuty, speedDuty
  model.boostDutyTrimPct = clamp(gearDuty + speedDuty, -100, 100)

  local appliedTargetTrim = closedLoop and (gearTarget + speedTarget) or 0
  local requested = clamp(baseTargetKpa + appliedTargetTrim, baro, mapMeta.boost.max)
  if legacy then requested = clamp(baseTargetKpa + gearTarget, baro, mapMeta.boost.max) end
  model.boostTargetTrimAppliedKpa = requested - baseTargetKpa
  model.boostRequestedTargetKpa = requested

  local shapingActive = shapeMode ~= 1
  local finalTarget = requested
  if closedLoop and shapingActive then
    if H.boostRampTargetKpa == nil then H.boostRampTargetKpa = baseTargetKpa end
    if requested > H.boostRampTargetKpa then
      local rate = clamp(settings.boostRampUpKpaS or 300, 0, 2000)
      H.boostRampTargetKpa = rate <= 0 and requested or min(requested, H.boostRampTargetKpa + rate * (dt or 0))
    elseif requested < H.boostRampTargetKpa then
      local rate = clamp(settings.boostRampDownKpaS or 0, 0, 2000)
      H.boostRampTargetKpa = rate <= 0 and requested or max(requested, H.boostRampTargetKpa - rate * (dt or 0))
    end
    finalTarget = H.boostRampTargetKpa
  else
    -- Off/Open Loop and exact legacy mode preserve their old immediate target behavior.
    H.boostRampTargetKpa = requested
  end

  if not base.hasTurbocharger then
    gearTarget, speedTarget, gearDuty, speedDuty = 0, 0, 0, 0
    model.boostGearTargetTrimKpa, model.boostSpeedTargetTrimKpa = 0, 0
    model.boostGearDutyTrimPct, model.boostSpeedDutyTrimPct, model.boostDutyTrimPct = 0, 0, 0
    model.boostTargetTrimAppliedKpa, model.boostRequestedTargetKpa = 0, baseTargetKpa
    H.boostRampTargetKpa, finalTarget = baseTargetKpa, baseTargetKpa
    model.boostShapeStatus = base.isSupercharger and "Not available - supercharger boost is mechanical" or "Not available - naturally aspirated"
    model.boostLimitReason = "Base target"
  elseif legacy then
    model.boostShapeStatus = "Legacy proportional gear target"
    model.boostLimitReason = gearValid and ("Legacy G" .. gear) or "Legacy G1 fallback"
  elseif shapeMode == 1 then
    model.boostShapeStatus = "Off"
    model.boostLimitReason = "Base target"
  elseif mode == 1 then
    model.boostShapeStatus = "Open loop - target trims ignored; duty trims active"
    model.boostLimitReason = "Open-loop duty shaping"
  else
    model.boostShapeStatus = shapeMode == 2 and "Gear target + duty"
      or (shapeMode == 3 and "Speed target + duty" or "Gear + speed target + duty")
    if useGear and not gearValid then model.boostLimitReason = "G1 fallback (invalid gear)"
    elseif shapeMode == 2 then model.boostLimitReason = "Gear G" .. gear
    elseif shapeMode == 3 then model.boostLimitReason = "Ground speed"
    else model.boostLimitReason = "Gear G" .. gear .. " + ground speed" end
  end

  model.boostTargetKpa = finalTarget
  return finalTarget
end

local function updateBoostController(dt, rpm, tps, mapKpa, targetKpa)
  if not base.hasTurbo then
    model.boostStatus = "N/A (naturally aspirated)"
    model.boostError = 0
    model.wastegateDuty = 0
    model.boostBaseWastegateDuty, model.boostFeedforwardDuty, model.boostPidDutyTrim = 0, 0, 0
    model.boostPTermPsi, model.boostDTermPsi, model.boostITermPsi, model.boostBiasPsi = 0, 0, 0, 0
    model.boostHardwareFloorKpa, model.boostTargetBelowSpring = model.baroKpa or 101.325, false
    model.wgOffset = 0
    model.boostIntegral = 0
    model.boostMaxed = false; boostMaxedTimer = 0; model.boostFloored = false; boostFloorTimer = 0
    return
  end
  if base.isSupercharger then
    -- supercharger: boost is mechanical (belt / pulley) - there is NO wastegate to chase a target.
    -- The Boost Target table acts as a CEILING (the power model bleeds power to hold boost under it),
    -- so there is no PID here - just report the live picture.
    local over = model.mapKpa - model.boostTargetKpa
    model.boostError = over
    model.wastegateDuty = 0
    model.boostBaseWastegateDuty, model.boostFeedforwardDuty, model.boostPidDutyTrim = 0, 0, 0
    model.boostPTermPsi, model.boostDTermPsi, model.boostITermPsi, model.boostBiasPsi = 0, 0, 0, 0
    model.boostHardwareFloorKpa, model.boostTargetBelowSpring = model.baroKpa or 101.325, false
    model.wgOffset = 0
    model.boostIntegral = 0
    if (model.boostKpa or 0) < 1 then model.boostStatus = "Supercharger - off boost"
    elseif over > 3 then model.boostStatus = "Over ceiling - bleeding power (+" .. floor(over + 0.5) .. " kPa)"
    else model.boostStatus = "Supercharger - " .. floor((model.mapKpa or 100) + 0.5) .. " kPa (mechanical)" end
    model.boostMaxed = false; boostMaxedTimer = 0; model.boostFloored = false; boostFloorTimer = 0
    return
  end
  local mode = settings.boostMode or 2
  -- V2.8 (Liqurdz's ask, real-EBC feature): the Wastegate Duty table shifts the Base duty slider
  -- per RPM/target cell (50 = neutral). Big turbos need less duty up top where exhaust energy
  -- explodes - sculpting duty down at high rpm is how real boost controllers stop the hunting.
  local dutyTableOffset = 0
  if maps.wgduty then dutyTableOffset = clamp(lookupMap("wgduty", rpm, targetKpa), 0, 100) - 50 end
  local baseDuty = clamp((settings.boostBaseDuty or 45) + dutyTableOffset, 0, 100)
  model.boostBaseWastegateDuty = baseDuty
  -- Link-style WG trim is a percentage correction to the base feed-forward, not percentage points:
  -- 60% base with -30% combined gear/speed trim commands 42%.
  baseDuty = clamp(baseDuty * (1 + clamp(model.boostDutyTrimPct or 0, -100, 100) / 100), 0, 100)
  model.boostFeedforwardDuty = baseDuty
  local pGain = clamp(settings.boostPGain or 0.8, 0, 2)
  local iGain = clamp(settings.boostIGain or 0.12, 0, 0.5)
  local stockPSI = max(1, ((base.turboRefKpa or base.turboMaxKpa) or 200) - 100) * KPA_TO_PSI
  -- hardware truth (V2.6): springPSI = the boost the wastegate spring regulates to (the game's
  -- Tuning-tab slider), wgMinOffset = the offset that parks the gate at its crack pressure - a
  -- pneumatic gate CANNOT run below its spring, so that's the honest floor. Cars whose jbeam
  -- didn't expose the spring keep the old estimated behavior.
  local springPSI = (base.wgSpringTargetPSI and base.wgSpringTargetPSI > 0.5) and base.wgSpringTargetPSI or stockPSI
  -- gate hardware (V2.6.x, a LABELED choice - both real): PNEUMATIC (default) = spring gate, boost
  -- can never run below the spring (the game's Tuning-tab slider) - the classic EBC reality.
  -- ELECTRIC = modern motor-driven gate (OEM style): full authority, can hold the gate open and
  -- run well below spring.
  local electricGate = floor(settings.wgType or 1) == 2
  local wgMinOffset = electricGate and -(springPSI + 15)
    or (base.wgSpringStartPSI and (base.wgSpringStartPSI - springPSI) or -stockPSI)
  model.boostHardwareFloorKpa = (model.baroKpa or 101.325)
    + (electricGate and 0 or max(0, base.wgSpringStartPSI or springPSI) * PSI_TO_KPA)
  model.boostTargetBelowSpring = not electricGate and targetKpa < model.boostHardwareFloorKpa - 0.5
  local targetPSI = max(0, (targetKpa - (model.baroKpa or 101.325)) * KPA_TO_PSI)
  local actualPSI = max(0, model.boostKpa * KPA_TO_PSI)
  model.boostError = targetKpa - mapKpa
  -- track the boost rate every frame, in EVERY mode: prevBoostPSI used to update only inside the
  -- closed-loop branch, so after an off-boost stretch or a mode switch the D-term's first frame saw a
  -- stale value and could spike the wastegate. The closed loop below uses prevPSI captured here.
  local prevPSI = model.prevBoostPSI or actualPSI
  model.prevBoostPSI = actualPSI

  -- off boost: relax the wastegate toward neutral but KEEP most of the integrator as spool memory, so
  -- boost rebuilds fast on tip-in. (The old code slammed the offset fully open to -stockPSI and dumped the
  -- integrator, which made big turbos sluggish to re-spool and fed the "takes a lot to build boost" feel.)
  -- V2.9.7 wave 4 (Liqurdz's BBG report): BUMPLESS TRANSFER on target step-down. The requested
  -- target drops INSTANTLY on an upshift (falling gear trims are the safety direction), but the
  -- integrator that held the old higher target survives (~83% through a 0.3 s lift) and unwinds
  -- only at err*iGain*iRate - seconds of riding 5+ psi over the new gear target ("3rd gear
  -- overshoots to 30+ after a 40 psi 2nd-gear pull"). On a per-frame drop of the PRE-RAMP
  -- requested target > 2 psi, clamp the integral DOWN to the plant-inverse equilibrium for the
  -- new target (never raise it). Uses the RAW bias - only its negative branch feeds the
  -- equilibrium. Detected pre-ramp so boostRampDownKpaS cannot mask the step. Also arms a short
  -- protection grace (consumed at the fault site): carried boost physically needs ~0.45 s to
  -- blow off, and the 0.3 s overboost maturation alone would latch a permanent DTC on big
  -- flat-shift step-downs even with a perfect loop. Sim: lift-shift peak +7.3 -> +1.6 psi,
  -- convergence 7.2 -> 0.9 s; same-target shifts and the wave-2 launch validations untouched.
  local reqKpa = model.boostRequestedTargetKpa or targetKpa
  if H.prevBoostReqKpa and (H.prevBoostReqKpa - reqKpa) * KPA_TO_PSI > 2 then
    local rawBias = (baseDuty - 50) / 50 * max(springPSI, targetPSI) * 0.6
    model.boostIntegral = min(model.boostIntegral, max(0, targetPSI - springPSI) - min(0, rawBias) + 3)
    model.boostProtGraceKpa = H.prevBoostReqKpa
    model.boostProtGraceTimer = 0.6
  end
  H.prevBoostReqKpa = reqKpa

  if tps < 30 or rpm < 1400 then
    model.boostIntegral = approach(model.boostIntegral, 0, 0.6, dt)
    -- V2.9.7 (Toast's BBG drag report): PRESTAGE. For a below-spring target (BBG low gear on an
    -- electric gate) relaxing to 0 = spring regulation guaranteed a launch spike: boost rushed to
    -- the spring while the integrator crawled down at ~6 psi/s. The gate model is 1:1 psi (see the
    -- spring-read notes), so command the known spring-to-target gap while staged instead of making
    -- the loop rediscover it on every hit. Above-spring targets: min(0, ...) = 0 = old behavior.
    -- Open loop (mode 1) keeps the old neutral relax - base duty is an explicit player command there.
    model.wgOffset = approach(model.wgOffset, mode ~= 1 and max(wgMinOffset, min(0, targetPSI - springPSI)) or 0, 8, dt)
    -- V2.9.7 hygiene (BBG-fix review): the duty gauge tracks the PRESTAGED command while staged
    -- (same wgOffset->duty mapping as the on-boost frame), not a neutral 50 the gate is not at.
    model.wastegateDuty = approach(model.wastegateDuty, clamp(50 + model.wgOffset / max(springPSI, targetPSI, 1) * 50, 0, 100), 120, dt)
    model.boostPidDutyTrim = 0
    model.boostStatus = "Idle / off boost"
    -- V2.9.7 wave 4: re-seed the taper filter each spool (initializes to full err on the next
    -- on-boost frame - launch response stays unchanged); loop-breakdown telemetry shows the
    -- surviving integrator (spool memory) and nothing else while off boost.
    H.boostTaperErrFilt = nil
    model.boostPTermPsi, model.boostDTermPsi, model.boostBiasPsi = 0, 0, 0
    model.boostITermPsi = model.boostIntegral
    model.boostMaxed = false; boostMaxedTimer = 0; model.boostFloored = false; boostFloorTimer = 0
    return
  end

  -- FACTORY / untouched Boost Target table (closed-loop modes only): do NOT chase it. A base ECU
  -- must not change the boost the car was built for - chasing auto-seeded targets caused both V2.5
  -- community boost bugs (big-turbo ~25psi floor no table value could lower, and the stock-Vivace
  -- target runaway into Overboost+LimpMode). The wastegate keeps its stock spring behavior until
  -- the player actually tunes boost (cell edit, non-stock preset, or a loaded tune drops the flag).
  -- Open loop is untouched: base duty is an explicit player command, table or no table.
  local shapeMode = floor(settings.boostShapeMode or 1)
  local legacyGearMode = floor(settings.boostMode or 2) == 3 and shapeMode == 1
  if boostTableFactory and (settings.boostMode or 2) ~= 1 and shapeMode == 1 and not legacyGearMode then
    model.boostIntegral = approach(model.boostIntegral, 0, 1.0, dt)
    model.wgOffset = approach(model.wgOffset, 0, 8, dt)
    model.wastegateDuty = approach(model.wastegateDuty, 50, 120, dt)
    model.boostPidDutyTrim = 0
    model.boostStatus = "Stock boost (edit Boost Target to take control)"
    model.boostPTermPsi, model.boostDTermPsi, model.boostBiasPsi = 0, 0, 0
    model.boostITermPsi = model.boostIntegral
    model.boostMaxed = false; boostMaxedTimer = 0; model.boostFloored = false; boostFloorTimer = 0
    return
  end

  -- base-duty feedforward: 50% duty ~= stock wastegate, higher = more boost. Scaled to the SPRING
  -- (the hardware) like a real boost controller's duty - not to the turbo's max or your target.
  local ff = (baseDuty - 50) / 50 * springPSI

  if mode == 1 then
    -- OPEN LOOP: boost is whatever base duty produces, no correction to target
    model.boostIntegral = approach(model.boostIntegral, 0, 1.0, dt)
    model.wgOffset = approach(model.wgOffset, ff, 6, dt)
    model.boostStatus = "Open loop"
    model.boostPTermPsi, model.boostDTermPsi, model.boostITermPsi = 0, 0, 0
    model.boostBiasPsi = ff   -- open loop: the whole command IS the duty feed-forward
    model.boostMaxed = false; boostMaxedTimer = 0; model.boostFloored = false; boostFloorTimer = 0
  else
    -- CLOSED LOOP: the final (base + gear/speed trims + ramp) target is king. The integrator finds the wastegate
    -- offset that HOLDS your target on whatever turbo is fitted - small or huge - so you don't have to
    -- hand-calibrate base duty to REACH target (that's how a real closed-loop EBC behaves). Base duty is a
    -- spool FEEDFORWARD here: higher pre-loads the wastegate for a faster/harder hit, lower softens it; it
    -- shapes the RESPONSE, the loop still lands on your target. (Old code subtracted the turbo's MAX boost
    -- as feedforward - "target - max" went strongly NEGATIVE on big turbos, commanding the wastegate open so
    -- you had to crank base duty just to build boost = the "stuck between two wastegates" the testers felt.)
    local tgtPSI = targetPSI
    local errPSI = tgtPSI - actualPSI
    -- base-duty spool feedforward: 50% = neutral, 100% pre-loads for a snappy spool, 0% holds it
    -- down. Scaled to the HARDWARE (spring, or target when tuned above it) - duty authority is a
    -- property of the gate on a real EBC, not of the number you typed (V2.6: target-scaling made
    -- the slider feel dead at low targets on big turbos - Liqurdz's report). The loop still lands
    -- on target; this only shapes the response.
    local baseBias = (baseDuty - 50) / 50 * max(springPSI, tgtPSI) * 0.6
    -- V2.9.7 (Toast's BBG drag report): the closed-gate push is a SPOOL device - fade it out over
    -- the last max(25% of target, 3 psi) of approach so it can neither punch boost through a low
    -- BBG gear target at crossing nor hold a standing offset the integrator must out-muscle. This
    -- makes the comment above literally true. POSITIVE bias only: the open-gate pull of duty < 50
    -- must stay constant (sim-tested: tapering it inverts the feedback and duty-0 tunes hunt/spike).
    -- V2.9.7 wave 4 (Liqurdz's "higher duty = more jitter"): the plain taper made positive bias
    -- an err-proportional push with 11-19x the explicit P gain at duty 80-100 - with real spool
    -- lag it PUMPED 2 Hz drag-pass disturbances, scaling with duty. Taper on min(instant,
    -- low-passed) error instead: the CUT stays instant (min picks the small instant err as boost
    -- closes on target, so the punch-through fix is untouched) and only the RETURN of the push
    -- after a dip is slowed (5/s filter) - which is what killed the pumping. Filter re-seeds
    -- each spool (nil'd off-boost). Sim: duty-100 hunt 2.35 -> 1.61 psi (baseline 1.56),
    -- limit-cycle ignition 2.56 -> 0.29; punch-through scenarios bit-identical.
    if baseBias > 0 then
      if H.boostTaperErrFilt == nil then H.boostTaperErrFilt = errPSI end
      H.boostTaperErrFilt = approach(H.boostTaperErrFilt, errPSI, 5, dt)
      baseBias = baseBias * clamp(min(errPSI, H.boostTaperErrFilt) / max(tgtPSI * 0.25, 3), 0, 1)
    end
    -- rate boost is climbing (psi/s) - used to anticipate and cut the spool spike
    local boostRate = clamp((actualPSI - prevPSI) / max(dt, 0.001), -40, 40)
    -- integrator does the real work of holding target; authority scaled to the target so it can find the
    -- holding offset on ANY turbo. Wind faster near target, slower far out so a big spool doesn't overshoot.
    -- V2.9.7 (Toast): AUTHORITY MUST COVER THE JOB. The old symmetric iLimit = max(10, 2*target)
    -- shrank with the BBG gear target while the work (bridge the spring-to-target gap + cancel any
    -- remaining closed-bias) scales with the SPRING - low gear targets were physically unreachable
    -- ((spring - target) + bias > iLimit; 67/245 swept combos, incl. the default 45% duty) and no
    -- gain slider touches iLimit. Lower bound now sized to the actual job; upper bound unchanged so
    -- wind-up (the launch-spike direction) gains nothing, plus a small term to cancel a constant
    -- open-bias when duty < 50. boostILimitLo lives on model as Tune Doctor telemetry.
    local iLimit = max(10, tgtPSI * 2, 3 - baseBias)
    model.boostILimitLo = max(iLimit, (springPSI - tgtPSI) + max(0, baseBias) + 3)
    local iRate = (abs(errPSI) < 3) and 12 or 6
    local proposedIntegral = model.boostIntegral + errPSI * iGain * dt * iRate
    -- Anti-windup at both physical ends. Key the low side from the ACTUAL gate stop, not merely a
    -- below-spring request: before the gate reaches that stop the integrator may need to go negative
    -- enough to cancel a positive base-duty feed-forward. Once saturated, block only motion farther
    -- into saturation while allowing an opposite-sign I-term to unwind toward zero.
    local gateAtLowStop = model.boostFloored or model.wgOffset <= wgMinOffset + 0.05
    if gateAtLowStop and errPSI < 0 then
      if model.boostIntegral > 0 then model.boostIntegral = clamp(max(0, proposedIntegral), -model.boostILimitLo, iLimit) end
    elseif model.boostMaxed and errPSI > 0 then
      if model.boostIntegral < 0 then model.boostIntegral = clamp(min(0, proposedIntegral), -model.boostILimitLo, iLimit) end
    else
      model.boostIntegral = clamp(proposedIntegral, -model.boostILimitLo, iLimit)
    end
    -- PD: P pulls toward target; D opens the wastegate as boost rushes up so it doesn't overshoot.
    -- Higher P = tighter control = smaller spool spike; too high and it hunts around target.
    local pTerm = errPSI * pGain * 0.16
    local dTerm = (boostRate > 0) and (-boostRate * pGain * 0.22) or 0
    -- V2.9.7 (Toast): plant-inverse feed-forward of the spring-to-target gap. The verified gate
    -- model is 1:1 psi from the spring, so for a below-spring target the ECU KNOWS the holding
    -- offset - command it directly instead of making the integrator discover -16 psi mid-launch.
    -- min(0, ...) = exact no-op for every above-spring target (old tunes bit-identical).
    model.wgOffset = approach(model.wgOffset, min(0, tgtPSI - springPSI) + baseBias + model.boostIntegral + pTerm + dTerm, 20, dt)
    -- V2.9.7 wave 4 (Liqurdz's "hard to tell what the controller is doing"): stream the loop's
    -- own decomposition instead of lumping everything into boostPidDutyTrim. These four are the
    -- exact terms summed into the command above.
    model.boostPTermPsi, model.boostDTermPsi = pTerm, dTerm
    model.boostITermPsi = model.boostIntegral
    model.boostBiasPsi = min(0, tgtPSI - springPSI) + baseBias
    -- TURBO PHYSICALLY MAXED (V2.6, straight from a beta session): wastegate command pegged, boost
    -- stopped climbing, still short of target = the COMPRESSOR is at its ceiling. No duty/gain/cut
    -- setting can add air a turbo can't flow - exactly like real life. The old status showed
    -- "Spooling" forever here, which sent people hunting phantom settings; now the ECU says it.
    local wgPegged = model.wgOffset >= max(stockPSI, tgtPSI) * 0.97
    if wgPegged and errPSI > 1.5 and boostRate < 0.6 and actualPSI > 3 then
      boostMaxedTimer = boostMaxedTimer + dt
    else
      boostMaxedTimer = 0
    end
    model.boostMaxed = boostMaxedTimer > 0.7
    -- WASTEGATE FLOOR (V2.6, from the engine-swap log): opposite twin of TURBO MAXED - the gate is
    -- pegged fully OPEN and boost still sits ABOVE target, meaning the spring/stock setting
    -- physically can't run this low (tune from a smaller-turbo engine, or a low baseline table
    -- under a stiff spring). Report it honestly; the overboost fault/limp is exempted while
    -- floored (see the fault site) - no ECU setting can comply, so an endless cut protected
    -- nothing and made the car undriveable (157 limp samples in the report log).
    local wgFloorPegged = model.wgOffset <= wgMinOffset + 0.05
    if wgFloorPegged and errPSI < -1.5 and actualPSI > 3 then
      boostFloorTimer = boostFloorTimer + dt
    else
      boostFloorTimer = 0
    end
    model.boostFloored = boostFloorTimer > 0.7
    if model.boostMaxed then
      model.boostStatus = "TURBO MAXED at ~" .. floor(actualPSI + 0.5) .. " psi - target unreachable (bigger turbo, or lower the target)"
    elseif model.boostFloored then
      -- name the ACTUAL hardware limit and the real-world fix
      if electricGate then
        model.boostStatus = "GATE WIDE OPEN at ~" .. floor(actualPSI + 0.5) .. " psi - the turbo makes this much even fully bypassed"
      else
        model.boostStatus = "WASTEGATE FLOOR at ~" .. floor(actualPSI + 0.5) .. " psi - spring is set to ~"
          .. floor((base.wgSpringStartPSI or actualPSI) + 0.5)
          .. " psi (lower the game's Tuning-tab boost slider, or fit an Electric gate here)"
      end
    elseif model.boostError > 22 then model.boostStatus = "Spooling - " .. (model.boostLimitReason or "base target")
    elseif model.boostError < -18 then model.boostStatus = "Overboost"
    else model.boostStatus = "Closed loop - " .. (model.boostLimitReason or "base target") end
    -- V2.9.7 wave 4: say it when the integrator is pinned - "target unreachable with this
    -- duty/spring" was previously invisible and got misdiagnosed as a P-gain problem.
    if model.boostIntegral >= iLimit * 0.95 or model.boostIntegral <= -(model.boostILimitLo or iLimit) * 0.95 then
      model.boostStatus = model.boostStatus .. " - integrator at authority limit"
    end
  end

  model.wgOffset = clamp(model.wgOffset, wgMinOffset, max(stockPSI * 2.5, targetPSI * 2.5))
  model.wastegateDuty = clamp(50 + model.wgOffset / max(springPSI, targetPSI, 1) * 50, 0, 100)
  model.boostPidDutyTrim = model.wastegateDuty - (model.boostFeedforwardDuty or 0)
end

-- apply the controller's wastegate offset to the real turbo (turbo only)
local function applyBoost()
  if not (base.hasTurbo and turbo and turbo.setWastegateOffset) then return end
  local offset = model.wgOffset or 0
  if lastWastegateOffset == nil or abs(offset - lastWastegateOffset) > 0.05 then
    turbo.setWastegateOffset(offset)
    lastWastegateOffset = offset
  end
end

local function updateKnock(dt, knockPotential, knockControlActive, protectionEnabled)
  local threshold = clamp(settings.knockThreshold or 1.2, 0.4, 3.5)
  local maxRetard = clamp(settings.knockMaxRetard or 8, 0, 12)
  local recovery = clamp(settings.knockRecoveryRate or 0.25, 0.05, 1)
  if not knockControlActive then
    -- knock control disabled: no protective retard, detonation runs unchecked
    model.knockRetard = approach(model.knockRetard, 0, 1, dt)
    return
  end
  if knockPotential > threshold then
    local learnRate = protectionEnabled and 2.2 or 1.25
    model.knockRetard = clamp(model.knockRetard + max(0, knockPotential - threshold + 0.1) * learnRate * dt, 0, maxRetard)
  else
    model.knockRetard = approach(model.knockRetard, 0, recovery, dt)
  end
end

local function updateLeanMisfire(dt, tps, load, lambdaTarget, lambdaTrue, ignTrim, knockLevel, protectionActive, extraRisk)
  local threshold = settings.knockThreshold or 1.2
  local leanError = max(0, lambdaTrue - lambdaTarget)
  local richError = max(0, lambdaTarget - lambdaTrue)
  local absLean = max(0, lambdaTrue - 1.12)   -- dangerously lean (absolute). Onset ABOVE the max targetable lambda (1.10): real engines cruise fine at 1.05-1.10 (eco lean-cruise), so a deliberate lean-cruise target must not stumble; genuine lean misfire starts ~1.15+. Lean-OF-TARGET under load is still caught by the leanError term below.
  local absRich = max(0, 0.68 - lambdaTrue)    -- drowning rich (absolute); ~AFR 10:1. Power tunes legitimately run 0.78-0.85 (stock turbo lambda map commands 0.80 at high boost) so the onset must sit well below that or normal/transient enrichment trips false rich misfires.
  local risk = extraRisk or 0
  -- the decel fuel cut intentionally leans the mixture on a closed throttle (overrun). That's normal and
  -- harmless, so don't treat it as a lean misfire (it was throwing false Lean/Knock codes on every lift).
  if simTime > 3 and (load > 0.25 or tps > 25) and not (model.decelCut or tps < 6) then
    risk = risk + absLean * 7.0
    risk = risk + absRich * 5.0
    risk = risk + max(0, leanError - 0.06) * 3.0
    risk = risk + max(0, knockLevel - threshold) * 0.35
    risk = risk + max(0, model.injectorDuty - 100) * 0.02
  end
  if protectionActive then risk = risk * 0.7 end
  risk = clamp(risk, 0, 1)
  model.misfireSeverity = risk
  model.misfireCooldown = max(0, model.misfireCooldown - dt)

  if model.misfireTimer > 0 then
    model.misfireTimer = max(0, model.misfireTimer - dt)
    model.misfireCut = clamp(0.20 - risk * 0.14, 0.05, 0.20)   -- near-total cut = real stumble
    if absLean > 0.04 or leanError > 0.10 then model.misfireStatus = "Lean misfire"
    elseif absRich > 0.04 or richError > 0.12 then model.misfireStatus = "Rich misfire"
    elseif knockLevel > threshold + 0.5 then model.misfireStatus = "Knock breakup"
    else model.misfireStatus = "Misfire" end
    return
  end
  model.misfireCut = 1
  if risk > 0.05 and model.misfireCooldown <= 0 then
    model.misfireTimer = 0.03 + risk * 0.10
    model.misfireCooldown = max(0.04, 0.30 - risk * 0.26)
    model.misfireCount = model.misfireCount + 1
    model.misfireStatus = (absLean > 0.04 or leanError > 0.10) and "Lean misfire" or (absRich > 0.04 and "Rich misfire" or "Misfire")
    return
  end
  if risk > 0.03 then model.misfireStatus = "Unstable"
  else model.misfireStatus = "OK"; model.misfireSeverity = 0 end
end

local function setFault(code, on)
  if on then model.faults[code] = true end
end

local function faultList()
  local order = { "Lean", "Knock", "Smoke", "Overboost", "FuelPressure", "InjectorDuty", "EGT", "CoolantTemp", "OilTemp", "EngineHealth", "EThrottle", "Trigger", "LimpMode" }
  local out = {}
  for i = 1, #order do
    if model.faults[order[i]] then out[#out + 1] = order[i] end
  end
  model.faultCount = #out
  return #out > 0 and table.concat(out, ",") or "None"
end

-- Mirror BeamNG's engine-damage icon to our ACTUAL accumulated damage (only on
-- change), instead of latching it on a momentary rate spike that never clears.
-- Keeps the in-game damage icon and the ECU's health readout in agreement.
local lastDamageFlag = nil
local function setEngineDamageFlag(on)
  on = on and true or false
  if on ~= lastDamageFlag then
    lastDamageFlag = on
    pcall(function() damageTracker.setDamage("engine", "engineReducedTorque", on) end)
  end
end

-- effective block strength: prefer the UI's name-based value (robust across mods), else the
-- torque-based auto-detect captured at init
local function effBlockStrength()
  local real = base.blockStrength or 1
  -- REALISM FIRST: when the engine advertises a real jbeam over-torque rating (maxTorqueRating /
  -- maxOverTorqueDamage), THAT is the strength - it matches the modder's own jbeam values exactly,
  -- and a name hint can never change it (up or down). This is the realistic, jbeam-driven path.
  if base.blockStrengthSource == "rated" then return real end
  -- No rating in the jbeam: there is no real value to match, so fall back to the power-from-curve
  -- estimate or the name-based UI hint, whichever is higher, so a genuine big build whose modder
  -- left the rating unset is never under-protected and grenaded like a stock block.
  if blockOverride then return max(blockOverride, real) end
  return real
end

local function updateDamage(dt, rpm, mapKpa, load, lambdaTarget, lambdaTrue, knockLevel, protectionActive)
  local threshold = settings.knockThreshold or 1.2
  local knockRisk = max(0, knockLevel - threshold)
  -- only genuinely dangerous lean under real load hurts the motor; stoich (lambda 1.0)
  -- cruising on a stock tune must NOT accumulate damage
  -- decel fuel cut leans the mixture on a closed throttle (no power, no real risk) - don't count it as damage
  local leanRisk = (load > 0.55 and not model.decelCut) and max(0, lambdaTrue - 1.08) or 0
  local leanTgtRisk = (load > 0.6 and not model.decelCut) and max(0, lambdaTrue - lambdaTarget - 0.08) or 0
  -- floored = the car is simply at its own spring/stock boost (target is below the hardware floor);
  -- that is not over-tuned abuse, so it must not grind engine damage (V2.6, with the fault exemption)
  local damageBoostTarget = model.boostTargetBelowSpring
    and max(model.boostTargetKpa, model.boostHardwareFloorKpa or model.boostTargetKpa) or model.boostTargetKpa
  local overboostRisk = (base.hasTurbocharger and not model.boostFloored) and max(0, mapKpa - (damageBoostTarget + (settings.boostOvercut or 25))) / 60 or 0
  -- Mechanical over-rev begins at BeamNG's maxPhysicalAV threshold, not at whichever ECU happened
  -- to be fitted when the vehicle spawned. The old stock-limiter+200 rule damaged the same engine at
  -- 7500 rpm through OE while a Race ECU at 7500 remained safe. Control path must not change physics.
  local physicalLimitRpm = base.maxPhysicalRPM
  local rpmRisk = physicalLimitRpm and (max(0, rpm - physicalLimitRpm) / 800) or 0
  local heatRisk = max(0, model.ectC - 112) / 18
  local oilRisk = max(0, model.oilC - 135) / 22
  local rate, reason = 0, "OK"
  local strength = effBlockStrength()   -- stronger block survives more mechanical abuse
  -- rates are per-second: clear abuse grenades the motor in ~5-20s (protection slows it).
  -- mechanical-stress channels (knock/lean/boost/rpm) scale with block strength: a forged block
  -- still detonates the same, it just survives it longer. thermal channels (heat/oil) do NOT
  -- scale -- forged internals don't help the engine shed heat.
  if knockRisk > 0 then rate = rate + knockRisk * 0.11 / strength; reason = "Knock" end
  if leanRisk > 0 then local c = leanRisk * 0.9 / strength; rate = rate + c; if c >= rate * 0.5 then reason = "Lean under load" end end
  if leanTgtRisk > 0 then rate = rate + leanTgtRisk * 0.5 / strength end
  if overboostRisk > 0 then local c = overboostRisk * 0.5 / strength; rate = rate + c; if c >= rate * 0.5 then reason = "Overboost" end end
  if rpmRisk > 0 then local c = rpmRisk * 0.3 / strength; rate = rate + c; if c >= rate * 0.5 then reason = "Over-rev" end end
  -- V3.0.9: valve float is a damage INPUT to this one channel, not a parallel system. A real log showed
  -- why it has to be here: float cuts power, which stops the engine climbing, which parks it in a
  -- self-limiting equilibrium just BELOW the over-rev threshold - 7866 against a 7875 rpmRisk onset. So
  -- floating indefinitely cost nothing at all, because the only channel that could punish it was one
  -- the float had made unreachable. Feeding the same `rate` keeps a single damage owner (no
  -- double-billing, one reason string, existing UI and blown handling) while making sustained float
  -- genuinely terminal. 0.12 vs over-rev's 0.3: bouncing the valves is hard on a head, but it is not
  -- the same as spinning the bottom end past its mechanical limit.
  if model.floatSeverity > 0 then local c = model.floatSeverity * 0.12 / strength; rate = rate + c; if c >= rate * 0.5 then reason = "Valve float" end end
  if heatRisk > 0 then local c = heatRisk * 0.25; rate = rate + c; if c >= rate * 0.5 then reason = "Coolant heat" end end
  if oilRisk > 0 then local c = oilRisk * 0.2; rate = rate + c; if c >= rate * 0.5 then reason = "Oil heat" end end
  if protectionActive then rate = rate * 0.4 end

  model.damageRate = rate
  model.damageReason = rate > 0.005 and reason or "OK"
  model.damage = clamp(model.damage + rate * dt, 0, 1)
  local health = 100 * (1 - model.damage)
  if health > 85 then model.damagePower = 100
  else model.damagePower = clamp(45 + health * 0.55, 45, 100) end

  -- icon follows real accumulated damage (not a transient blip), so it matches the ECU
  setEngineDamageFlag(model.damage > 0.03)

  -- catastrophic: sustained abuse grenades the motor (vanilla blow sequence)
  if not model.blown and model.damage >= 0.9 and engine and engine.lockUp then
    model.blown = true
    engine:lockUp()
    guihooks.message({ txt = "OE ProTune: engine destroyed (" .. model.damageReason .. ")" }, 6, "vehicle.damage.oeprotune")
    if engine.engineBlockNodes and #engine.engineBlockNodes >= 2 then
      pcall(function()
        sounds.playSoundOnceFollowNode("event:>Vehicle>Failures>engine_explode", engine.engineBlockNodes[1], 1)
        for i = 1, 40 do
          local rnd = random()
          obj:addParticleByNodesRelative(engine.engineBlockNodes[2], engine.engineBlockNodes[1], i * rnd, 43, 0, 1)
          obj:addParticleByNodesRelative(engine.engineBlockNodes[2], engine.engineBlockNodes[1], -i * rnd, 39, 0, 1)
        end
      end)
    end
    local blownMsg = { vehId = (obj and obj.getID) and obj:getID() or 0, reason = model.damageReason }
    guihooks.trigger("OEProTuneBlown", blownMsg)
    if remoteEmit then remoteEmit("OEProTuneBlown", blownMsg) end
  end
end

-- ---------------------------------------------------------------------------
-- main per-frame update
-- ---------------------------------------------------------------------------

-- e-throttle plate release (V2.9.7): hand the throttleOverride channel back. Called on the falling
-- edge of Connect, on Cable mode, on vehicle reset and when no actuator responds. Only clears the
-- channel if the value in it is still OURS - never stomps cruise control's (or a script's) override.
function H.releaseEthrottle()
  if H.ethOverrideSent ~= nil and electrics.values.throttleOverride == H.ethOverrideSent then
    electrics.values.throttleOverride = nil
  end
  H.ethOverrideSent = nil
  H.ethNoActTimer = 0
  H.ethNoActMin, H.ethNoActMax = nil, nil
  model.ethrottleErrTimer = 0   -- a released plate has nothing to track: never carry a stale fault timer across disconnect/reset/mode switches
end

local mapFilt = nil   -- light MAP-sensor filter state (see readInputs)
local function readInputs(dt)
  local rpm = ev("rpm", 0) or 0
  local tps = clamp((ev("throttle", 0) or 0) * 100, 0, 100)
  local load = clamp(engine and (engine.instantEngineLoad or 0) or 0, 0, 1.8)
  -- BeamNG exposes the current environmental pressure in Pa. Use it as the
  -- absolute-pressure reference instead of assuming sea-level 100 kPa.
  local envPa = tonumber(powertrain and powertrain.currentEnvPressure) or 101325
  if envPa ~= envPa or envPa < 1000 or envPa > 200000 then envPa = 101325 end
  local baroKpa = envPa * 0.001
  -- "boost" is the game's COMBINED channel (turboBoost + superchargerBoost, electrics.lua:431) - the
  -- old turboBoost-only read left SUPERCHARGERS on the load-based estimate below, which caps at
  -- ~11.6 psi (load clamps at 1.8): big blowers read wrong boost/MAP, wrong table cells, wrong ceiling.
  local boostKpa = base.hasTurbo and max(0, (ev("boost", 0) or 0) * PSI_TO_KPA) or 0
  -- some chargers (custom "variable boost" mods etc.) never feed the boost electrics, but
  -- instantEngineLoad still climbs above 1.0 under boost - use that as a fallback boost signal so
  -- the boost gauge, boost cells and the hardware-needs-tuning lean all actually engage
  if base.hasTurbo then boostKpa = max(boostKpa, max(0, load - 1.0) * baroKpa) end
  -- Gasoline engines model throttle-created vacuum. A diesel is essentially
  -- unthrottled, so its off-boost manifold stays near barometric pressure.
  local baseMapKpa
  if effDiesel() then
    baseMapKpa = baroKpa
  else
    local vacFrac = clamp(0.30 + load * 0.70, 0.30, 1.0)
    -- V3.0.7 INDIVIDUAL THROTTLE BODIES (CosmicEgg's report). Until now fitting ITBs changed nothing
    -- about the manifold-pressure signal, so Alpha-N was a correctly-labelled control sitting on top
    -- of a problem the sim did not have: speed density worked fine on an ITB build and switching
    -- load source only relabelled rows.
    --
    -- The real mechanism: a plenum is a volume big enough to hold vacuum and average the intake
    -- events of every cylinder. ITBs delete it. Each cylinder breathes through its own throttle into
    -- a runner of almost no volume, so once that throttle is even part way open it stops being a
    -- restriction and the runner sits at atmospheric. Manifold pressure therefore collapses its
    -- whole useful range into the first fraction of pedal travel and reads flat above it - which is
    -- exactly why an ITB engine cannot be tuned on a MAP load axis, and why real ITB calibrations
    -- index on throttle position instead.
    --
    -- Modelled as a remap of the SAME vacuum fraction against throttle angle rather than load,
    -- because the physical driver is how far the butterfly is open. Above ITB_SAT_TPS the signal is
    -- pinned at barometric. Blended by itbAbs so a part-ITB classification degrades smoothly.
    --
    -- The pulsation half (discrete intake events with no plenum to average them) is deliberately NOT
    -- modelled: the ~60ms filter below exists precisely to remove that flicker, so injecting it here
    -- to filter it out again would be circular, and jitter on the single most load-bearing signal in
    -- the controller buys texture rather than a tuning consequence. Saturation alone is what
    -- collapses the load axis, and that is the whole reason Alpha-N exists.
    local itb = clamp(hw.itbAbs or 0, 0, 1)
    if itb > 0 then
      local satFrac = clamp(0.30 + (tps / H.ITB_SAT_TPS) * 0.70, 0.30, 1.0)
      vacFrac = vacFrac + (satFrac - vacFrac) * itb
    end
    baseMapKpa = baroKpa * vacFrac
  end
  local mapKpa = baseMapKpa + boostKpa
  -- MAP SENSOR FILTER (V2.6.x): MAP is derived from instantEngineLoad, which pulses with individual
  -- firing events at idle on lumpy engines - the raw value flapped 30<->100 kPa sample to sample
  -- (visible pre-connect in exported logs). Real ECUs average MAP across an engine cycle; ~60ms of
  -- settle here kills the flicker and adds no meaningful lag to spool (which spans hundreds of ms).
  mapFilt = mapFilt and (mapFilt + (mapKpa - mapFilt) * clamp((dt or 0.016) * 25, 0, 1)) or mapKpa
  return rpm, tps, load, boostKpa, mapFilt, baroKpa
end

local function updateModel(dt)
  local rpm, tps, load, boostKpa, mapKpa, baroKpa = readInputs(dt)

  -- sensor calibration: raw physical vs ECU-sensed
  model.rawTps, model.rawMapKpa = tps, mapKpa
  local sensedTps = clamp(tps * (settings.sensorTpsScale or 1) + (settings.sensorTpsOffset or 0), 0, 100)
  local sensedMap = clamp(mapKpa * (settings.sensorMapScale or 1) + (settings.sensorMapOffset or 0), 20, mapMeta.boost.max)   -- V3.0: sensor ceiling follows the shared meta (was 600) - keep in sync with the updateMonitor copy
  local sensedEct = clamp(model.ectC + (settings.sensorEctBias or 0), -20, 160)
  model.rpm, model.tps, model.load = rpm, sensedTps, load
  model.mapKpa, model.boostKpa = sensedMap, boostKpa
  model.baroKpa = baroKpa
  model.mapGaugePsi = (sensedMap - baroKpa) * KPA_TO_PSI
  model.vacuumInHg = max(0, baroKpa - sensedMap) * 0.295299875
  -- adapt the factory boost target to the boost this car ACTUALLY makes. Fed the RAW (physical) MAP,
  -- not the sensor-calibrated one, so a MAP scale/offset in Sensor Setup can't inflate the reference.
  learnBoost(mapKpa, dt)

  updateThermal(dt, rpm, sensedTps, boostKpa, load)

  -- diesel mode (#4): branch the combustion model. Everything shared (boost control, thermal, faults
  -- framework, dyno, aux) still runs; only the fuel/lambda/knock/EGT/power core changes below.
  local diesel = effDiesel()
  model.isDiesel = diesel

  -- table lookups at the live cell
  -- Fuel Main is a SCALAR table (1.0 = follow lambda target, 1.10 = +10%); convert to the % trim the model
  -- uses. SAFETY: a real scalar cell is always 0.5..2.0, so a value <= 0.4 (e.g. a 0 from an old/stale-cached
  -- UI still sending the legacy 0-centered trim) is used DIRECTLY as a trim, never (0-1)*100 = -100% lean.
  local fv = lookupMap("fuel", rpm, sensedMap, sensedTps)
  local fuelTrim = ((fv <= 0.4) and fv or ((fv - 1) * 100)) + (settings.globalFuelTrim or 0)
  local ignTrim = lookupMap("ignition", rpm, sensedMap, sensedTps) + (settings.globalIgnitionTrim or 0)
  local boostTarget = base.hasTurbo and lookupMap("boost", rpm, sensedMap) or 0
  -- GAUGE HONESTY (V2.6): a factory/untouched boost table isn't chased (stock passthrough), so the
  -- displayed/faulted target is the boost actually being REGULATED - the spring - not the phantom
  -- auto-seed (a fresh Vivace used to show a 38 psi "target" nothing was commanding). Also keys the
  -- overboost fault margin to reality. Superchargers keep the table (it's a ceiling there).
  -- (V2.9.7 wave-2 review: a proposed "> 0.5 validity" guard here was tested and REVERTED - the
  -- game's own gate regulates at wastegateStart + range/2 with NO lower bound, so baro+spring is
  -- the physically correct display even for a sub-0.5-psi spring; the guard would re-show the
  -- phantom auto-seed target this site exists to suppress. Truthy is right; leave it truthy.)
  if boostTableFactory and base.hasTurbocharger and (settings.boostMode or 2) ~= 1 and base.wgSpringTargetPSI then
    boostTarget = (model.baroKpa or 101.325) + base.wgSpringTargetPSI * PSI_TO_KPA
  end
  boostTarget = H.shapeBoostTarget(dt, boostTarget)
  local lambdaTarget = clamp(lookupMap("lambda", rpm, sensedMap, sensedTps), 0.68, 1.10)
  model.lambdaTarget = lambdaTarget
  model.boostTargetKpa = boostTarget

  -- cylinder trims: 8 on engines whose parsed spec says 7+ cylinders (V8/V10/V12 - the UI shows
  -- Cyl 7/8 under the same condition so the two always agree), 6 otherwise (unchanged behaviour).
  -- avg divides by the live count so unused trims don't dilute it.
  local nCyl = (engSpec.cyl >= 7) and 8 or 6
  local cylMin, cylMax, cylSum = 99, -99, 0
  for cyl = 1, nCyl do
    local t = clamp(settings["cylTrim" .. cyl] or 0, -10, 10)
    cylMin = min(cylMin, t); cylMax = max(cylMax, t); cylSum = cylSum + t
  end
  model.cylTrimAvg = cylSum / nCyl
  model.cylTrimSpread = cylMax - cylMin

  -- boost control + apply
  updateBoostController(dt, rpm, sensedTps, sensedMap, boostTarget)
  applyBoost()

  -- fuel
  local startupTrim = updateStartupFuel(rpm, sensedEct)
  local transientTrim = updateTransientFuel(dt, sensedTps, rpm)
  local fuelModelTrim = startupTrim + transientTrim + model.cylTrimAvg
  local lambdaActual, adaptive, lambdaTrue = updateFuelModel(dt, rpm, sensedTps, boostKpa, load, lambdaTarget, fuelTrim, fuelModelTrim, sensedEct, sensedMap)
  model.effFuelTrim = ((model.commandedFuelFactor or 1) * (1 + adaptive / 100) - 1) * 100

  -- DIESEL combustion core (#4): no spark/lambda. Pedal = fuel demand; the Fuel Main table is the fuel
  -- QUANTITY multiplier and Ignition Main is INJECTION TIMING (deg). Power rises with fuel, but only the
  -- fuel the air (boost) can burn makes power - the rest is black SMOKE + EGT. More boost or rail
  -- pressure raises the clean-fuelling headroom. Computed here so the EGT/fault/damage/power sections
  -- below can branch on it; stays inert (dieselPower = 1) for spark engines.
  local dieselPower, dieselDemand, dieselFuelMult, dieselAirSupply = 1, 0, 0, 1
  if diesel then
    local dfv = clamp((fv > 0.4) and fv or 1, 0.5, 2.0)                      -- Fuel Main scalar (1.0 = baseline)
    local maxFuelPct = clamp(settings.dieselMaxFuel or 100, 60, 260) / 100   -- the "turn it up" lever
    local railBar = clamp(settings.dieselRailPressure or 1200, 300, 2200)
    local smokeLimit = clamp(settings.dieselSmokeLimit or 17, 12, 24)        -- target AFR floor (below = smoke)
    local railFactor = clamp(0.85 + railBar / 6000, 0.7, 1.35)              -- finer atomisation burns more fuel cleanly
    dieselDemand = clamp(sensedTps / 100, 0, 1)                              -- no throttle plate: pedal = fuel command
    dieselFuelMult = dfv * maxFuelPct                                        -- tune fuelling (1.0 = stock-equivalent)
    local fuelInj                                                            -- fuel actually delivered this instant

    -- REVIEW FIX 2026-08-03 (A3). The corrected air model now follows EITHER V3.0.4 switch, not just
    -- the nozzle one. Before this, limiter-ON + nozzle-OFF clamped against the LEGACY
    -- `(sensedMap/100) * railFactor` - the exact rail-pressure-creates-air error the nozzle branch's
    -- own comment below denounces - so at fixed boost the rail slider bought +41% power with smoke 0,
    -- AFR pinned at the limit and cooler EGT, inside the mode marketed as the honest ECU. Rail's
    -- legitimate effect is already carried separately by railPower, so it was double counted.
    -- Both switches still default false, so the frozen legacy path is untouched.
    if settings.dieselNozzleEnabled == true or settings.dieselSmokeLimiter == true then
      -- V3.0.4 NOZZLE MODEL (opt-in). Two things are corrected here at once, which is why it is one
      -- switch rather than two.
      --
      -- 1. THE INJECTOR CAN NOW RUN OUT. Until now nothing limited diesel fuelling: commanding 260%
      --    delivered 260%. Real injectors have a nozzle flow rate, and past it the extra commanded
      --    fuel simply is not there. That limit is the whole reason bigger nozzles exist.
      -- 2. RAIL PRESSURE IS MOVED TO WHERE IT PHYSICALLY ACTS. The legacy path multiplies AIR by a
      --    rail-pressure factor, which is not a thing rail pressure does - it cannot create air.
      --    Pressure drives flow through the nozzle orifice as sqrt(dP) (Bernoulli), and finer
      --    atomisation makes the fuel that IS injected burn more completely.
      --
      -- Nozzle flow is quoted at a 1200 bar reference like a real spec sheet, so doubling rail
      -- pressure does NOT double flow - it multiplies it by sqrt(2000/1200) = 1.29, exactly as it
      -- would on an engine. Default nozzle is auto-sized to the engine with ~35% headroom over stock
      -- fuelling, which is where real factory nozzles sit; turn fuelling up past that and you are
      -- nozzle-limited until you fit bigger ones or raise rail pressure.
      local refFlow = clamp(floor(600 * max(1, (base.peakPowerHp or 0) / 320)), 600, 6000)
      local nozzleFlow = clamp(settings.dieselNozzleFlow or (refFlow * 1.35), 200, 6000)
      local nozzleCap = (nozzleFlow / refFlow) * sqrt(railBar / 1200)         -- orifice flow ~ sqrt(dP)
      local commanded = dieselDemand * dieselFuelMult
      -- The nozzle CAP is still gated on its own switch: A3 shares the corrected AIR model between the
      -- two switches, not the hardware flow limit. Limiter-only keeps commanding whatever you asked
      -- for and lets the ECU refuse it; only the nozzle model says the injector physically cannot.
      if settings.dieselNozzleEnabled == true then
        fuelInj = min(commanded, nozzleCap)                                   -- past the nozzle there is no more fuel
        model.dieselNozzleDuty = clamp(commanded / max(0.01, nozzleCap) * 100, 0, 200)
        model.dieselNozzleLimited = commanded > nozzleCap + 1e-6
      else
        fuelInj = commanded
        model.dieselNozzleDuty, model.dieselNozzleLimited = 0, false
      end
      -- Atomisation acts on COMBUSTION QUALITY, not on air quantity: finer spray burns more of the
      -- fuel that is already in the cylinder, so it shows up as less soot at a given AFR.
      dieselAirSupply = clamp(sensedMap / 100, 0.25, 4.5)
      model.dieselAtomisation = clamp(0.88 + railBar / 9000, 0.85, 1.15)
    else
      -- Legacy path, byte-identical to V3.0.3 and earlier so every saved diesel tune fuels exactly
      -- as it always has. Do not "tidy" this into the branch above - the difference IS the point.
      dieselAirSupply = clamp((sensedMap / 100) * railFactor, 0.25, 4.5)     -- air available to burn fuel
      fuelInj = dieselDemand * dieselFuelMult
      model.dieselNozzleDuty, model.dieselNozzleLimited, model.dieselAtomisation = 0, false, 1
    end
    -- REVIEW FIX 2026-08-03 (A1). Two DIFFERENT quantities used to be one, and the slider moved both.
    --   airBurnCap  = PHYSICS. The fuel quantity at which AFR reaches 17, i.e. where soot actually
    --                 begins. On a real diesel that is combustion chemistry and no calibration screw
    --                 moves it. Drives model.smoke and the clean-burn power cap.
    --   limiterCap  = JUDGMENT. Where the ECU chooses to stop delivering fuel. That IS the slider's
    --                 job, and its only job.
    -- Wiring dieselSmokeLimit into both meant lowering it ADDED 37% torque and DELETED the visible
    -- coal, in the free-power-by-lowering direction, and three lessons taught the artifact as physics.
    -- Turning the screw up on a real truck gives more power WITH more smoke; now it does here too.
    -- At the default 17 the two are numerically identical, so default tunes are untouched.
    local airBurnCap = dieselAirSupply                                       -- 17 * air / 17
    local limiterCap = 17 * dieselAirSupply / smokeLimit                     -- fuel level where AFR hits the CHOSEN limit

    -- V3.0.4 SMOKE LIMITER (opt-in). This is the OTHER half of Eddie's request and it is a genuinely
    -- different constraint from the nozzle, which is why they are separate switches:
    --   * the NOZZLE limit is HARDWARE - the injector physically cannot flow more fuel.
    --   * the SMOKE LIMITER is the ECU CHOOSING not to deliver fuel the measured air cannot burn.
    -- Every real diesel ECU has one. It is why a stock truck will not roll coal however hard you
    -- mash the pedal: commanded fuel is clamped against measured air (MAF/MAP) before injection.
    --
    -- Without it, the model lets the player dump raw fuel and merely punishes them with soot and a
    -- 15% power credit for the unburnt part. With it on, the ECU refuses in the first place - fuel is
    -- clamped to airBurnCap, smoke stays near zero, and the way to make more power becomes the real
    -- one: MORE AIR (boost), not more fuel. That is the single most important lesson in diesel
    -- tuning and the legacy model could not teach it.
    --
    -- NOTE ON WHY THIS IS NOT A MAP: a real limiter is a fuel-vs-air map, and this mod's map
    -- infrastructure could carry one - but every saved tune stores exactly six tables and both
    -- copyMapBankMaps (app.js:5170) and mapSetValidationIssue (app.js:5931) reject a package with a
    -- missing table. Adding a seventh would make every existing tune fail to load. The scalar AFR
    -- floor already models the mechanism honestly; the map version needs a save migration first.
    model.dieselLimiterCut = 0
    if settings.dieselSmokeLimiter == true then
      local commandedFuel = fuelInj
      -- REVIEW FIX 2026-07-27: clamp against airBurnCap ALONE. airBurnCap is already a fuel QUANTITY -
      -- it is the fuelInj at which model.afr (below) equals smokeLimit - so multiplying it by pedal a
      -- second time made the limiter fire at part throttle where the mixture was already 4+ points
      -- LEANER than the limit and model.smoke was identically zero, reporting up to 60% "fuel cut"
      -- for soot that did not exist. The NOZZLE limit ten lines above already clamps the quantity
      -- with no pedal term; the two limits must speak the same units. The bug was invisible at WOT
      -- (dieselDemand = 1), which is why a validation sweep taken entirely at full pedal agreed with
      -- both forms.
      fuelInj = min(fuelInj, limiterCap)   -- A1: the ECU clamps at the CHOSEN limit, not at the soot point
      if commandedFuel > 1e-6 then
        model.dieselLimiterCut = clamp((commandedFuel - fuelInj) / commandedFuel * 100, 0, 100)
      end
    end

    model.afr = clamp(17 * dieselAirSupply / max(0.05, fuelInj), 8, 99)      -- diesel runs lean; AFR drops as you over-fuel
    -- A1: soot forms against the FIXED chemistry point (17), not against the calibration screw.
    model.smoke = clamp((17 - model.afr) / 17 * 130 / (model.dieselAtomisation or 1), 0, 100) -- black smoke when AFR falls below the soot point; finer atomisation burns soot away
    -- power: credit clean (air-burnable) fuel fully, smoke fuel barely (it just makes soot + heat)
    -- Power follows the fuel that was actually DELIVERED. With the nozzle model or the smoke limiter
    -- on, fuel the engine never received makes no power - that is the point of both limits. Legacy
    -- path passes the commanded value through unchanged (fuelInj == dieselDemand * dieselFuelMult).
    local fuelWasLimited = (settings.dieselNozzleEnabled == true) or (settings.dieselSmokeLimiter == true)
    -- REVIEW FIX 2026-07-27: fall back to the commanded multiplier at closed throttle instead of
    -- dividing by the max(0.01, ...) floor. With either new switch on, pedal 0 gave fuelInj 0 and so
    -- deliveredMult 0 - not dieselFuelMult as the legacy path yields - collapsing model.coef to ~0.53
    -- and halving idle torque, which BeamNG's own idle path (combustionEngine idleTorque / lockUp
    -- check) can turn into a stall or a seize on a heavy diesel. Ticking a fuelling-at-load box must
    -- not change what the engine does with the driver's foot off it.
    local deliveredMult = (fuelWasLimited and dieselDemand > 0.01) and (fuelInj / dieselDemand) or dieselFuelMult
    -- REVIEW FIX 2026-08-03 (A2). Published so the EGT model can heat the exhaust with the fuel that
    -- was actually DELIVERED. It used to use the commanded multiplier, so fuel the limiter or the
    -- nozzle refused still heated the exhaust: limiter on at WOT with maxFuel 260 x Fuel-Main 2.0 gave
    -- an EGT target of 935 C against 561 C real, and six degrees of retard pushed it past both the
    -- 950 C DTC (which latches for the session) and the 1010 C damage threshold - while the limiter
    -- cut read 80%, AFR pinned at the limit and smoke was 0. The mod's whole pitch is that the limits
    -- refuse fuel; EGT was then punishing the player for obeying them.
    model.dieselDeliveredMult = deliveredMult
    -- REVIEW FIX 2026-07-27: the 15% credit models power from fuel that entered the cylinder but burnt
    -- late and left as soot. With the smoke limiter ON that fuel provably does not exist - fuelInj is
    -- clamped to airBurnCap, so AFR sits exactly at the limit and model.smoke above is identically 0 -
    -- yet deliveredMult is a per-pedal multiplier and airBurnCap is an absolute quantity, so below WOT
    -- the difference was non-zero and paid a soot credit for soot the same frame reports as absent.
    -- Zero it while the limiter owns fuelling; the legacy and nozzle-only paths keep the original term.
    -- REVIEW FIX 2026-08-03 (B1). The comparison now happens in FUEL-QUANTITY units. deliveredMult is
    -- per-pedal and airBurnCap is absolute, so below WOT the old form capped part-throttle power
    -- against a full-throttle air quantity - the same class as the pedal-term bug fixed on 2026-07-27,
    -- and equally invisible at WOT where dieselDemand is 1. Quantities are converted back to a
    -- multiplier once, at the end. The legacy path (neither switch) is unchanged: fuelInj ==
    -- dieselDemand * dieselFuelMult there, so fuelInj / dieselDemand == deliveredMult exactly.
    --
    -- MAGNITUDE, corrected 2026-08-04 - the first version of this comment badly understated it.
    -- The cap is NOT deleted: it still binds whenever injected fuel exceeds burnable air, which is
    -- the physically correct condition, and both versions agree exactly at WOT. What changed is that
    -- it no longer binds where it never should have. Worked example, maxFuel 200% x Fuel Main 1.0,
    -- MAP 101 kPa, smoke limit 17, limiter ON:
    --   pedal 0.30 -> 0.60 units injected against 1.013 of burnable air, AFR 28.7, limiter cut 0%.
    --   3.0.6 returned coef 0.966 - LESS than a stock tune - for fuel that entirely burned.
    --   HEAD returns 1.392. At pedal 0.70 the cut engages (28%) and at 1.00 the two agree again.
    -- So an existing V3.0.4-era limiter or nozzle tune GAINS part-throttle torque on update. That is
    -- the bug being fixed rather than a new one, but it is a real user-visible change and it is
    -- called out in CHANGES_3.0.8_since_3.0.6.md rather than left for someone to discover.
    -- REVIEW FIX 2026-08-04: the soot credit is no longer gated on "is the limiter on". That gate was
    -- written when limiterCap and the soot point were the SAME quantity (both 17*air/smokeLimit), so
    -- clamping to the limiter guaranteed AFR == the limit and zero soot, and discarding the credit was
    -- right. A1 split them: the limiter now clamps at the player's chosen limit while soot forms at the
    -- fixed 17. Below a limit of 17 the limiter therefore permits fuel PAST the soot point, and the
    -- gate threw away a credit for soot the same frame reported as present - enabling the limiter cost
    -- power while cutting 0% of the fuel. Measured at limit 12, WOT: AFR 14.17, smoke 22, cut 0%, yet
    -- fuelPower fell 1.014 -> 1.000.
    -- Dropping the gate is correct at EVERY setting and needs no replacement condition, because the
    -- subtraction already answers the question: limit 17 clamps exactly at the soot point (soot fuel is
    -- 0, credit 0, byte-identical to before); limit 24 clamps below it (also 0); limit 12 leaves real
    -- soot fuel and now credits it. The legacy branch keeps its own form - the gate there was already
    -- dead, since that branch is only reached when neither switch is on.
    local cleanFuel = min(fuelInj, airBurnCap)
    local sootFuel = 0.15 * max(0, fuelInj - airBurnCap)
    local effFuelMult = (fuelWasLimited and dieselDemand > 0.01)
      and ((cleanFuel + sootFuel) / dieselDemand)
      or (min(deliveredMult, airBurnCap) + 0.15 * max(0, deliveredMult - airBurnCap))
    local fuelPower = clamp(0.55 + 0.45 * effFuelMult, 0.4, 1.9)
    local timingPower = clamp(1 + ignTrim * 0.005 - max(0, ignTrim - 6) * 0.015, 0.85, 1.08)  -- a little injection advance helps; too much plateaus
    local railPower = clamp(0.96 + (railBar - 1200) / 9000, 0.92, 1.07)
    dieselPower = fuelPower * timingPower * railPower
    model.dieselStatus = model.blown and "Engine dead" or (model.smoke > 55 and "Smoking - over-fuelled"
      or (model.afr > 40 and dieselDemand > 0.6 and "Lean - room for more fuel" or "Running"))
  else
    model.smoke = 0
  end

  -- V3.0.4 TRANSMISSION CONTROL. Two levers, both real device fields that nothing rewrites per
  -- frame, so the mod can own them without fighting the stock shift logic.
  --
  -- SHIFT FIRMNESS maps to the gearbox's own minimumGearChangeTime (automaticGearbox.lua:401,
  -- consumed at :244 to set the ratio-change rate). On a real auto this is line pressure: higher
  -- pressure clamps the clutches faster, giving a short hard shift; lower pressure slips them longer
  -- for a soft one. Slipping longer is what cooks a transmission, which is the lesson.
  --
  -- TORQUE LIMIT maps to engine.maxTorqueLimit (combustionEngine.lua:755, default math.huge, only
  -- ever written at init) - a real ECU torque limiter, used to protect a drivetrain in the low gears
  -- where multiplication is highest. Applied per-gear so 1st/2nd can be capped while top gear is
  -- left alone, exactly as factory calibrations do it.
  H.updateShiftFirmness()
  H.updateClutchPackage()

  -- V3.0.5 TORQUE CONVERTER - stall speed + live slip. The single biggest drag transmission lever.
  --
  -- BeamNG's converter absorbs torque as  inputTorque = kFactor * kFactorCoef * inputAV^2
  -- (torqueConverter.lua:36), where kFactorCoef = fluidDensity * diameter^5 (:181, density 844).
  -- At stall the converter is holding the engine's torque AT THAT RPM, so the stall speed solves
  --      T = kFactor(stall) * kFactorCoef * AV^2
  -- and a SMALLER converter absorbs less at a given rpm, letting the engine flash higher. That is
  -- exactly how a real stall converter works, and it is why stall is a converter/ENGINE pairing
  -- rather than a property of the converter alone - the same unit stalls higher behind a bigger
  -- motor. We solve that equation for kFactorCoef, so the player asks for a stall RPM like they
  -- would when ordering a converter, and gets the hardware that produces it.
  H.updateConverterStall()
  if H.converter and base.stockKFactorCoef then
    -- Live slip: real numbers straight off the device, not an estimate. Slip RPM is what a drag
    -- racer watches on the trans brake. Fluid loss is input shaft power minus output shaft power,
    -- using the converter's own exposed torque/AV fields (torque multiplication included).
    local inAV, outAV = H.converter.inputAV or 0, H.converter.outputAV1 or 0
    local inputPowerW = abs((H.converter.torqueDiff or 0) * inAV)
    local outputPowerW = abs((H.converter.outputTorque1 or 0) * outAV)
    model.converterSlipRpm = abs(inAV - outAV) / rpmToAV
    model.converterSlipKw = max(0, inputPowerW - outputPowerW) * 0.001

    -- V3.0.5 SLIP HEAT - the cost that was missing.
    --
    -- The slip POWER above is measured from converter input/output power. Where that
    -- energy goes is a MODEL and is labelled an estimate on the page, because BeamNG carries no ATF
    -- thermal state at all - torqueConverter.lua has no temperature, unlike frictionClutch.lua which
    -- does. What is NOT invented is the physics: every watt of slip becomes heat in the fluid, a
    -- converter holds roughly ten litres of it, and a cooler rejects heat in proportion to how far
    -- above ambient it is and how much air is going through it.
    --
    -- This is what makes the rest of the page honest. Sitting on the trans brake at full throttle
    -- dumps the ENTIRE engine output into the fluid - that is why you get a few seconds on the
    -- button, not a minute. A soft, long shift slips the clutches longer and heats them more than a
    -- firm one. Neither had any consequence before this.
    --
    -- When the player has explicitly selected a custom stall, the consequence uses a real device
    -- field: hot fluid loses lockup-clutch capacity. At converterStall=0 the estimator can still be
    -- displayed, but OE never writes lockupClutchTorque, preserving neutral/default behavior.
    local ambientC = 40
    local speedKmh = (ev("wheelspeed", 0) or 0) * 3.6
    local shiftSlipKw = (H.evOn("isShifting") and (model.shiftTimeMs or 0) > 0)
      and (abs(model.engineTorqueNm or 0) * (model.rpm or 0) * rpmToAV * 0.0002) or 0
    local heatW = (model.converterSlipKw + shiftSlipKw) * 1000
    local coolW = max(0, (model.transTempC or ambientC) - ambientC) * (20 + speedKmh * 2)
    model.transTempC = clamp((model.transTempC or ambientC) + (heatW - coolW) / 20000 * dt, ambientC, 220)

    -- Fluid past ~120C starts losing clutch capacity; by ~180C it is badly down. Restores as it cools.
    H.updateConverterLockupFade()
    model.transTempStatus = model.transTempC > 175 and "OVERHEATED - clutch slipping"
      or (model.transTempC > 140 and "Very hot" or (model.transTempC > 110 and "Hot" or "OK"))
  end

  H.updateTorqueLimit()

  -- V3.0.4 COMPRESSION (JAKE) BRAKE - real mechanism, diesel only.
  --
  -- A compression-release brake holds the exhaust valve open near TDC so the work spent compressing
  -- the charge is dumped out of the stack instead of being returned to the piston. It is a HEAVY
  -- DIESEL device, which is why this is gated on the engine actually being a diesel rather than
  -- offered on everything.
  --
  -- BeamNG models it properly: device.compressionBrakeCurve gives retarding torque vs RPM and
  -- setCompressionBrakeCoef sets how much of it to apply (combustionEngine.lua:1058-1060, applied
  -- as a real torque term at :778 and integrated at :794). The engine ALSO zeroes it by itself
  -- whenever the throttle is off idle (:575), so we deliberately do not add our own throttle gate -
  -- the game's is the correct one and stays correct if they change it.
  --
  -- SCALING, and why it is not just level/100: combustionEngine.lua:1943 hands EVERY engine a
  -- default curve (500 Nm at 1000 rpm, 1500 Nm at 3000) when its part declares none, and that is a
  -- truck-sized curve. Only six vanilla parts declare a real one (us_semi x3, md_series, wl40).
  -- Applying the placeholder raw to a 2.0 car diesel would brake it like a semi. A real Jake brake
  -- makes retarding power on the order of the engine's own output, so the applied coefficient is
  -- capped to keep peak retarding torque near the engine's own peak torque. On a truck with a
  -- declared curve the cap is inactive and the manufacturer's real numbers are used as authored.
  -- V3.0.5 DETECT, DO NOT CREATE. base.hasCompBrake is resolved once at init: an engine only counts
  -- as having a compression brake if its PART declares a real torqueCompressionBrake curve. BeamNG
  -- hands every engine a placeholder curve when the part declares none (combustionEngine.lua:1943,
  -- marked "todo remove defaults"), so gating on "is a diesel" alone offered a Jake brake on engines
  -- that have no such hardware. Same rule the app uses for turbo vs NA: calibrate what is fitted,
  -- never invent it.
  H.updateCompressionBrake(diesel, rpm, sensedTps)

  -- knock potential
  local protectionEnabled = settings.protection ~= false
  local knockControl = settings.knockControl ~= false
  -- V2.9 fix: the ECU's IAT compensation reads the SENSED intake temp (physical iatC + the Sensor
  -- Setup bias) - the bias slider was stored but never read, so it did nothing while still wiping
  -- fuel learning. Physical effects (charge-density lean-out, knock, heat soak) stay on real iatC.
  local sensedIat = clamp(model.iatC + (settings.sensorIatBias or 0), -20, 130)
  model.iatTimingTrim = -max(0, sensedIat - 42) * 0.045
  -- hardware knock demand: forced induction / bigger (higher-comp) builds need LESS timing than
  -- stock; pulling ignition (negative trim) tunes it out
  -- Nitrous PHASE 2 (V2.9.x): the REAL N2O part is bottle + solenoid + jets - IT makes the power
  -- (the vanilla device adds torque to the engine and drains the tank; OE adds NO synthetic power
  -- anymore, so nothing can double-dip). The ECU's job is everything AROUND the spray, like real
  -- life: fuel enrichment + timing retard inside YOUR configured window, and honest consequences
  -- when the kit sprays untuned - no enrichment = a lean shot = knock/EGT risk. The hazard scale
  -- comes from the KIT's real rating (base.nitrousKitShotPct, read from its torque table in init).
  local n2oDev = (base.hasNitrous and engine) and engine.nitrousOxideInjection or nil
  local nitrousSpraying = (n2oDev and n2oDev.isActive == true) and true or false
  model.nitrousArmedHw = (n2oDev and n2oDev.isArmed == true) and true or false
  model.nitrousTankRatio = 0
  if n2oDev and n2oDev.getTankRatio then
    local okTank, tankR = pcall(n2oDev.getTankRatio)
    if okTank then model.nitrousTankRatio = clamp(tonumber(tankR) or 0, 0, 1) end
  end
  -- the ECU enrichment window: extra fuel + retard apply only where YOU configured them; a spray
  -- outside the window (or with enrichment disabled) goes unmitigated - exactly like a real kit
  -- plumbed around an untuned ECU. Runs to redline: the kit sprays there, so the fuel must follow.
  local nitrousEnrichOk = settings.nitrousEnabled == true and sensedTps > (settings.nitrousArmTps or 90)
    and rpm > (settings.nitrousArmRpm or 3500) and not model.blown
  local nitrousActive = nitrousSpraying
  local nShot = nitrousActive and clamp(base.nitrousKitShotPct or 25, 0, 60) or 0
  local nFuel = (nitrousActive and nitrousEnrichOk) and clamp(settings.nitrousFuel or 15, 0, 40) or 0
  local nRet = (nitrousActive and nitrousEnrichOk) and clamp(settings.nitrousRetard or 6, 0, 15) or 0
  if diesel then nitrousActive = false; nShot = 0 end
  model.nitrousActive = nitrousActive
  model.nitrousStatus = diesel and "N/A (diesel)"
    or (not base.hasNitrous and "No N2O kit installed")
    or (nitrousActive and (nitrousEnrichOk and "SPRAYING" or "SPRAYING UNTUNED - lean! (enrichment off or window not met)"))
    or (model.nitrousTankRatio <= 0.01 and "Bottle EMPTY - refill via the Parts menu")
    or (not model.nitrousArmedHw and "Disarmed - use the car's own N2O arm control")
    or (settings.nitrousEnabled == true and "Armed - enrichment ready" or "Armed (car) - ECU enrichment OFF")
  if diesel then
    -- compression ignition: no spark-knock. (Over-advanced injection timing makes harsh combustion,
    -- handled as a power plateau in the diesel power model above, not a detonation-retard loop.)
    model.knockLevel = 0
    model.knockRetard = approach(model.knockRetard, 0, 2, dt)
  else
  local hwKnock = 0
  if hw.turbo > 0 then hwKnock = hwKnock + max(0, (mapKpa - ((model.baroKpa or 101.325) + 10)) / 100) * 1.0 end -- gentler (was 2.2); threshold is 10 kPa of real boost
  if hw.displ > 0 then hwKnock = hwKnock + max(0, load) * 0.5 * hw.displ end
  hwKnock = max(0, hwKnock + min(0, ignTrim) * 0.13)
  -- knock rises with advance, lean mixture, load/boost and heat; gated by load so it never knocks at idle
  local knockPotential = (max(0, ignTrim) * 0.16
    + max(0, lambdaTrue - lambdaTarget) * 10
    + max(0, lambdaTrue - 1.0) * 5
    + max(0, mapKpa - (model.baroKpa or 101.325)) * (base.hasTurbo and 0.005 or 0.006) -- real boost above live barometric pressure
    + max(0, model.iatC - 45) * 0.03
    + (base.maxPhysicalRPM and max(0, rpm - base.maxPhysicalRPM + 300) * 0.0005 or 0)
    + hwKnock
    + (nitrousActive and max(0, nShot * 0.05 - nRet * 0.12 - nFuel * 0.05) or 0)   -- nitrous adds cylinder pressure; pulling timing + adding fuel tunes it out
    - max(0, 0.85 - lambdaTrue) * 9)   -- running RICH cools the charge and resists knock - the real lever for running more boost (richen + pull timing)
    * clamp(load + 0.2, 0, 1)
    * ((model.decelCut or sensedTps < 6) and 0 or 1)   -- no knock on a closed throttle / decel cut (overrun)
  knockPotential = knockPotential * fuelProps().knockResist   -- E85/E30 resist knock -> more timing/boost headroom
  knockPotential = clamp(knockPotential, 0, 6)
  updateKnock(dt, knockPotential, knockControl, protectionEnabled)
  model.knockLevel = clamp(knockPotential - model.knockRetard * 0.24, 0, 6)
  if model.knockLevel > (settings.knockThreshold or 1.2) and model.knockTimer <= 0 then
    model.knockCount = model.knockCount + 1
    model.knockTimer = 0.25
    model.damage = clamp(model.damage + (0.006 + max(0, model.knockLevel - (settings.knockThreshold or 1.2)) * 0.01) / effBlockStrength(), 0, 1)
    local knockMsg = { vehId = (obj and obj.getID) and obj:getID() or 0, count = model.knockCount, rpm = floor(rpm), load = load, level = model.knockLevel }
    guihooks.trigger("OEProTuneKnock", knockMsg)
    if remoteEmit then remoteEmit("OEProTuneKnock", knockMsg) end
  end
  if model.knockTimer > 0 then model.knockTimer = model.knockTimer - dt end
  end

  -- idle control: push the target idle into the engine governor (real effect)
  local speedKmh = (ev("wheelspeed", 0) or 0) * 3.6
  local targetIdle = (settings.idleEnabled ~= false) and (settings.idleTargetRpm or base.idleRPM) or base.idleRPM
  if engine.idleRPM and abs(engine.idleRPM - targetIdle) > 1 then
    engine.idleRPM = targetIdle
    -- V3.0 clutch fix: raise idle through idleAVOverwrite (the idle GOVERNOR reads max(idleAV,
    -- idleAVOverwrite)), NOT engine.idleAV. BeamNG's automatic clutch uses engine.idleAV as its
    -- anti-stall floor (shiftLogic forces clutchRatio = 0 below idleAV), so raising idleAV there
    -- dropped/slipped the clutch on launch for any engine idling below the target - the community
    -- "clutch slips with the app on, fine with it off" report. Leave idleAV at stock when raising;
    -- a below-stock target still writes idleAV (a LOWER clutch floor can never cause slip).
    if engine.idleAVOverwrite ~= nil then
      if targetIdle > (base.idleRPM or targetIdle) then
        engine.idleAVOverwrite = targetIdle * rpmToAV
        if engine.idleAV then engine.idleAV = (base.idleRPM or targetIdle) * rpmToAV end
      else
        engine.idleAVOverwrite = 0
        if engine.idleAV then engine.idleAV = targetIdle * rpmToAV end
      end
    elseif engine.idleAV then
      engine.idleAV = targetIdle * rpmToAV
    end
  end
  local idleActive = (settings.idleEnabled ~= false) and sensedTps < 1.5 and rpm > 300 and rpm < 1800 and speedKmh < 5
  model.idleActive = idleActive
  if idleActive then
    model.idleError = (settings.idleTargetRpm or 950) - rpm
    model.idleValvePos = clamp((settings.idleBasePos or 35) + model.idleError * 0.02, 0, 100)
    model.idleIgnTrim = (settings.idleIgnControl ~= false) and clamp(model.idleError * 0.012, -5, 8) or 0
    model.idleStatus = (settings.idleIgnControl ~= false) and "Active (pos+ign)" or "Active (pos)"
  else
    model.idleError = 0
    model.idleValvePos = settings.idleBasePos or 35
    model.idleIgnTrim = 0
    model.idleStatus = (settings.idleEnabled ~= false) and "Armed" or "Off"
  end

  -- anti-lag (turbo only effect, but status shown either way)
  -- anti-lag keeps a TURBINE lit - it's meaningless on a belt-driven blower, so gate on the real
  -- turbocharger flag (base.hasTurbo includes superchargers, which were wrongly building anti-lag
  -- heat/damage with zero function)
  local antilagActive = base.hasTurbocharger and (settings.antilagEnabled == true) and sensedTps < 12 and rpm > 3200
  model.antilagActive = antilagActive
  if antilagActive then
    model.antilagHeat = clamp(model.antilagHeat + dt * (12 + (settings.antilagFuel or 12) * 0.5), 0, 100)
    model.antilagStatus = "Active"
    model.iatC = clamp(model.iatC + dt * 2.5, 14, 110)
    model.damage = clamp(model.damage + dt * model.antilagHeat * 0.000011, 0, 1)
    -- actually keep the turbo spooled off-throttle (real effect, turbo only)
    if turbo and turbo.setAntilagCoef then turbo.setAntilagCoef(clamp((settings.antilagFuel or 12) / 30, 0.25, 1)) end
    -- BANGS: real anti-lag cracks and spits fire on the overrun. Drive the native afterfire crackle/flame
    -- envelope hard while active, scaled by Extra fuel + the Pops / flames slider. The braap SOUND
    -- (updateLaunchSound) and the particle FLAMES + smoke (updateOverrunPops / updateExhaustSmoke) also key
    -- off model.antilagActive, so enabling anti-lag now sounds and looks like real ALS, not just held boost.
    if engine then
      local af = (2 + (settings.antilagFuel or 12) / 30 * 5) * clamp(settings.popsLevel or 1, 0, 3)
      engine.instantAfterFireCoef = max(base.instantAfterFireCoef or 0, 1.0) * af
      engine.sustainedAfterFireCoef = max(base.sustainedAfterFireCoef or 0, 1.0) * af
    end
  else
    model.antilagHeat = clamp(model.antilagHeat - dt * 9, 0, 100)
    model.antilagStatus = (settings.antilagEnabled == true) and (base.hasTurbocharger and (rpm > 3200 and "Armed" or "Below RPM") or (base.isSupercharger and "N/A (supercharger - no turbine)" or "N/A (NA)")) or "Off"
    if turbo and turbo.setAntilagCoef then turbo.setAntilagCoef(0) end
  end

  -- VVT
  model.vvtTarget = 0
  if (settings.vvtEnabled ~= false) and rpm > 1600 and rpm < 5600 then
    local shape = 1 - min(1, abs(rpm - 3600) / 2000)
    model.vvtTarget = (settings.vvtIntakeAdvance or 25) * clamp(shape, 0, 1)
  end
  model.vvtActual = approach(model.vvtActual, model.vvtTarget, 4, dt)
  if settings.vvtEnabled == false then model.vvtStatus = "Off"
  elseif model.vvtTarget < 1 then model.vvtStatus = "Holding base"
  elseif abs(model.vvtActual - model.vvtTarget) > 2 then model.vvtStatus = "Tracking"
  else model.vvtStatus = "On target" end

  -- e-throttle (V2.9.7): E-Throttle now DRIVES the real plate instead of scaling power. The mapped
  -- pedal goes out through electrics.values.throttleOverride - the same hook BeamNG's cruise
  -- control uses (vehicleController.lua reads it in place of the driver pedal) - so TPS, load,
  -- MAP, fueling and boost all see the remap like a real DBW system. The old ethrottleResp power
  -- multiplier is retired: the plate does the work now, scaling power on top would double-count.
  -- Pedal source is input.throttle (the RAW pedal): model.rawTps reads the PLATE, which follows
  -- our own override once consumed - reading it back as "pedal" would feed the map its own output.
  model.ethrottleResp = 1
  local gamma = (settings.ethrottleCurve == 2) and 1.6 or ((settings.ethrottleCurve == 3) and 0.65 or 1)
  if (settings.ethrottleMode or 1) == 2 then
    local pf = clamp(input and input.throttle or 0, 0, 1)
    local pedal = pf * 100
    local plateTarget = pf ^ gamma
    model.ethrottleTarget = plateTarget * 100
    -- ownership: anything in the channel that isn't OUR value is an external system (cruise
    -- control, a scenario script) - monitor only, never fight it. Also true while we're released
    -- (ethOverrideSent nil) and cruise's PID owns the channel, so the status can't lie "Tracking".
    local externalWriter = electrics.values.throttleOverride ~= nil
      and electrics.values.throttleOverride ~= H.ethOverrideSent
    -- actuator sanity: if the plate keeps hugging the RAW pedal while the command meaningfully
    -- differs, nothing on this vehicle consumes throttleOverride (exotic non-vehicleController
    -- jbeams). Latch cable behavior instead of letting a missing actuator fake a SAFETY CUT.
    if not H.ethNoActuator and H.ethOverrideSent ~= nil and not externalWriter and gamma ~= 1
       and abs(model.rawTps - pedal) < 3 and abs(model.ethrottleTarget - pedal) > 8 then
      H.ethNoActTimer = (H.ethNoActTimer or 0) + dt
      -- a dead channel hugs the pedal EVERYWHERE; a jbeam top-speed governor can pin the plate
      -- near one coincidental pedal point. Demand the hugging span >5% of pedal travel too.
      H.ethNoActMin = min(H.ethNoActMin or pedal, pedal)
      H.ethNoActMax = max(H.ethNoActMax or pedal, pedal)
      if H.ethNoActTimer > 1.5 and (H.ethNoActMax - H.ethNoActMin) > 5 then H.ethNoActuator = true end
    else
      H.ethNoActTimer = max(0, (H.ethNoActTimer or 0) - dt)
      if H.ethNoActTimer == 0 then H.ethNoActMin, H.ethNoActMax = nil, nil end
    end
    if H.ethNoActuator then
      H.releaseEthrottle()
      model.ethrottleErrTimer = 0
      model.ethrottleFault = false
      model.ethrottleStatus = "Cable (no DBW actuator)"
    else
      if pf < 0.02 or floor(tonumber(ev("gearIndex", 0)) or 0) < 0 then
        -- closed pedal / reverse: release the plate. Idle control and arcade reverse logic expect
        -- the raw pedal channel (and pf^gamma preserves 0 anyway, so nothing is lost at rest).
        if H.ethOverrideSent ~= nil and electrics.values.throttleOverride == H.ethOverrideSent then
          electrics.values.throttleOverride = nil
        end
        H.ethOverrideSent = nil
      elseif not externalWriter then
        electrics.values.throttleOverride = plateTarget
        H.ethOverrideSent = plateTarget
      end
      -- plausibility monitor (the teaching feature): the ECU cross-checks its SENSED TPS against
      -- the commanded plate. Error only counts while the PHYSICAL plate is on our command - then a
      -- sensed mismatch means the TPS sensor is lying (Sensor Setup miscalibration, the intended
      -- trip) and the ECU cuts to 30% power, the classic DBW limp strategy. If the plate itself is
      -- off-command, some legitimate authority owns it (gear cut, arcade reverse swap, jbeam
      -- top-speed governor) - or the actuator is dead, which is the no-actuator latch's job.
      local tol = settings.ethrottleTolerance or 12
      local err = abs(sensedTps - model.ethrottleTarget)
      if H.ethOverrideSent ~= nil and pedal > 5 and not externalWriter
         and not electrics.values.isShifting
         and abs(model.rawTps - model.ethrottleTarget) <= tol
         and err > tol then
        model.ethrottleErrTimer = min(model.ethrottleErrTimer + dt, 1.5)   -- capped: recovery always takes <1s once the cause is fixed
      else
        model.ethrottleErrTimer = max(0, model.ethrottleErrTimer - dt * 2)
      end
      model.ethrottleFault = model.ethrottleErrTimer > 0.6
      model.ethrottleStatus = model.ethrottleFault and "SAFETY CUT"
        or (externalWriter and "External override"
        or (H.ethOverrideSent == nil and (pf < 0.02 and "Released (idle)" or "Released (reverse)")
        or "Tracking"))
    end
  else
    model.ethrottleTarget = model.rawTps
    model.ethrottleErrTimer = 0
    model.ethrottleFault = false
    model.ethrottleStatus = "Cable"
    H.ethNoActuator = false   -- toggling back to E-Throttle is an explicit re-probe of the actuator
    H.releaseEthrottle()
  end
  setFault("EThrottle", model.ethrottleFault)

  -- trigger
  local triggerRisk = 0
  local tp = settings.triggerPattern or 1
  if tp == 2 then model.triggerStatus = "Noisy crank"; triggerRisk = 0.05
  elseif tp == 3 then model.triggerStatus = "No cam sync"; triggerRisk = 0.09
  else model.triggerStatus = "OK" end
  setFault("Trigger", tp > 1)

  -- ignition advance display. Base Timing (V2.9.7, Trigger Scope page) replaces the old fixed 18:
  -- like PCLink's "Set Base Timing" it is the calibration reference the displayed absolute advance
  -- is built on, settable per engine. DISPLAY ONLY by design - knock/EGT/power all run on the
  -- TRIMS, so this never adds power; it makes the advance gauge read like the real engine's spec.
  -- V3.0.4 SHIFT TORQUE MANAGEMENT. A real automatic ECU pulls timing during a shift so the clutches
  -- are not asked to swallow full engine torque while they are slipping - it is why a factory auto
  -- shifts smoothly and why a "tuned" trans with torque management removed hammers its clutches.
  -- This is genuinely modellable here because the mod ALREADY owns ignition timing, and BeamNG
  -- publishes electrics.values.isShifting (used by the stock esc, activeCenterDiffLock,
  -- electronicSplitShaftLock and hPattern controllers, so it is a real, maintained signal).
  -- Trade-off is the real one: more retard = softer, cooler shift but a longer torque hole.
  local shiftMgmtRetard = 0
  if (settings.shiftTorqueMgmt or 0) > 0 and ev("isShifting", false) then
    shiftMgmtRetard = clamp(settings.shiftTorqueMgmt or 0, 0, 100) * 0.25   -- up to 25 deg pull
    model.shiftMgmtActive = true
  else
    model.shiftMgmtActive = false
  end
  -- REVIEW FIX 2026-08-03 (A7). Published because the retard only ever reached model.effIgnTrim, and
  -- the diesel power path never reads that - diesel coef has no effIgnTrim term and dieselPower's own
  -- timing factor is computed from the raw map trim before this retard exists. So on a diesel
  -- automatic - the D-Series and T-Series, the core audience and the cars the lessons require - the
  -- slider did NOTHING while model.shiftMgmtActive still reported true, the displayed advance dipped,
  -- the help text promised a torque pull, a lesson narrated it and the tutorial gate passed off it.
  -- On petrol the same slider cuts up to ~28% torque. Consumed by the diesel coef and the diesel EGT.
  model.shiftMgmtRetard = shiftMgmtRetard
  model.effIgnTrim = ignTrim + model.iatTimingTrim - model.knockRetard + model.idleIgnTrim - (antilagActive and (settings.antilagRetard or 18) or 0) - (nitrousActive and nRet or 0)
    - ((model.launchActive or model.rollingActive or model.hardCutActive) and (settings.launchRetard or 0) or 0)   -- V2.8 launch retard (0 = pre-2.8)
    - shiftMgmtRetard
  model.ignitionAdvance = (settings.baseTiming or 18) + model.effIgnTrim - load * 8

  -- faults + protection
  local leanError = max(0, lambdaActual - lambdaTarget)
  -- A pneumatic gate cannot obey a target below its crack pressure. Protect against boost above the
  -- achievable floor immediately instead of falsely latching Overboost during the 0.7 s floor detector.
  local protectionBoostTarget = model.boostTargetBelowSpring
    and max(boostTarget, model.boostHardwareFloorKpa or boostTarget) or boostTarget
  -- V2.9.7 wave 4: step-down grace, armed by the bumpless-transfer clamp. The protection target
  -- follows a stepped-down gear target INSTANTLY, but carried boost physically needs ~0.45 s to
  -- blow off even with a perfect loop - without this, a >=10 psi flat-shift step-down held excess
  -- past the 0.3 s maturation and latched a permanent Overboost DTC. Hold the pre-step target
  -- briefly; a genuine runaway still exceeds even the pre-step target and matures normally.
  if (model.boostProtGraceTimer or 0) > 0 then
    model.boostProtGraceTimer = model.boostProtGraceTimer - dt
    protectionBoostTarget = max(protectionBoostTarget, model.boostProtGraceKpa or protectionBoostTarget)
  end
  local boostExcess = base.hasTurbocharger and max(0, sensedMap - protectionBoostTarget) or 0   -- overboost fault/limp is turbo-only (wastegate). Superchargers use the smooth ceiling cut below.
  -- WASTEGATE FLOOR exemption (V2.6): gate pegged fully open + spring still makes more than target
  -- = no setting can comply - cutting/limping forever protected nothing (engine-swap log: endless
  -- Overboost+LimpMode at map 226 vs a 185 tune from the old engine). boostStatus reports the floor.
  -- V2.9.7 (Toast's BBG drag report): MATURE the overboost like the Lean DTC below - setFault
  -- latches forever, so a single-frame spool spike past a low BBG gear target must not brick the
  -- pass with a permanent DTC + limp. 0.3 s matches real EBC overboost-delay settings; decays at
  -- 2x rate like the lean timer. A genuine runaway still cuts, ~0.3 s later.
  model.overboostTimer = clamp((model.overboostTimer or 0)
    + ((boostExcess > (settings.boostOvercut or 25) and not model.boostFloored) and dt or -dt * 2), 0, 1)
  local overboost = model.overboostTimer > 0.3
  local knockActive = model.knockLevel > (settings.knockThreshold or 1.2)
  local protectionActive = protectionEnabled and ((knockControl and knockActive) or overboost)
  -- Lean DTC: only a GENUINE, sustained lean under load -- never a transient spool/tip-in spike or a
  -- decel-cut overrun lean (those were latching a false "Lean" code on every lift, since setFault latches
  -- and never clears). Real ECUs mature a lean code over time, so a single frame over threshold must not latch.
  local leanGenuine = leanError > 0.09 and load > 0.5 and not model.decelCut and sensedTps > 8
  model.leanFaultTimer = clamp((model.leanFaultTimer or 0) + (leanGenuine and dt or -dt * 2), 0, 1)
  if diesel then
    -- diesel runs lean by design and has no spark-knock; its limit is SMOKE (over-fuelling for the air)
    setFault("Smoke", model.smoke > 60)
  else
    setFault("Lean", model.leanFaultTimer > 0.6)
    setFault("Knock", knockActive)
  end
  setFault("Overboost", overboost)
  setFault("FuelPressure", model.fuelDiffPressureKpa < 240)   -- differential collapsing = pump out of headroom
  setFault("InjectorDuty", model.injectorDuty > 103)
  setFault("CoolantTemp", model.ectC > 112)
  setFault("OilTemp", model.oilC > 140)
  -- EGT (training model): lean mixture + pulled timing + boost run the exhaust HOT; rich + advance cool it.
  -- Gasoline danger ~950C, meltdown >1050. Lets tuners watch EGT and learn the AFR/timing-vs-heat tradeoff.
  local egtTarget
  if diesel then
    -- diesel EGT climbs with fuel/smoke, retarded injection timing and load; more boost (air) cools it.
    -- This is THE gauge a diesel tuner watches - too much fuel for the air melts pistons.
    -- REVIEW FIX 2026-08-03 (A2): DELIVERED, not commanded. Fuel the nozzle could not flow or the
    -- limiter refused never entered the cylinder, so it cannot heat the exhaust. The fallback keeps
    -- the legacy path byte-identical (there deliveredMult == dieselFuelMult) and covers the frame
    -- before the diesel block has run.
    -- REVIEW FIX 2026-08-03 (A7): shift torque management now costs diesel EGT too, since on a diesel
    -- it is fuel cut rather than spark retard. Scaled at 4 C/deg, NOT the 13 C/deg the retard term
    -- above uses - at full slider that term would have added ~325 C of target and ~172 C of real EGT
    -- through the 0.4 s filter, manufacturing exactly the latched-DTC injustice A2 exists to remove.
    egtTarget = 330 + dieselDemand * 230 + model.smoke * 3.2 + max(0, -ignTrim) * 13
      + max(0, (model.dieselDeliveredMult or dieselFuelMult) - 1) * 90 - max(0, dieselAirSupply - 1) * 70
      + (model.shiftMgmtRetard or 0) * 4
  else
    -- AFR/timing-dominant EGT (V2_1, like real life): the mixture and spark timing are what a tuner
    -- actually controls EGT with - LEAN runs the exhaust hot fast (melts pistons/valves/turbo), RICH
    -- cools it strongly (the main lever for surviving big boost), and RETARDED timing dumps unburnt
    -- heat out the port. Load + boost set a baseline, but BOOST TAPERS above ~26 psi (extra boost comes
    -- with proportional fuel, so it doesn't raise EGT without limit) - so a properly rich, well-timed
    -- big-boost build runs hot-but-survivable instead of auto-melting, while a lean or over-retarded
    -- tune still cooks. Was load*360 + boost*0.7, an untunable floor that pegged EGT (and tripped the
    -- meltdown limp) on ANY high-load/high-boost car no matter how well it was tuned.
    local boostHeat = min(max(0, model.boostKpa), 180) * 0.55 + max(0, model.boostKpa - 180) * 0.22
    -- lambdaTrue, not lambdaActual: EGT is physics - the REAL mixture heats the pipe. A wideband
    -- calibration bias (Sensor Setup) must not move exhaust temperature the ECU isn't acting on;
    -- knock/misfire/damage/smoke already run on lambdaTrue for the same reason.
    egtTarget = 420 + max(0, load) * 220 + boostHeat
      + max(0, model.lambdaTrue - 0.88) * 1300 - max(0, 0.84 - model.lambdaTrue) * 700
      + max(0, -model.effIgnTrim) * 10 + (nitrousActive and max(0, nShot * 4 - nFuel * 8) or 0)
      - fuelProps().egtCool   -- alcohol fuels run the exhaust cooler (methanol's huge latent heat especially - real meth cars show notably low EGT)
  end
  model.egtC = approach(model.egtC or 400, clamp(egtTarget, 300, 1100), 2.5, dt)
  setFault("EGT", model.egtC > (settings.egtLimit or 950))
  setFault("EngineHealth", 100 * (1 - model.damage) < 82)
  -- LIMP is a protection strategy, so it only exists while Protection is enabled (Protection OFF now
  -- really means "nothing saves the engine", matching the toggle's promise - damage still accumulates
  -- and still costs power via damagePower, there's just no ECU intervention). The EGT leg follows the
  -- tunable EGT limit (+100 margin past the warning) instead of a hard-coded 1050, so raising the limit
  -- on the Protection page actually moves the limp point (default 950 keeps the old 1050 behaviour).
  model.limpMode = protectionEnabled and ((100 * (1 - model.damage) < 58) or model.fuelDiffPressureKpa < 210 or model.ectC > 120
    or model.egtC > (settings.egtLimit or 950) + 100
    or (protectionActive and (overboost or model.knockLevel > (settings.knockThreshold or 1.2) + 1.1)))
  setFault("LimpMode", model.limpMode)
  model.protectionStatus = protectionActive and (overboost and "Overboost cut" or "Active")
    or (model.limpMode and "Limp active" or (protectionEnabled and "Armed" or "Off"))
  model.auxCheckEngineOn = (settings.auxCheckEngine ~= false) and (model.faultCount > 0 or model.damageRate > 0 or knockActive or model.limpMode)

  -- cams / ITBs make for a lumpy, hunting idle until idle control is dialled in (a race cam stays
  -- a touch lumpy by nature even when tuned)
  local hwIdleRough = 0
  if (hw.cam > 0 or hw.itb > 0) and rpm < (base.idleRPM or 850) + 600 and sensedTps < 12 then
    local idleHelp = (settings.idleEnabled ~= false) and 0.5 or 1.0
    hwIdleRough = (hw.cam * 0.05 + hw.itb * 0.06) * idleHelp
  end
  -- lean misfire + damage
  if diesel then
    -- diesel doesn't lean-misfire (it's always lean). updateDamage with neutral lambda/knock still
    -- handles the shared mechanical channels (overboost, over-rev, coolant/oil heat); then add the
    -- diesel killers: a sustained EGT meltdown and dangerously over-fuelled (smoke-rich) running.
    model.misfireStatus = "OK"; model.misfireSeverity = 0; model.misfireCut = 1
    updateDamage(dt, rpm, mapKpa, load, 1, 1, 0, protectionActive)
    local dRate = 0
    if model.egtC > 1010 then dRate = dRate + (model.egtC - 1010) * 0.0008 end
    if model.afr < 9.5 and dieselDemand > 0.4 then dRate = dRate + (9.5 - model.afr) * 0.015 end
    dRate = dRate / effBlockStrength()
    if protectionActive then dRate = dRate * 0.4 end
    if dRate > 0 then
      model.damageRate = model.damageRate + dRate
      model.damage = clamp(model.damage + dRate * dt, 0, 1)
      if model.damageReason == "OK" then model.damageReason = (model.egtC > 1010) and "EGT meltdown" or "Over-fuelled" end
    end
  else
    updateLeanMisfire(dt, sensedTps, load, lambdaTarget, lambdaTrue, ignTrim, model.knockLevel, protectionActive,
      max(0, model.cylTrimSpread - 5) * 0.03 + triggerRisk + hwIdleRough)
    updateDamage(dt, rpm, mapKpa, load, lambdaTarget, lambdaTrue, model.knockLevel, protectionActive)
  end

  -- power coefficient: timing is the main lever, AFR has a power peak near 0.89.
  -- A stock zero-trim tune sits ~1.0; a good tune gains real power, a bad tune
  -- knocks, leans out and loses it. effIgnTrim already includes knock retard, so
  -- over-advancing self-limits as knock pulls timing back.
  -- shared cuts (apply to gas AND diesel): injector duty, heat, overboost, damage, limp.
  local richError = max(0, lambdaTarget - lambdaTrue)   -- physical richness (pops burn REAL unburned fuel; the misfire path at updateLeanMisfire already uses lambdaTrue)
  local injCut = clamp(1 - max(0, model.injectorDuty - 100) * 0.02, 0.5, 1)
  local thermalCut = clamp(1 - max(0, model.iatC - 70) * 0.005 - max(0, model.ectC - 115) * 0.01, 0.55, 1)
  local overboostCut = overboost and 0.6 or 1
  if base.isSupercharger then
    -- boost table = CEILING on a blower: mechanical boost can't be wastegated, so the ECU bleeds power
    -- (pulls fuel / timing) as boost climbs past the table target. Smooth, not a slam; only bites when the
    -- tuner sets the ceiling BELOW the blower's natural boost (learnBoost keeps the default AT natural).
    local over = max(0, model.mapKpa - model.boostTargetKpa)
    overboostCut = clamp(1 - over / 130, 0.55, 1)
  end
  local damageCut = model.damagePower / 100
  local limpCut = model.limpMode and 0.6 or 1
  local ethrottleCut = model.ethrottleFault and 0.3 or 1
  if diesel then
    -- diesel power: fuel quantity (capped by what the air can burn) x injection timing x rail pressure,
    -- already in dieselPower. No spark/AFR-peak/VVT/nitrous terms.
    -- A7: shift torque management is real here now. A diesel has no throttle plate and no spark, so
    -- the ECU softens a shift by cutting FUEL rather than retarding ignition - a direct multiplier is
    -- the honest shape for it. 0.005/deg gives up to -12.5% at the full 25 deg, deliberately gentler
    -- than the ~28% the same slider costs on petrol, because pulling fuel is a smaller lever than
    -- pulling 25 degrees of spark.
    local coef = dieselPower * injCut * thermalCut * overboostCut * damageCut * limpCut * ethrottleCut
      * clamp(1 - (model.shiftMgmtRetard or 0) * 0.005, 0.5, 1)
    model.coef = clamp(coef, 0.2, 1.8)
  else
    -- IGNITION POWER with a real MBT (V2_3): advance used to be LINEAR (+1.4%/deg with no ceiling), so
    -- on knock-resistant fuel max advance was free power - the exploit from the code review, and why
    -- the trim range was capped at +10. Now power matches the old linear gain up to +5, PLATEAUS at
    -- ~+8.8% around +7.5deg (MBT), then FALLS past it (floor 0.7) - like a real dyno session: you FIND
    -- the peak, and timing past it costs power even before knock bites. Retard side unchanged (linear
    -- cost), so pops / rotational idle / safety tunes are byte-identical. Tunes at the old +10 cap now
    -- read ~+7% instead of +14% - communicated in the tester notes; pull back to the peak to recover.
    local ignT = clamp(model.effIgnTrim, -20, 30)
    local ignitionPower
    if ignT <= 5 then
      ignitionPower = 1 + ignT * 0.014
    else
      local o = ignT - 5
      ignitionPower = max(1.07 + 0.014 * o - 0.0028 * o * o, 0.7)
    end
    local d = lambdaTrue - 0.89   -- power comes from the REAL mixture; a biased wideband can't add horsepower
    local afrPower = clamp(1.03 - 6.5 * d * d, 0.5, 1.03)
    local knockCut = knockActive and clamp(1 - (model.knockLevel - (settings.knockThreshold or 1.2)) * 0.22, 0.45, 1) or 1
    -- cam timing (VVT) fills the midrange where it's active - VTEC-style power add
    local vvtPower = 1 + (model.vvtActual / 40) * 0.04
    -- V2.9.x nitrous phase 2: NO synthetic nitrous multiplier here anymore - the real N2O device
    -- adds its torque to the engine itself, so the ECU multiplying coef too would double the shot.
    local coef = ignitionPower * afrPower * vvtPower * fuelProps().power * knockCut * injCut * thermalCut * overboostCut * damageCut * limpCut * ethrottleCut
    if model.misfireTimer > 0 then coef = coef * model.misfireCut end
    model.coef = clamp(coef, 0.2, 1.3)
  end
  -- a destroyed engine makes NO power until the vehicle is reset
  -- (belt-and-suspenders in case BeamNG's lockUp doesn't fully seize this engine).
  -- 0.02, NOT exact 0: same floor as the hard cut - multiplying intakeAirDensityCoef by a true 0.0
  -- can blow up division/NaN edge cases in modded engines' math (the V2.6 crash report), and the
  -- engines lockUp fails to seize are exactly those modded ones. 2% power is still dead.
  if model.blown then model.coef = 0.02 end

  -- ============================================================================
  -- V3.0.9 VALVE FLOAT. Applied HERE, after every other power term has settled, because it is a
  -- mechanical failure of the head rather than a tuning result - it multiplies whatever the
  -- calibration managed to make, exactly like the real thing.
  -- Shared by petrol and diesel on purpose: springs do not care what you burn.
  -- ============================================================================
  do
    local fl = base.floatRPM
    local band = base.floatBandRPM or 900
    if fl and rpm > fl and not model.blown then
      model.floatSeverity = clamp((rpm - fl) / band, 0, 1)
      -- Cylinder filling collapses as the valves stop following the cam. Progressive, never a cliff:
      -- at the very top of the band the engine still makes ~45%, which is enough to keep pulling
      -- weakly and feel WRONG rather than feel broken.
      model.floatPowerCut = clamp(1 - model.floatSeverity * 0.55, 0.45, 1)
      model.coef = model.coef * model.floatPowerCut
      -- Wear accrues with severity AND time, so a brush past the float point on one shift costs almost
      -- nothing while hanging in the band eats the head.
      -- WHO OWNS WHAT: floatRPM is BeamNG's own maxPhysicalAV, so the game is ALREADY accruing
      -- overRevDamage across this whole band and will lock the engine up (bang, particles) at roughly
      -- 9.5 s of full-severity float, near-certain by 16 s. That catastrophic end is deliberately left
      -- to the game - OE owns the progressive part it does not model: power fade and lasting wear.
      -- The rate is therefore matched to the game's window, not chosen freely: 0.05/s puts wear near
      -- 0.5 at the 10 s mark, exactly when the rods can first let go. At the old 0.025 the engine blew
      -- up while this number still read 0.25, so the whole wear model was invisible.
      model.floatWear = clamp(model.floatWear + model.floatSeverity * dt * 0.05, 0, 1)
      model.floatStatus = model.floatSeverity > 0.66 and "Severe" or (model.floatSeverity > 0.25 and "Floating" or "Onset")
    else
      model.floatSeverity = 0
      model.floatPowerCut = 1
      model.floatStatus = model.floatWear > 0.02 and "Worn" or "OK"
    end
    -- floatWear is DIAGNOSTIC ONLY - it deliberately does not touch model.coef.
    -- Caught by a real Covet log: OE's own mechanical over-rev channel (updateDamage, `rpmRisk`) keys
    -- on base.maxPhysicalRPM, which is the SAME rpm this float model starts at. That channel was
    -- already eating engine health (100 -> 58 in ~20 s on the limiter) and health already costs power
    -- through damagePower. Multiplying coef here as well meant the same over-rev was billed twice.
    -- One threshold, one owner: rpmRisk owns lasting damage, float owns the live rpm-dependent fade
    -- that nothing else models, BeamNG's overRevDamage owns the bang. This number just reports how
    -- hard the head has been floated.
  end

  -- torque delta for display (vs a stock 1.0 baseline)
  local baseTq = engine and (engine.outputTorque1 or 0) or 0
  model.engineTorqueNm = max(0, baseTq)
  H.updateClutchTelemetry()
  local drivetrainLoss = clamp(settings.drivetrainLoss or 15, 0, 35) / 100
  model.enginePowerHp = model.engineTorqueNm * 0.73756 * rpm / 5252
  -- MEASURED wheel power (V2.6 dyno pass): wheels.wheelPower = sum of propulsionTorque x
  -- angularVelocity over every wheel (watts, wheels.lua:312) - actual delivered power through
  -- BeamNG's really-simulated drivetrain (gearbox/diff friction, converter slip), i.e. what a hub
  -- dyno reads. The old wheel figures were crank x (1 - loss slider) = a guess dressed as data.
  -- The slider stays as the fallback for exotic setups and as the player's crank-estimate factor
  -- to compare against the live MEASURED loss readout (Limiters page) - real dyno-day literacy.
  local wheelW = (wheels and type(wheels.wheelPower) == "number") and wheels.wheelPower or nil
  if wheelW then
    model.wheelPowerHp = max(0, wheelW * 0.00134102)
    model.wheelTorqueNm = rpm > 100 and (model.wheelPowerHp * 5252 / rpm) / 0.73756 or 0
    -- live measured loss %, only sampled under real load so idle/coast noise can't skew it
    if model.enginePowerHp > 20 and model.tps > 50 and model.wheelPowerHp > 1 then
      local inst = clamp((1 - model.wheelPowerHp / model.enginePowerHp) * 100, 0, 60)
      model.drivetrainLossMeasured = (model.drivetrainLossMeasured or inst) * 0.95 + inst * 0.05
    end
  else
    model.wheelTorqueNm = model.engineTorqueNm * (1 - drivetrainLoss)
    model.wheelPowerHp = model.enginePowerHp * (1 - drivetrainLoss)
  end
  -- approximate the torque this calibration added/removed vs the stock baseline (blown: the 0.02
  -- survival floor would read as a giant fake negative delta - report 0, the engine is dead)
  model.torqueDelta = (model.coef ~= 0 and not model.blown) and (model.engineTorqueNm - model.engineTorqueNm / model.coef) or 0

  -- AUTO PEAK CAPTURE (V2.6 Build Card): remember the highest SUSTAINED power the ECU actually
  -- measured - over 85% throttle held for 0.3s, so a one-frame spike or a downshift blip can't
  -- fake it. The Build Card shows it as the zero-effort "ECU PEAK" tier: 99% of shared cards
  -- never had a dyno pull behind them, but any card that's been DRIVEN now carries a real
  -- measured number. Fresh per controller fit, so an engine swap starts a clean record.
  if (model.tps or 0) > 85 then
    peakHoldTimer = peakHoldTimer + dt
    if peakHoldTimer > 0.3 and model.enginePowerHp > (model.peakHpSeen or 0) then
      model.peakHpSeen = model.enginePowerHp
      model.peakHpRpm = rpm
    end
  else
    peakHoldTimer = 0
  end

  -- pops: rich exhaust dumps unburned fuel -> more afterfire
  local popsMult = (settings.popsLevel or 1) * (1 + richError * 4)
  if engine then
    engine.instantAfterFireCoef = max(base.instantAfterFireCoef or 0, 0.4) * popsMult
    engine.sustainedAfterFireCoef = max(base.sustainedAfterFireCoef or 0, 0.4) * popsMult
  end

  -- aux outputs
  model.auxFuelPumpOn = rpm > 300 or simTime <= (settings.auxFuelPumpPrime or 2)
  model.auxShiftLightOn = rpm >= (settings.auxShiftLight or 6800)
  -- real outputs that external dashes / HUDs can read
  electrics.values.oep_shiftlight = model.auxShiftLightOn and 1 or 0
  electrics.values.oep_checkengine = model.auxCheckEngineOn and 1 or 0
  electrics.values.oep_fuelpump = model.auxFuelPumpOn and 1 or 0
end

-- ---------------------------------------------------------------------------
-- dyno
-- ---------------------------------------------------------------------------

local function sendDyno(withRows)
  -- winLo/winHi = the launch window a pull must START inside (V2.8 UX: the armed status can now say
  -- WHERE to floor it instead of silently never triggering - the #1 "dyno won't register" confusion)
  local winLo = (dyno.startRpm and dyno.startRpm > 0) and dyno.startRpm or ((base.idleRPM or 800) + 300)
  local dynoMsg = { vehId = (obj and obj.getID) and obj:getID() or 0, state = dyno.state, auto = dyno.auto, startGear = dyno.startGear, startRpm = dyno.startRpm, sweep = dyno.sweep, runSeq = dyno.runSeq, winLo = floor(winLo), winHi = floor(winLo + 1700), rows = withRows and dyno.rows or nil }
  guihooks.trigger("OEProTuneDyno", dynoMsg)
  if remoteEmit then remoteEmit("OEProTuneDyno", dynoMsg) end
end

local function updateDyno(dt)
  local throttle = ev("throttle", 0) or 0
  local rpm = ev("rpm", 0) or 0
  local idleRPM = base.idleRPM or 800
  if dyno.state == "done" then
    -- Auto pull: re-arm for the next pass once the driver has lifted and dropped back to low RPM.
    if dyno.auto and throttle < 0.2 and rpm < idleRPM + 1500 then dyno.state = "armed"; dyno.rows = {}; sendDyno(false) end
    return
  end
  if dyno.state == "idle" then
    if dyno.auto then dyno.state = "armed"; sendDyno(false) else return end
  end
  if dyno.state == "armed" then
    -- Only START a pull within a launch window, so every run is comparable and flooring at high revs
    -- won't begin a truncated run. Default window = just off idle (full curve from the bottom). If a
    -- Start RPM is set (Auto pull), the window's lower edge becomes that RPM so the pull begins there
    -- (e.g. an in-gear pull from 3000). In Auto pull with a Start gear set, only begin in that gear.
    local gearOk = (dyno.startGear == 0) or (floor(tonumber(ev("gearIndex", 0)) or 0) == dyno.startGear)
    local loRpm = (dyno.startRpm and dyno.startRpm > 0) and dyno.startRpm or (idleRPM + 300)
    local hiRpm = loRpm + 1700
    if throttle > 0.9 and rpm >= loRpm and rpm < hiRpm and gearOk then
      dyno.state = "running"; dyno.rows = {}; dyno.lastRPM = rpm; dyno.tqSum = 0; dyno.tqCount = 0
      dyno.pullTime = 0; dyno.pullRpm0 = rpm; dyno.sweep = nil   -- sweep-rate quality (rpm/s) for this pull
      sendDyno(false)
    end
  elseif dyno.state == "running" then
    local target = (settings.revLimit or base.revLimiterRPM) - 150
    dyno.pullTime = (dyno.pullTime or 0) + (dt or 0)
    -- only abort on a real lift, so a little throttle noise or a quick shift
    -- doesn't truncate the pull
    if throttle < 0.5 then
      if #dyno.rows > 15 then
        -- SWEEP RATE (V2.6 dyno pass): how fast the revs climbed. outputTorque1 is measured AFTER
        -- engine inertia, so a 1st-gear rocket pull genuinely reads LOW - exactly like a real
        -- inertia dyno. The UI turns this into pull-quality advice (use a higher gear).
        dyno.sweep = dyno.pullTime > 0.2 and floor((rpm - (dyno.pullRpm0 or rpm)) / dyno.pullTime + 0.5) or nil
        dyno.runSeq = (dyno.runSeq or 0) + 1   -- V2.9.x: unique id per completed pull so replayed envelopes dedupe
        dyno.state = "done"; sendDyno(true)
      else dyno.state = "armed"; sendDyno(false) end
      return
    end
    dyno.tqSum = dyno.tqSum + (engine.outputTorque1 or 0)
    dyno.tqCount = dyno.tqCount + 1
    if rpm >= dyno.lastRPM + 40 then
      dyno.lastRPM = rpm
      local tq = dyno.tqCount > 0 and (dyno.tqSum / dyno.tqCount) or 0
      dyno.tqSum, dyno.tqCount = 0, 0
      -- row = {rpm, torqueNm, lambda, boostKpa} so the dyno graph can plot AFR + boost across the pull
      dyno.rows[#dyno.rows + 1] = { floor(rpm + 0.5), floor(tq * 10 + 0.5) / 10, floor((model.lambdaActual or 1) * 1000 + 0.5) / 1000, floor((model.boostKpa or 0) + 0.5) }
      if #dyno.rows % 5 == 0 then sendDyno(true) end
    end
    if rpm >= target then
      dyno.sweep = (dyno.pullTime or 0) > 0.2 and floor((rpm - (dyno.pullRpm0 or rpm)) / dyno.pullTime + 0.5) or nil
      dyno.runSeq = (dyno.runSeq or 0) + 1   -- V2.9.x: unique id per completed pull so replayed envelopes dedupe
      dyno.state = "done"; sendDyno(true)
    end
  end
end

-- ---------------------------------------------------------------------------
-- telemetry + state publishing
-- ---------------------------------------------------------------------------

local function buildTelemetry()
  local health = 100 * (1 - model.damage)
  local ctrl = controlling and settings.enabled ~= false   -- effective control state
  local telemetrySpeedKmh = model.boostRawSpeedKmh or (ev("airspeed", ev("wheelspeed", 0)) or 0) * 3.6
  local telemetryClutchInput = clamp(tonumber(ev("clutch_input", 0)) or 0, 0, 1)
  -- Use the calibrated physical pedal source for persistent zero-demand/coast detection. This stays
  -- distinct from a genuine 1-4% light-throttle cruise, which the Mixture Map is allowed to learn.
  local zeroDemandActive = (model.rawTps or model.tps or 0) < 0.5 and telemetrySpeedKmh > 5 and (model.rpm or 0) > 1100
  -- Conditions a lambda/VE learner must reject. These are observable ECU states, not access to the
  -- simulator's hidden trueVE. The UI can use fuelLearningStable directly or apply stricter filters.
  local startupFuelActive = abs(model.startupTrim or 0) > 0.1
  local transientFuelActive = model.accelActive or model.decelCut or abs(model.transientTrim or 0) > 0.25
  local launchFuelActive = model.launchActive or model.rollingActive or model.hardCutActive
  local fuelLearningStable = ctrl
    and not model.isDiesel
    and not startupFuelActive
    and not transientFuelActive
    and not launchFuelActive
    and not model.antilagActive
    and not model.nitrousActive
    and (model.injectorDuty or 0) < 95
    and (model.fuelDiffPressureKpa or 300) >= 250
  -- V2.9.7 beta feedback: BeamNG has no stock oil-pressure electric, and the old fallback
  -- chain ("oil" is the warning-LAMP signal, then a hardcoded 0) fabricated a permanent
  -- 0 bar - so the LINK dash flashed "OIL PRESS LOW" forever. Publish the channel only
  -- when a real pressure electric exists; nil otherwise, and the dash renders '--' like
  -- an unwired analog input (PCLink convention) with no alarm.
  local oilRaw = tonumber(ev("oilPressure", ev("oilPress", nil)))
  local oilPressureBarLocal = oilRaw and (oilRaw > 5 and oilRaw / 100 or oilRaw)
  local oilPressureKpaLocal = oilRaw and (oilRaw > 5 and oilRaw or oilRaw * 100)
  return {
    vehId = (obj and obj.getID) and obj:getID() or 0,   -- UI ignores other vehicles' telemetry (BeamMP / traffic / multi-spawn)
    status = active and (ctrl and "Controlling" or "Monitoring") or "Waiting",
    controlling = ctrl, otherTunerActive = electrics.values.oet_active == 1,
    -- Acquisition metadata used by the browser Log Studio (Remote Dash). sampleTime is the
    -- controller's monotonic simulation clock; load/pedal/clutch are real vehicle signals,
    -- so pull-comparability checks never have to infer them.
    sampleTime = simTime,
    engineLoad = model.load,
    pedal = clamp((ev("throttle", 0) or 0) * 100, 0, 100),
    clutch = telemetryClutchInput * 100,
    hardCutActive = hardCutOn and true or false,
    rpm = model.rpm, tps = model.tps, mapKpa = model.mapKpa, rawMapKpa = model.rawMapKpa, rawTps = model.rawTps,
    tuneLoad = (not effDiesel() and floor(settings.loadSource or 1) == 2) and model.tps or model.mapKpa,
    loadSourceMode = (not effDiesel() and floor(settings.loadSource or 1) == 2) and 2 or 1,
    loadSource = (not effDiesel() and floor(settings.loadSource or 1) == 2) and "Alpha-N (TPS)" or "Speed Density (MAP)",
    baroKpa = model.baroKpa, mapGaugePsi = model.mapGaugePsi, vacuumInHg = model.vacuumInHg,
    mapSource = "modeled_load_boost",
    boostKpa = model.boostKpa, boostTargetKpa = model.boostTargetKpa, boostError = model.boostError,
    boostBaseTargetKpa = model.boostBaseTargetKpa, boostRequestedTargetKpa = model.boostRequestedTargetKpa,
    boostGearTargetTrimKpa = model.boostGearTargetTrimKpa, boostSpeedTargetTrimKpa = model.boostSpeedTargetTrimKpa,
    boostTargetTrimAppliedKpa = model.boostTargetTrimAppliedKpa,
    wastegateDuty = model.wastegateDuty, boostBaseWastegateDuty = model.boostBaseWastegateDuty,
    boostILimitLo = model.boostILimitLo or 0,   -- V2.9.7: integrator authority floor (Tune Doctor: "at its limit" beats "raise P gain")
    -- V2.9.7 wave 4: loop decomposition - turns "is it BBG, my trim, or the controller?" into a screenshot
    boostPTermPsi = model.boostPTermPsi or 0, boostDTermPsi = model.boostDTermPsi or 0,
    boostITermPsi = model.boostITermPsi or 0, boostBiasPsi = model.boostBiasPsi or 0,
    boostFeedforwardDuty = model.boostFeedforwardDuty, boostPidDutyTrim = model.boostPidDutyTrim,
    boostGearDutyTrimPct = model.boostGearDutyTrimPct, boostSpeedDutyTrimPct = model.boostSpeedDutyTrimPct,
    boostDutyTrimPct = model.boostDutyTrimPct,
    boostControlStatus = model.boostStatus, boostShapeStatus = model.boostShapeStatus,
    boostLimitReason = model.boostLimitReason, boostSpeedKmh = model.boostSpeedKmh,
    boostRawSpeedKmh = model.boostRawSpeedKmh, boostSpeedSource = model.boostSpeedSource,
    boostGear = model.boostGear, boostGearValid = model.boostGearValid and true or false,
    boostHardwareFloorKpa = model.boostHardwareFloorKpa, boostTargetBelowSpring = model.boostTargetBelowSpring and true or false,
    boostMaxed = model.boostMaxed and true or false,   -- turbo at its physical ceiling, short of target
    boostFloored = model.boostFloored and true or false,   -- wastegate spring floor: boost can't go DOWN to target
    lambdaTarget = model.lambdaTarget, lambdaActual = model.lambdaActual, trueLambda = model.lambdaTrue,
    lambdaError = model.lambdaActual - model.lambdaTarget,
    -- Sensor-facing lambda before STFT/LTFT plus the active fuel correction. Together these let a
    -- Mixture Map remain trim-aware without using/revealing the hidden true VE as its answer key.
    lambdaUncorrected = model.lambdaUncorrected, lambdaBase = model.lambdaUncorrected,
    lambdaSensorReady = ctrl and not model.isDiesel, lambdaSensorFault = false,
    closedLoopActive = model.closedLoop,
    closedLoopFuelTrim = model.adaptiveFuelTrim or 0,
    closedLoopFuelFactor = 1 + (model.adaptiveFuelTrim or 0) / 100,
    fuelLearningStable = fuelLearningStable,
    startupFuelActive = startupFuelActive, accelEnrichActive = model.accelActive and true or false,
    decelFuelCutActive = model.decelCut and true or false, zeroDemandActive = zeroDemandActive,
    transientFuelActive = transientFuelActive,
    launchFuelActive = launchFuelActive and true or false,
    startupFuelTrim = model.startupTrim or 0, transientFuelTrim = model.transientTrim or 0,
    tpsDelta = model.tpsDelta or 0,
    veTableCell = model.veTableCell, veTrueCell = model.veTrueCell,   -- V2.8 speed density: player's VE model vs reality at the live cell (Modelled mode only)
    fuelTrim = model.effFuelTrim, effectiveFuelTrim = model.effFuelTrim, stft = model.stft, ltft = model.ltft,
    closedLoopStatus = model.cllStatus, injectorDuty = model.injectorDuty, injPulseMs = model.injPulseMs,
    injMismatch = model.injFactor or 1, fittedInjCc = hw.inj or 0,
    injMatchStatus = (hw.inj and hw.inj > 0)
      and ((abs(1 - (model.injFactor or 1)) > 0.07)
        and ("MISMATCH - ECU set " .. floor(settings.injectorFlow or 600) .. "cc, fitted ~" .. floor(hw.inj) .. "cc")
        or ("Matched (~" .. floor(hw.inj) .. "cc fitted)"))
      or "No aftermarket injectors detected",
    fuelPressureKpa = model.fuelPressureKpa, fuelDiffPressureKpa = model.fuelDiffPressureKpa,
    ignitionAdvance = model.ignitionAdvance, ignitionTrim = model.effIgnTrim, effectiveIgnitionTrim = model.effIgnTrim,
    iatTimingTrim = model.iatTimingTrim, knockLevel = model.knockLevel, knockRetard = model.knockRetard, knockCount = model.knockCount,
    misfireStatus = model.misfireStatus, misfireCount = model.misfireCount, misfireSeverity = model.misfireSeverity,
    -- V2.9: the "ECU IAT/ECT" readouts show what the ECU SENSES (physical temp + Sensor Setup bias),
    -- so the bias sliders visibly move the value they claim to shift
    iatC = model.iatC + (settings.sensorIatBias or 0), ectC = model.ectC + (settings.sensorEctBias or 0),
    oilC = model.oilC, egtC = model.egtC,
    engineTorqueNm = model.engineTorqueNm, wheelTorqueNm = model.wheelTorqueNm,
    enginePowerHp = model.enginePowerHp, wheelPowerHp = model.wheelPowerHp, calibrationTorqueDelta = model.torqueDelta,
    drivetrainLossMeasured = model.drivetrainLossMeasured,   -- live measured loss % (nil until a loaded pull samples it)
    engineHealth = health, damagePower = model.damagePower, damageReason = model.damageReason, damageRate = model.damageRate,
    protectionStatus = model.protectionStatus, limiterStatus = model.limiterStatus,
    limiterActive = model.limiterActive and true or false,
    commandedRevLimit = settings.revLimit or base.revLimiterRPM, activeRevLimit = lastAppliedLimiterRPM > 0 and lastAppliedLimiterRPM or (settings.revLimit or base.revLimiterRPM),
    revLimitCeiling = base.revLimitCeilingRPM or base.revLimiterRPM,   -- highest limit you may ASK for (top of the float band / torque-curve end)
    valveFloatRPM = base.floatRPM or base.revLimiterRPM,               -- where the valves start to float and torque starts falling away
    valveFloatSource = base.floatSource or "estimated",                -- declared / jbeam / estimated - is the number real or our guess?
    rpmDamageRPM = base.maxPhysicalRPM or base.revLimiterRPM,          -- where mechanical damage begins, ABOVE the float onset
    floatSeverity = model.floatSeverity, floatStatus = model.floatStatus,
    floatWear = model.floatWear, floatPowerCut = model.floatPowerCut,
    maxPhysicalRPM = base.maxPhysicalRPM,
    turboMaxPsi = base.hasTurbo and max(0, (base.turboMaxKpa or 100) - 100) * KPA_TO_PSI or 0, -- legacy hardware reference converted to gauge pressure
    -- V2.9 switchable MAPs on every dash surface: which slot is live, whether a switch is armed
    -- and (while armed) the live reason it hasn't committed yet. mapSlotLabel is preformatted so
    -- dumb row-based dashes can show it without string logic.
    mapSlot = H.activeMapBank or 1,
    mapSlotName = (H.mapBanks and H.mapBanks[H.activeMapBank] and H.mapBanks[H.activeMapBank].name) or ("MAP " .. tostring(H.activeMapBank or 1)),
    mapSlotPending = H.pendingMapBank or 0,
    mapSlotEdited = H.workingDirty == true,
    mapSwitchBlockReason = (H.pendingMapBank and H.mapBankBlockReason) or "",
    mapSlotLabel = H.pendingMapBank
      and ("M" .. tostring(H.activeMapBank or 1) .. " > M" .. tostring(H.pendingMapBank) .. " PENDING")
      or ("M" .. tostring(H.activeMapBank or 1) .. " - " .. ((H.mapBanks and H.mapBanks[H.activeMapBank] and H.mapBanks[H.activeMapBank].name) or ("MAP " .. tostring(H.activeMapBank or 1)))),
    launchLimit = settings.launchLimit or 5500, launchActive = model.launchActive,
    -- V3.0.9: launch silently never arms when its limit is not BELOW the rev limit (see the
    -- `launchLimit < revLimit` term in applyLimiter). Published so the UI can say so instead of the
    -- player discovering it by staging and getting nothing.
    launchAboveRevLimit = (settings.launchEnabled == true)
      and ((settings.launchLimit or 5500) >= (settings.revLimit or base.revLimiterRPM)) or false,
    -- V3.0.9 launch diagnostics. launchDone is the LATCH that ends a 2-step: once true the limiter
    -- stops holding and only standstill + a lifted throttle clears it, so it is the single channel
    -- that says whether a "revs through it" report is the trans-brake release firing early.
    -- launchRollTimer is how long sustained motion has been seen; it releases at 0.25.
    launchDone = H.launchDone and true or false,
    launchRollTimer = H.launchRollTimer or 0,
    launchClutchArmed = H.launchClutchArmed and true or false,
    flatShiftActive = model.flatShiftActive, flatShiftRpm = settings.flatShiftRpm,
    rollingActive = model.rollingActive, rollingHold = rollingHold,
    speedKmh = telemetrySpeedKmh,   -- for the UI 2-step bark gate (launch = low speed)
    gearIndex = floor(tonumber(ev("gearIndex", 0)) or 0),
    clutchInput = telemetryClutchInput,
    idleStatus = model.idleStatus, idleActive = model.idleActive and true or false,
    idleValvePos = model.idleValvePos, idleError = model.idleError,
    antilagStatus = model.antilagStatus, antilagHeat = model.antilagHeat,
    overrunPopStrength = (model.overrunPopStrength or 0) * 100,   -- live "pop strength" from the overrun tune, as % (0..130)
    overrunStatus = model.overrunStatus or "Off",                -- live diagnostic: why it is / isn't popping
    rotIdleStatus = model.rotIdleStatus or "Off", rotIdleStrength = (model.rotIdleStrength or 0) * 100,
    nitrousStatus = model.nitrousStatus, nitrousActive = model.nitrousActive,
    nitrousArmed = model.nitrousArmedHw and "Yes" or "No",
    nitrousTank = floor((model.nitrousTankRatio or 0) * 100 + 0.5),
    nitrousKitHp = base.nitrousKitPowerHp or 0,
    vvtTarget = model.vvtTarget, vvtActual = model.vvtActual, vvtStatus = model.vvtStatus,
    ethrottleStatus = model.ethrottleStatus, ethrottleTarget = model.ethrottleTarget,
    triggerStatus = model.triggerStatus, cylTrimAvg = model.cylTrimAvg, cylTrimSpread = model.cylTrimSpread,
    auxFanOn = model.auxFanOn, auxFuelPumpOn = model.auxFuelPumpOn, auxShiftLightOn = model.auxShiftLightOn, auxCheckEngineOn = model.auxCheckEngineOn,
    faults = faultList(), faultCount = model.faultCount, limpMode = model.limpMode,
    coef = model.coef, hasTurbo = base.hasTurbo,
    isDiesel = effDiesel(), afr = model.afr, smoke = model.smoke, dieselStatus = model.dieselStatus,
    compBrakeStatus = model.compBrakeStatus,
    dieselNozzleDuty = model.dieselNozzleDuty, dieselNozzleLimited = model.dieselNozzleLimited,
    dieselLimiterCut = model.dieselLimiterCut,
    -- V3.0.8: the RETARD in degrees, not just the boolean. shiftMgmtActive only says the ECU thinks
    -- it is managing a shift - which is exactly what it said on a diesel while doing nothing at all
    -- (A7). The magnitude is what proves the slider is connected to something.
    shiftTimeMs = model.shiftTimeMs, shiftMgmtActive = model.shiftMgmtActive,
    shiftMgmtRetard = model.shiftMgmtRetard, torqueLimitStatus = model.torqueLimitStatus,
    converterStall = model.converterStall, converterSlipRpm = model.converterSlipRpm,
    converterSlipKw = model.converterSlipKw, hasConverter = base.hasConverter, transbrake = ev("transbrake", false),
    transTempC = model.transTempC, transTempStatus = model.transTempStatus, transFadePct = model.transFadePct,
    hasFrictionClutch = base.hasFrictionClutch, clutchStatus = model.clutchStatus,
    clutchFittedCapacityNm = model.clutchFittedCapacityNm, clutchStaticCapacityNm = model.clutchStaticCapacityNm,
    clutchCapacityNm = model.clutchCapacityNm, clutchUpgradeRatingNm = model.clutchUpgradeRatingNm,
    clutchDemandNm = model.clutchDemandNm, clutchHeadroomNm = model.clutchHeadroomNm,
    clutchSlipRpm = model.clutchSlipRpm, clutchTempC = model.clutchTempC,
    clutchEfficiencyPct = model.clutchEfficiencyPct,
    blockStrength = effBlockStrength(), blockStrengthSource = (base.blockStrengthSource ~= "rated" and blockOverride and blockOverride >= (base.blockStrength or 1)) and "build" or base.blockStrengthSource,
    maxTorqueRating = base.maxTorqueRating, peakTorqueNm = base.peakTorqueNm, peakPowerHp = base.peakPowerHp,
    peakHpSeen = model.peakHpSeen, peakHpRpm = model.peakHpRpm,   -- best sustained power the ECU has measured (Build Card ECU PEAK tier)
    carName = carName,
    engineFp = base.engineFp,
    engineFpLegacy = base.engineFpLegacy,   -- lets the browser dashboards migrate their own per-engine storage
    -- Standard vehicle electrics for the LINK dash telltales and other dashboard skins.
    turnSignal = ev("turnsignal", 0),
    -- V2.9.7 HOTFIX ("Error loading vehicle" on Vivace/Sunburst): lamp/active electrics are
    -- BOOLEANS on the stock controllers (esc.lua publishes escActive = isActing; ABS/lights
    -- similar) and numbers elsewhere - a raw `> 0.5` on a boolean is a FATAL vehicle-Lua error
    -- that killed every ESC-equipped car at spawn. Drag builds without ESC/ABS parts read the 0
    -- default and never crashed, which is why weeks of strip testing missed it. H.evOn() accepts
    -- boolean true OR numeric > 0.5 for every "is it on" channel; analog thresholds keep their
    -- number path through tonumber() (boolean true counts as on).
    leftTurnSignal = (tonumber(ev("turnsignal", 0)) or 0) < -0.5 or H.evOn("leftTurnSignal"),
    rightTurnSignal = (tonumber(ev("turnsignal", 0)) or 0) > 0.5 or H.evOn("rightTurnSignal"),
    headlights = H.evOn("headlights") or H.evOn("lowBeamOn") or H.evOn("lights"),
    lightState = ev("lights", 0),
    highBeam = H.evOn("highbeam") or H.evOn("highBeamOn"),
    lowBeam = H.evOn("lowbeam") or H.evOn("lowBeamOn") or H.evOn("headlights"),
    parkingBrake = H.evOn("parkingbrake") or H.evOn("parkingBrakeOn"),
    brakePedal = ev("brake", ev("brake_input", 0)),
    absActive = H.evOn("abs") or H.evOn("absActive"),
    escActive = H.evOn("esc") or H.evOn("tcs") or H.evOn("escActive"),
    wiperOn = ev("wiper", 0) == true or (tonumber(ev("wiper", 0)) or 0) > 0.1
      or ev("wipers", 0) == true or (tonumber(ev("wipers", 0)) or 0) > 0.1,
    fuelLevel = ev("fuel", 1),
    batteryVoltage = ev("batteryVoltage", ev("battery", 14.2)),
    engineRunning = ev("engineRunning", nil) == true or (tonumber(ev("engineRunning", 0)) or 0) > 0.5
      or (ev("engineRunning", nil) == nil and (model.rpm or 0) > 100),
    oepCheckEngine = electrics.values.oep_checkengine == 1,
    oepShiftLight = electrics.values.oep_shiftlight == 1,
    oepFuelPump = electrics.values.oep_fuelpump == 1,
    -- Oil pressure: some vehicles expose kPa, others bar.  Heuristic raw > 5 means kPa.
    -- V2.9.7 beta feedback: nil (key absent from the table) when no real electrics source
    -- exists - never a fabricated 0, which alarmed the dash permanently.
    oilPressureKpa = oilPressureKpaLocal,
    oilPressureBar = oilPressureBarLocal
  }
end

-- REMOTE DASH (ported from V3): while the GE server has flagged us on (setRemoteDash), forward
-- UI events to it over the game's own vehicle->GE lane (queueGameEngineLua + serialize, the
-- beamstate.lua idiom); the GE side broadcasts them to every browser as {hook, data} JSON - the
-- same hooks the in-game app listens to, so remote pages render the real ECU unchanged.
-- Flag off (the default) = none of this runs, so a disabled dash costs the vehicle nothing.
remoteEmit = function(name, data)   -- assigns the forward-declared upvalue so earlier event sites share it
  if not (remoteDash and obj and obj.queueGameEngineLua) then return end
  local id = (obj.getID and obj:getID()) or 0
  obj:queueGameEngineLua("if oeProTuneRemote and oeProTuneRemote.push then oeProTuneRemote.push(" .. id .. ",\"" .. name .. "\"," .. serialize(data) .. ") end")
end

local function publishTelemetry()
  guihooks.trigger("OEProTuneTelemetry", buildTelemetry())
end

local function pushRemoteTelemetry()
  local tel = buildTelemetry()
  model.remoteSampleSeq = (model.remoteSampleSeq or 0) + 1
  tel.sampleSeq = model.remoteSampleSeq              -- detects relay/browser packet gaps without guessing from wall time
  tel.carName = carName                              -- dash header; lives in state (not telemetry) so add it here
  tel.stoichRatio = settings.stoichRatio or 14.7     -- lets the dash display AFR alongside lambda
  remoteEmit("OEProTuneTelemetry", tel)
end

local function publishState()
  local st = {
    vehId = (obj and obj.getID) and obj:getID() or 0,   -- so the UI can ignore OTHER vehicles' ECUs (BeamMP / traffic / multi-spawn all broadcast to the same app)
    active = active,
    controlling = controlling and settings.enabled ~= false,
    justInited = justInited,
    engineClassKnown = engineClassKnown,
    carName = carName,
    carNameFallback = H.carNameFallback,   -- REVIEW FIX 2026-07-29: durable-file read fallback
    -- calibration identity (V3 remote studio): epoch = this controller session, revision = accepted
    -- mutations, storedRevision = what the trusted in-game persistence path last stored. dirty is a
    -- legacy alias of calibrationDirty for the remote pages.
    calibrationEpoch = H.epoch,
    calibrationRevision = H.revision,
    storedRevision = H.storedRevision,
    calibrationDirty = H.calibrationDirty(),
    dirty = H.calibrationDirty(),
    changeSource = H.changeSource,
    base = base,
    settings = settings,
    maps = maps,
    mapBank = H.mapBankState(),
    rpmAxis = rpmAxis,
    mapAxis = mapAxis,
    alphaNAxis = H.alphaNAxis,
    mapMeta = mapMeta,
    axisLimits = H.axisLimits,   -- V3.0.7: so app.js / remote.html validate axis length against the
                                 -- live shared bound instead of their own hand-copied 4..12 literals
    -- V3.0.7 grid resolution. gridRestore tells the UI whether going back to that shape is an exact
    -- RESTORE (snapshot held) or a lossy resample, so the confirm copy can say which one it is.
    gridRows = NUM_ROWS, gridCols = NUM_COLS,
    gridRestoreRows = H.gridRestore and H.gridRestore.rows or nil,
    gridRestoreCols = H.gridRestore and H.gridRestore.cols or nil,
    frSeq = frSeq,           -- V2.9: factory-reset sequence - lets the UI accept exactly the post-reset broadcast
    ctrlVersion = "3.0.7"    -- lets the UI identify the matching lesson/mixture controller
  }
  guihooks.trigger("OEProTuneState", st)
  remoteEmit("OEProTuneState", st)
  stateDirty = false
end

-- ---------------------------------------------------------------------------
-- apply outputs
-- ---------------------------------------------------------------------------

local function applyLimiter(dt)
  dt = dt or 0
  local rpm = model.rpm
  local revLimit = settings.revLimit or base.revLimiterRPM
  local launchLimit = clamp(settings.launchLimit or 5500, 2500, max(2500, min(revLimit, base.revLimiterRPM)))
  local flatShiftRpm = clamp(settings.flatShiftRpm or (base.revLimiterRPM - 500), 3000, max(base.revLimiterRPM, revLimit))   -- a RAISED rev limit lets NLS sit above stock too
  local speedKmh = (ev("wheelspeed", 0) or 0) * 3.6
  -- clutch pedal input (manual). NLS needs the clutch pressed; auto/sequential cars never feed a
  -- pedal press, so flat-shift simply won't engage on them (correct - it's a manual technique).
  local clutchIn = (ev("clutch_input", 0) or 0) > 0.5
  local throttle = ev("throttle", 0) or 0
  -- V2.9.x drag launch release (Discord field report: "stumble when dumping the clutch/trans brake
  -- on the OE 2 step"): the 2-step used to release only past 12 km/h WHEELSPEED, so a car that
  -- HOOKS spent its first car-length under the 2% launch governor, re-cutting every time rpm
  -- crossed the limit ("bangs the limiter even when you floor it"). Spinning street launches never
  -- felt it - wheelspin blows through 12 km/h instantly - which is why it survived to the drag
  -- beta. A real 2-step releases at the LAUNCH EVENT, not a speed window:
  --   ENGAGE only near standstill (< 3 km/h, where you actually stage);
  --   RELEASE the instant the clutch comes up (the clutch-switch path, manuals),
  --     or as soon as the car is genuinely rolling (>= 3 km/h - autos / trans brakes),
  --     or on a deep rpm dip while moving (the driveline has coupled = the launch happened);
  --   then LATCH OFF until back at a standstill - one launch per stage, like real launch control.
  -- 12 km/h stays only as the outer safety bound of the CONTINUE branch.
  -- Re-arm needs standstill AND a lifted throttle: during the first instants of the rollout the
  -- car is still < 2 km/h but the driver is pinned - without the throttle guard the latch would
  -- clear mid-launch and re-cut, recreating the exact stumble this block removes.
  if speedKmh < 2 and throttle < 0.3 then H.launchDone = false end
  -- V3.0 auto-clutch fix: clutchIn is the manual clutch PEDAL; automatic-clutch / keyboard / gamepad
  -- cars never feed it, so H.launchClutchArmed stays false and the "clutch came up" release branch is
  -- dead on them. Release then relied on speedKmh >= 3, which the slipping auto-clutch (pinned by the
  -- 2% governor cut) crawls to only after 1-3 s, so the governor chopped the whole 0->3 km/h roll and
  -- the clutch slipped (the field report). On a car with NO clutch pedal the launch is committed the
  -- instant it rolls, so release at the first real motion and let full power reach the wheels. Manual
  -- launches (clutch held in -> armed) keep the exact clutch-up release; they never hit this branch.
  -- V3.0.9 TRANS-BRAKE FIX (community report: "2-step works sometimes, other times the car just revs
  -- through it - I have to floor it, let off, and floor it again"). The clutchless release below used
  -- to fire the instant speedKmh crossed 0.5, which is 0.14 m/s - BeamNG's own two-step uses 0.5 m/s
  -- (twoStepLaunch.lua:53), 3.6x less twitchy. A car staged on a trans brake is an automatic, so it
  -- never arms the clutch branch, and it creeps, shudders and rocks against the converter while you
  -- hold it. Any one of those blips latched launchDone, the 2-step dropped out, and re-arming needs
  -- BOTH standstill and a lifted throttle - which is precisely the "let off and try again" ritual.
  -- Rolling out of a launch is sustained; staging judder is not. So require the motion to PERSIST
  -- rather than merely occur. 0.25 s at walking pace still releases well inside the first metre, which
  -- keeps the V3.0 auto-clutch fix intact (the governor must not chop the 0->3 km/h roll).
  if speedKmh >= 1.8 then H.launchRollTimer = (H.launchRollTimer or 0) + dt
  else H.launchRollTimer = 0 end
  if H.launchWasActive and ((H.launchClutchArmed and not clutchIn) or speedKmh >= 3
      or (speedKmh >= 0.5 and rpm <= launchLimit - 100)
      or (not H.launchClutchArmed and (H.launchRollTimer or 0) >= 0.25)) then
    H.launchDone = true
  end
  model.launchActive = (settings.launchEnabled == true) and model.tps > 35 and rpm > launchLimit
    and launchLimit < revLimit and speedKmh < 12 and not H.launchDone
    and (speedKmh < 3 or H.launchWasActive == true)
  if model.launchActive then
    if clutchIn then H.launchClutchArmed = true end
  elseif not H.launchWasActive then
    H.launchClutchArmed = false
  end
  H.launchWasActive = model.launchActive
  -- flat-foot / no-lift shift: keep the throttle PINNED, clutch in to swap gears, and the limiter holds
  -- revs at the shift point so you don't over-rev or lose boost. Distinct from launch (which is the
  -- standstill case) by speed; needs near-WOT so it can't trip during normal around-town clutching.
  model.flatShiftActive = (settings.flatShiftEnabled == true) and clutchIn and speedKmh >= 12
    and model.tps > 80 and rpm > flatShiftRpm - 300 and flatShiftRpm < revLimit and not model.blown
  -- rolling anti-lag = a flexible ROLLING 2-STEP. HOLD a bound key (the OE ProTune input action) anywhere
  -- you're on the throttle above idle, and it LOCKS the revs at whatever rpm you pressed at and loads boost
  -- (full anti-lag spool + bangs/flames) until you let go - then you launch off boost. Turbo cars.
  -- ROLLING 2-STEP on ANY engine (V2.6 fix - a V8 muscle-car report caught the over-tight gate): the
  -- REV HOLD + bangs + flames are engine-agnostic, exactly like the standstill 2-step; only the
  -- anti-lag BOOST-holding part is turbo tech, and that self-guards (turbo is nil on NA/blower cars).
  model.rollingActive = rollingHold and (settings.rollingEnabled == true)
    and throttle > 0.05 and rpm > base.idleRPM + 300 and not model.blown
  if model.rollingActive then
    if not rollingWasActive then rollingHoldRpm = rpm end   -- capture the rpm at the PRESS and hold it (no cap)
  else
    rollingHoldRpm = nil
  end
  rollingWasActive = model.rollingActive
  local target = revLimit
  if model.launchActive then target = min(target, launchLimit) end
  if model.flatShiftActive then target = min(target, flatShiftRpm) end
  if model.rollingActive and rollingHoldRpm then target = min(target, rollingHoldRpm) end   -- hold the rev you pressed at
  if model.blown then target = base.idleRPM end   -- dead engine: hold at idle, no revving

  -- V2.9.7 (drag beta: "hard cut acts like a fuel cut, bounces ~500 rpm, no noise"): while the hard
  -- cut owns the MAIN limit, apply the NATIVE limiter ABOVE the bounce band instead of ON it. The
  -- stock default limiter (rpmDrop) is a silent throttle-drop of ~3% of maxRPM engaging at the same
  -- rpm - it held the engine through our whole hysteresis band, masking the bounce and voicing
  -- nothing. 400 rpm of headroom clears the band; the raise ceiling (valve float / torque-curve
  -- V3.1, READ BEFORE "FIXING" THIS: the band is now player-sized (limiterRange, 100-600 rpm) and
  -- the +400 still does NOT need to scale with it. The two bands cannot overlap by construction -
  -- OE's hysteresis lives entirely BELOW hcLimit (it releases at hcLimit - range) while the native
  -- rpmDrop band lives entirely at or above applyTarget, which the max() below pins at or above
  -- target. Scaling this by limiterRange would only raise the native limiter deeper into valve
  -- float for no benefit.
  -- end / stock+1500 - the Rev limit slider's own rule) still caps it, so a no-headroom engine
  -- simply keeps the old masked-but-working behavior. Lowered targets raise via setTempRevLimiter
  -- through the same branches; hcLimit below still cuts at the CONFIGURED limit, not the raised one.
  -- (throttle > 0.2 or hardCutOn): the OE cut latch below needs throttle > 0.2, so raising at ANY
  -- throttle opened a (0, 0.2] free-rev window to hcLimit+400 with nothing cutting - Over-rev
  -- damage territory the pre-wave code never exposed. hardCutOn (last frame's latch) keeps the
  -- raise through the bounce so the band cannot chatter at the 0.2 boundary. Arcade gearboxes
  -- force-upshift off the NATIVE limiter (isRevLimitReached in every stock shiftLogic) - keep the
  -- native limiter ON the band there (pre-wave masked behavior; arcade is not the drag audience).
  local hcOwnsLimit = (settings.hardCut == true) and not model.blown
    and not (model.launchActive or model.flatShiftActive or model.rollingActive)
    and (throttle > 0.2 or hardCutOn)
    and ev("gearboxMode", "") ~= "arcade"
  -- V3.0.9: the ceiling term used to be `min(target + 400, ceiling)`, which was safe only while target
  -- could never exceed the ceiling. Now that it can, that expression would return the CEILING - BELOW
  -- the configured limit - so the native limiter would cut under OE's own hard-cut band, the bounce
  -- would never fire, and hard cut would appear to do nothing. The outer max() pins applyTarget at or
  -- above target so the native cut can only ever sit above OE's band, which is the whole point of +400.
  local hcCeil = base.revLimitCeilingRPM or (target + 400)
  local applyTarget = hcOwnsLimit and max(target, min(target + 400, hcCeil)) or target
  if applyTarget ~= lastAppliedLimiterRPM then
    if base.canRaiseLimiter and applyTarget > base.revLimiterRPM + 1 then
      -- RAISED limiter: setTempRevLimiter can only lower, so move the engine's own hard limiter up
      -- (ceiling enforced in setSetting: torque-curve end / +1500 max). Restored to stock on
      -- disconnect (releaseOutputs), on a lower/stock target below, and on blown (idle hold path).
      if engine.resetTempRevLimiter then pcall(function() engine:resetTempRevLimiter() end) end
      engine.revLimiterAV = applyTarget * rpmToAV
    elseif applyTarget < base.revLimiterRPM - 1 then
      if base.canRaiseLimiter and base.stockRevLimiterAV then engine.revLimiterAV = base.stockRevLimiterAV end
      engine:setTempRevLimiter(applyTarget * rpmToAV)
    else
      if base.canRaiseLimiter and base.stockRevLimiterAV then engine.revLimiterAV = base.stockRevLimiterAV end
      if engine.resetTempRevLimiter then engine:resetTempRevLimiter() end
    end
    lastAppliedLimiterRPM = applyTarget
  end

  -- 2-step BOOST HOLD (V3.0): hold the anti-lag spool THROUGH the launch, decoupled from the rev
  -- governor. The launch governor now releases early so the car hooks (auto-clutch fix); if the spool
  -- dropped WITH it, boost sagged right as the clutch bit ("2-step doesn't hold boost like it used
  -- to"). Latch the spool on while staged and hold it through the launch (WOT, still under ~12 km/h)
  -- until the engine's own load takes over - the pre-rework <12 km/h behavior. Flat-shift and rolling
  -- spool only while genuinely active, as before. Vanilla turbo spools on max(engineLoad, antilagCoef)
  -- (turbocharger.lua), so pinning the coef keeps boost up across the cut.
  if model.launchActive then H.launchBoostHold = true end
  if speedKmh >= 12 or throttle < 0.5 or settings.launchEnabled ~= true then H.launchBoostHold = false end
  if turbo and turbo.setAntilagCoef and (model.flatShiftActive or model.rollingActive or H.launchBoostHold) then
    turbo.setAntilagCoef(1)
  end

  -- 2-step bangs + exhaust flames: launch 2-step, flat-shift AND the rolling 2-step hold revs and dump
  -- raw fuel on ANY engine (a fuel-cut 2-step is very much an NA/V8 thing too). The bangs stay tied to
  -- the ACTIVE window (not the extended boost hold) - the exhaust shouldn't crack once you're rolling.
  -- Runs AFTER updateModel so it overrides its pops this frame; updateModel restores them on release.
  if model.flatShiftActive or model.rollingActive or model.launchActive then
    if engine then
      -- BANGS + FLAMES. afterFireCoef drives BOTH the crack sound AND the exhaust flame size, scaled by the
      -- Pops / flames slider (popsLevel 0..3). The 2-step and the rolling 2-step bang HARD; flat-shift is a
      -- punchy crack through the gear.
      local pl = clamp(settings.popsLevel or 1, 0, 3)
      local af = ((model.launchActive or model.rollingActive) and 7 or 4) * pl
      engine.instantAfterFireCoef = max(base.instantAfterFireCoef or 0, 1.0) * af
      engine.sustainedAfterFireCoef = max(base.sustainedAfterFireCoef or 0, 1.0) * af
    end
  end

  -- GOVERNOR that pins the held rev. BELOW the hold: progressive power cut as rpm approaches it (so it
  -- spools up to the hold smoothly). AT/ABOVE the hold: HARD cut to ~2% torque immediately, so a boost
  -- surge can't drift the rev past the hold. This last part matters most for the ROLLING 2-step, where the
  -- engine is coupled to the car's momentum - a soft 5% floor let a boost pulse push rpm up for ~1s before
  -- it settled; cutting to ~2% the instant we reach the hold kills that drift. Applies to launch + rolling.
  if (model.launchActive or model.rollingActive) and target < base.revLimiterRPM - 1 then
    local g = (rpm >= target) and 0.02 or clamp((target - rpm + 40) / 200, 0.05, 1)
    model.coef = min(model.coef, g)
  end

  -- HARD-CUT rev limiter: bounce off the MAIN rev limit with bangs + flames (not just the 2-step). A soft
  -- limiter eases power off near the limit; a hard cut SLAMS power to zero the instant you hit it, so the
  -- engine bounces against the limiter (the drag/drift "brap-brap") and dumps raw fuel into the hot exhaust =
  -- cracks + flames. Latched with hysteresis so it actually BOUNCES (cut at the limit, hold the cut until
  -- ~300 rpm below, release, climb back, repeat) instead of sitting pinned. On the throttle only, on the MAIN
  -- limit only, and NOT while launch / flat-shift / rolling own the limiter. Works on NA and turbo.
  model.hardCutActive = false
  if (settings.hardCut == true) and not model.blown and throttle > 0.2
     and not (model.launchActive or model.flatShiftActive or model.rollingActive) then
    local hcLimit = (revLimit and revLimit >= 2000) and revLimit or (base.revLimiterRPM or 7000)
    -- V3.1: the hysteresis band is now the player's, as it is on a real standalone (Link, Haltech
    -- and MoTeC all expose the limiter's control range). History: it was 300, cut to 150 in V2.9.7
    -- because with the native limiter raised out of the way and a REAL spark cut below, 300 rpm of
    -- fall read as a slow sag rather than the brap cadence the drag testers wanted. 150 stays the
    -- DEFAULT, so an untouched tune runs the identical code path it did in 3.0.x.
    -- Narrow = hard instant wall that braps. Wide = the engine leans into the limiter and sags.
    local hcRange = clamp(settings.limiterRange or 150, 100, 600)
    -- 1 = Ignition (spark cut: bangs and flames). 2 = Fuel (quiet - the torque floor still holds
    -- the revs, but no spark cut, so no bang samples and no afterfire).
    local cutMode = clamp(floor(settings.limiterCutMode or 1), 1, 2)
    -- Spark-cut hold, seconds. Shortening this is the ONLY way past the cadence ceiling: while
    -- latched the cut is refreshed every frame, so what this really sets is how long spark stays
    -- off AFTER the band releases - dead time at the bottom of every single bounce.
    local cutSec = clamp(settings.limiterCutMs or 50, 10, 150) / 1000
    -- Floor the release point clear of idle. setSetting accepts a rev limit as low as idleRPM+700,
    -- so a 1,500 rpm limit with a 600 rpm band would release at 900 - and because the idle governor
    -- works through throttle while model.coef scales the torque curve, the engine cannot recover:
    -- that is a stall, not a bounce. Unreachable at the old fixed 150.
    local hcRelease = max(hcLimit - hcRange, (base.idleRPM or 800) + 400)
    -- Release the latch on a gear change. At 150 rpm an upshift always dropped clear of the band so
    -- this was never needed; at a player-set 600 it does not, and the engine would carry the torque
    -- floor and a refreshed spark cut into the new gear until it had decelerated the whole band.
    -- Guaranteed on every upshift in arcade gearbox mode, where the native limiter owns the band.
    local hcGear = 0
    if electrics and electrics.values then hcGear = floor(tonumber(electrics.values.gearIndex) or 0) end
    if hcGear ~= hcLastGear then hardCutOn = false end
    hcLastGear = hcGear
    local wasBouncing = hardCutOn
    if rpm >= hcLimit then hardCutOn = true
    elseif rpm <= hcRelease then hardCutOn = false end
    if hardCutOn then
      model.hardCutActive = true
      -- FLOOR, not exact zero (V2.6, from a hard-cut game-crash report): we multiply the engine's
      -- intakeAirDensityCoef by this every frame, and a true 0.0 can blow up division/NaN edge
      -- cases in some modded engines' math and take the whole game down. 0.02 is the same bounce
      -- (revs still fall off the limit - 2% power at redline moves nothing) with no zero edge
      -- case; the launch governor already survives on the same floor.
      model.coef = min(model.coef, 0.02)
      -- V2.9.7 REAL IGNITION CUT (drag beta: "as if it is fuel cut, not ignition cut" - correct
      -- diagnosis): the game's own exported engine:cutIgnition() hard-zeroes torque AND routes
      -- afterfire fuel through the ignition-cut-gated path in combustionEngine.lua, so the loud
      -- bang samples fire deterministically on every bounce instead of by jbeam lottery. Refreshed
      -- while latched, expires ~50 ms after release. The coef floor above stays: engines without
      -- cutIgnition keep the old torque-cut bounce, and the floor is the no-zero safety either way.
      -- max() with the live cut: the sequential/DCT/auto gearboxes drive their own shift cut
      -- through this same field (0.15 s default) - a flat 0.05 overwrite would return spark up
      -- to ~100 ms EARLY mid-shift. Never shorten someone else's cut, only extend our own.
      -- V3.1 CUT MODE. Everything below is the SPARK cut and its consequences, so mode 2 (Fuel)
      -- skips the lot. The torque floor above is deliberately NOT gated: this controller removes
      -- torque through exactly one channel (model.coef -> intakeAirDensityCoef), so dropping the
      -- floor in "Fuel" mode would not simulate a fuel cut - it would remove the limiter entirely
      -- and free-rev the engine into the native limiter sitting 400 rpm above, i.e. straight
      -- through valve float into the over-rev damage band. Both modes hold the revs identically;
      -- what differs is the bang, which is exactly what differs on a real engine.
      if cutMode ~= 2 then
        -- The max() against the LIVE value is not optional and must survive any tuning of cutSec:
        -- sequential/DCT/auto gearboxes drive their own shift cut through this same field (0.15 s),
        -- so a flat overwrite with a shorter hold would hand spark back up to 100 ms early in the
        -- middle of somebody else's shift. Only ever extend our own cut, never shorten theirs.
        if engine and engine.cutIgnition then pcall(function() engine:cutIgnition(max(engine.ignitionCutTime or 0, cutSec)) end) end
        -- one OE voice per bounce (A1b). firePop is chunk-local BELOW this function (no hoisting in
        -- Lua), so the request rides H and updateOverrunPops consumes it.
        if not wasBouncing then H.hardCutPopQueued = true end
        if engine then
          local af = 6 * clamp(settings.popsLevel or 1, 0, 3)
          engine.instantAfterFireCoef = max(base.instantAfterFireCoef or 0, 1.0) * af
          engine.sustainedAfterFireCoef = max(base.sustainedAfterFireCoef or 0, 1.0) * af
        end
      end
    end
  else
    hardCutOn = false
  end

  model.limiterStatus = model.blown and "ENGINE DEAD - reset to repair"
    or (model.rollingActive and "Rolling anti-lag"
    or (model.flatShiftActive and "Flat-shift (NLS)"
    or (model.launchActive and "Launch limit"
    -- V3.1: name the cut mode here rather than adding new telemetry channels. Every diagnostic
    -- surface (Runtime Values, the Remote Dash, Log Studio, the CSV log) already carries
    -- limiterStatus, so "which mode was it actually in" rides along to all of them for free.
    or (model.hardCutActive and (((settings.limiterCutMode or 1) == 2) and "Hard cut (fuel)" or "Hard cut (ignition)")
    -- V3.0.9: float outranks the plain rev-limit text. Without this a raised limit reported "Inactive"
    -- while the head was being destroyed, which is the least useful thing this field could say.
    or (model.floatSeverity > 0 and ("VALVE FLOAT - " .. model.floatStatus)
    or (target < base.revLimiterRPM - 1 and "Rev limit" or "Inactive"))))))
  -- Separate actual cut activity from the descriptive status above. A lowered
  -- configured limit may display "Rev limit" at cruise, but it must not make
  -- Mixture Map reject every sample. Stock/raised soft-limit activity must
  -- still be visible even when the descriptive status is Inactive.
  -- floatSeverity joins this list so Mixture Map discards samples taken while the head is floating:
  -- the mixture up there is a consequence of collapsed cylinder filling, not of the calibration, and
  -- learning from it would poison the table.
  model.limiterActive = model.blown or model.launchActive or model.flatShiftActive
    or model.rollingActive or model.hardCutActive or model.floatSeverity > 0 or rpm >= target - 100
end

-- ---------------------------------------------------------------------------
-- command API (called from the UI via bngApi)
-- ---------------------------------------------------------------------------

local function defaultSettings()
  -- FACTORY INJECTOR SIZING (V2_1): scale the stock injector flow to the engine's real power so a
  -- build that comes big from the factory (e.g. a ~1500hp swap) ships with appropriately large
  -- injectors and runs clean on connect, instead of pegging a fixed 600cc at 113% duty (which then
  -- cascades into low rail pressure + a rich/erratic mixture). ~320hp and under = 600cc (a normal car,
  -- unchanged); scales ~linearly with power up to the 6000cc ceiling (~3200hp). Real builds are injector-
  -- sized to power; this mirrors that so "factory = runs fine" holds for big motors too. Adding a turbo
  -- to an NA car does NOT auto-resize (the working tune is kept on respawn), so a player-added turbo
  -- still needs its injectors sized by hand - exactly like real life.
  local injFlow = clamp(floor(600 * max(1, (base.peakPowerHp or 0) / 320)), 600, 6000)
  return {
    enabled = true, protection = true,
    revLimit = nil, launchEnabled = false, launchLimit = clamp(5500, 2500, base.revLimiterRPM), drivetrainLoss = 15,
    flatShiftEnabled = false, flatShiftRpm = max(3000, (base.revLimiterRPM or 7000) - 500),
    launchSoundVol = 5,   -- 2-step bark volume (0-10, 0 = off); the UI plays the sound, controller just stores it
    launchFuel = 0,       -- V2.8 (Liqurdz): extra fuel % during 2-step / rolling / hard-cut events (real launch-fuel table; 0 = pre-2.8 behavior)
    launchRetard = 0,     -- V2.8: ignition retard during those same events (real launch-retard; more retard = more heat/bang out the pipe)
    knockAudio = true,    -- V3.0: recorded detonation ticking + permanent rod knock (per-car voice)
    rollingEnabled = false,   -- rolling anti-lag (hold a bound key) - holds at whatever rpm you press
    hardCut = false,          -- hard-cut rev limiter: bounce off the MAIN rev limit with bangs + flames
    -- V3.1: the two hard-cut shape controls. These EXACT numbers are the no-op contract and are
    -- repeated in three more tables (H.boostShapeSettingDefaults, app.js normalizeSettings and
    -- app.js MAP_BANK_SETTING_DEFAULTS). If any copy disagrees, a clean 3.0.x MAP bank restore is
    -- rejected outright as clean_bank_mismatch - so change all four together or none.
    limiterCutMode = 1,       -- 1 = Ignition (spark cut: bangs + flames), 2 = Fuel (quiet, no spark cut)
    limiterRange = 150,       -- rpm the bounce falls through before the cut releases (real ECU "control range")
    limiterCutMs = 50,        -- spark-cut hold per bounce (ms); 50 = the old hardcoded 0.05 s
    globalFuelTrim = 0, globalIgnitionTrim = 0,
    boostMode = 2, boostBaseDuty = 45, boostPGain = 0.8, boostIGain = 0.12, boostOvercut = 25,
    boostShapeMode = 1, boostRampUpKpaS = 300, boostRampDownKpaS = 0, boostShiftHoldMs = 250,
    boostGear1TargetTrimKpa = 0, boostGear1DutyTrimPct = 0,
    boostGear2TargetTrimKpa = 0, boostGear2DutyTrimPct = 0,
    boostGear3TargetTrimKpa = 0, boostGear3DutyTrimPct = 0,
    boostGear4TargetTrimKpa = 0, boostGear4DutyTrimPct = 0,
    boostGear5TargetTrimKpa = 0, boostGear5DutyTrimPct = 0,
    boostGear6TargetTrimKpa = 0, boostGear6DutyTrimPct = 0,
    boostGear7TargetTrimKpa = 0, boostGear7DutyTrimPct = 0,
    boostGear8TargetTrimKpa = 0, boostGear8DutyTrimPct = 0,
    boostSpeed1TargetTrimKpa = 0, boostSpeed1DutyTrimPct = 0,
    boostSpeed2TargetTrimKpa = 0, boostSpeed2DutyTrimPct = 0,
    boostSpeed3TargetTrimKpa = 0, boostSpeed3DutyTrimPct = 0,
    boostSpeed4TargetTrimKpa = 0, boostSpeed4DutyTrimPct = 0,
    boostSpeed5TargetTrimKpa = 0, boostSpeed5DutyTrimPct = 0,
    boostSpeed6TargetTrimKpa = 0, boostSpeed6DutyTrimPct = 0,
    knockControl = true, knockThreshold = 1.2, knockMaxRetard = 8, knockRecoveryRate = 0.25, egtLimit = 950,
    canMode = 1, canSpeed = 3, canRuntime = true, canLambda = true, canDash = true,
    auxFanOnTemp = 96, auxFuelPumpPrime = 2, auxShiftLight = max(base.idleRPM + 500, base.revLimiterRPM - 400), auxCheckEngine = true,
    sensorMapOffset = 0, sensorMapScale = 1, sensorTpsOffset = 0, sensorTpsScale = 1,
    sensorIatBias = 0, sensorEctBias = 0, sensorLambdaBias = 0, sensorFuelPressureBias = 0,
    crankEnrich = 25, postStartEnrich = 8, postStartDecay = 12, warmupEnrich = 10, warmupEndTemp = 70,
    accelEnrich = 7, accelDecay = 0.55, wallWetting = 0.35, decelFuelCut = true, decelFuel = -28,
    fuelEquationMode = 1, loadSource = 1, masterFuel = 8, injectorFlow = injFlow, injectorDeadtime = 1, stoichRatio = 14.7, fuelType = 1,
    cllMode = 2, cllEctLockout = 60, cllTpsLockout = 60, cllMapLockout = 150,
    idleEnabled = true, idleTargetRpm = 950, idleBasePos = 35, idleIgnControl = true,
    cylTrim1 = 0, cylTrim2 = 0, cylTrim3 = 0, cylTrim4 = 0, cylTrim5 = 0, cylTrim6 = 0, cylTrim7 = 0, cylTrim8 = 0,
    antilagEnabled = false, antilagRetard = 18, antilagFuel = 12,
    nitrousEnabled = false, nitrousShot = 25, nitrousArmRpm = 3500, nitrousArmTps = 90, nitrousFuel = 15, nitrousRetard = 6,
    dieselMaxFuel = 100, dieselRailPressure = 1200, dieselSmokeLimit = 17, glowPlugs = true,
    compBrakeEnabled = false, compBrakeLevel = 50,   -- V3.0.4 Jake brake: OFF by default so existing tunes are untouched
    dieselNozzleEnabled = false, dieselNozzleFlow = clamp(floor(injFlow * 1.35), 200, 6000),   -- V3.0.4 nozzle model: OFF by default (legacy fuelling preserved)
    dieselSmokeLimiter = false,   -- V3.0.4 ECU fuel clamp: OFF by default
    shiftFirmness = 0, shiftTorqueMgmt = 0, torqueLimitNm = 0, torqueLimitLowGears = false,   -- V3.0.4 transmission: all neutral by default
    converterStall = 0,   -- V3.0.5 drag: 0 = keep the fitted converter
    clutchUpgrade = 1,   -- fitted clutch; fixed 500/800/1300/2200 Nm packages are explicit opt-in
    vvtEnabled = true, vvtIntakeAdvance = 25,
    ethrottleMode = 1, ethrottleCurve = 1, ethrottleTolerance = 12,
    -- Base Timing (V2.9.7): absolute advance reference the ignition display is built on (PCLink
    -- "Set Base Timing" analogue, Trigger Scope page). Engine-aware default: boosted engines run
    -- less static advance than NA. Display-only - physics stays on the trims.
    baseTiming = base.hasTurbo and 12 or 16,
    triggerPattern = 1, popsLevel = 1,
    popSoundMode = 1,   -- what voices the pops: 1 = Both (OE samples + car's own, the classic), 2 = Car's own only (boosted), 3 = OE samples only
    fxStyle = 2,        -- particles/profile: 1 = BeamNG stock, 2 = OE Realistic (default), 3 = OE Show / Coal (legacy big-cloud look)
    smokeDensity = 1,   -- requested visual amount (0 = none .. 2 = double, capped per profile). Cosmetic only
    autoFitAxes = true, -- V3.0: grow RPM/MAP breakpoints to the top boost target + raised rev limit and bilinearly resample every map. Off = fully manual axes (Axis Setup)
    wgType = 1,         -- wastegate hardware: 1 = pneumatic spring gate (floor at the spring, default), 2 = electric gate (full authority)
    rotIdleEnabled = false, rotIdleRpm = 1300, rotIdleChop = 6,   -- rotational idle (drag-scene "rowdy idle"): armed flag, idle target while chopping, cut depth
    -- overrun pops & crackles (tune it like real life): the pops come from the TUNE - turn off decel
    -- fuel cut, richen the overrun (vacuum) cells, and retard ignition there. This page only sets the
    -- conditions (enable, RPM window, lift throttle); the bangs scale with how rich/retarded you tune it.
    overrunThrottle = 8, overrunMinRpm = 2800, overrunPopVol = 6
  }
end

-- numeric clamp ranges for setSetting (mirrors the Assetto applyTuneData clamps).
-- V2.9.7: ranges, bool set and integer-enum set now live in lua/common/oeProTuneShared.lua - the
-- SAME file the remote GE extension requires for its typed boundary, so the two validation layers
-- can never drift again (they did once: popsLevel). This side still CLAMPS; the remote REJECTS.
local settingRanges = require("oeProTuneShared").settingRanges
local boolSettings = require("oeProTuneShared").boolSettings

-- integer ENUM settings (mode selectors): a typed command must send a whole number - a fractional
-- enum would clamp/store a meaningless in-between mode. In-game callers always send integers, so
-- this only rejects values nothing legitimate produces. (Shared definition; includes rotIdleChop
-- as of V2.9.7 - the UI slider steps by 1, and the remote already integer-forced it.)
H.enumSettings = require("oeProTuneShared").enumSettings

-- V3.0.7: axis length limits, shared so the six hand-written "4 to 12" copies cannot drift. Read by
-- R.setAxisValues and setAxis below, and republished in publishState so app.js / remote.html can
-- validate against the live bound instead of their own literals.
H.axisLimits = require("oeProTuneShared").axisLimits

-- V3.0.7: throttle angle (%) at which an ITB runner has reached barometric and manifold pressure
-- stops carrying load information. Real ITB engines lose the signal in roughly the first quarter of
-- pedal travel; 30 keeps a usable if cramped vacuum region below it rather than pinning at idle.
-- On H, not a chunk local: this file is at 193 of LuaJIT's 200-local ceiling and overrunning it means
-- the controller never compiles and the ECU simply does not appear on the car.
H.ITB_SAT_TPS = 30

-- Remote command dispatch (V3 remote studio). Like H, every helper is a FIELD on R, never a new
-- chunk local (LuaJIT 200-local ceiling). R.commands is the explicit allowlist the typed browser
-- boundary may reach - nothing else on M is remotely callable.
local R = { commands = {} }

-- Four controller-owned switchable calibrations. Hardware identity, sensors, fuel/injector setup,
-- axes and protections intentionally remain ECU-global; only the performance/drive-mode layer below
-- follows a bank. Keeping this allowlist on H avoids adding more chunk locals near LuaJIT's limit and
-- gives configureMapBank one exact contract to validate before it accepts anything.
H.mapBankSettings = {
  revLimit = true,
  launchEnabled = true, launchLimit = true, flatShiftEnabled = true, flatShiftRpm = true,
  launchSoundVol = true, launchFuel = true, launchRetard = true, rollingEnabled = true, hardCut = true,
  limiterCutMode = true, limiterRange = true, limiterCutMs = true,   -- V3.1: per-MAP, because hardCut itself is per-MAP - split them and a MAP switch would run the previous MAP's cut shape against the new MAP's limiter
  globalFuelTrim = true, globalIgnitionTrim = true,
  boostMode = true, boostBaseDuty = true, boostPGain = true, boostIGain = true, boostOvercut = true,
  boostShapeMode = true, boostRampUpKpaS = true, boostRampDownKpaS = true, boostShiftHoldMs = true,
  boostGear1TargetTrimKpa = true, boostGear1DutyTrimPct = true,
  boostGear2TargetTrimKpa = true, boostGear2DutyTrimPct = true,
  boostGear3TargetTrimKpa = true, boostGear3DutyTrimPct = true,
  boostGear4TargetTrimKpa = true, boostGear4DutyTrimPct = true,
  boostGear5TargetTrimKpa = true, boostGear5DutyTrimPct = true,
  boostGear6TargetTrimKpa = true, boostGear6DutyTrimPct = true,
  boostGear7TargetTrimKpa = true, boostGear7DutyTrimPct = true,
  boostGear8TargetTrimKpa = true, boostGear8DutyTrimPct = true,
  boostSpeed1TargetTrimKpa = true, boostSpeed1DutyTrimPct = true,
  boostSpeed2TargetTrimKpa = true, boostSpeed2DutyTrimPct = true,
  boostSpeed3TargetTrimKpa = true, boostSpeed3DutyTrimPct = true,
  boostSpeed4TargetTrimKpa = true, boostSpeed4DutyTrimPct = true,
  boostSpeed5TargetTrimKpa = true, boostSpeed5DutyTrimPct = true,
  boostSpeed6TargetTrimKpa = true, boostSpeed6DutyTrimPct = true,
  idleEnabled = true, idleTargetRpm = true, idleBasePos = true, idleIgnControl = true,
  antilagEnabled = true, antilagRetard = true, antilagFuel = true,
  nitrousEnabled = true, nitrousShot = true, nitrousArmRpm = true, nitrousArmTps = true,
  nitrousFuel = true, nitrousRetard = true,
  dieselMaxFuel = true, dieselRailPressure = true, dieselSmokeLimit = true, glowPlugs = true,
  compBrakeEnabled = true, compBrakeLevel = true,
  dieselNozzleEnabled = true, dieselNozzleFlow = true, dieselSmokeLimiter = true,
  shiftFirmness = true, shiftTorqueMgmt = true, torqueLimitNm = true, torqueLimitLowGears = true,
  converterStall = true,
  vvtEnabled = true, vvtIntakeAdvance = true,
  ethrottleMode = true, ethrottleCurve = true, ethrottleTolerance = true,
  decelFuelCut = true, decelFuel = true, popsLevel = true, popSoundMode = true,
  rotIdleEnabled = true, rotIdleRpm = true, rotIdleChop = true,
  overrunThrottle = true, overrunMinRpm = true, overrunPopVol = true
}
-- Despite the name this is the GENERIC map-bank migration table: the nil-tolerating escape that
-- lets a package saved by an older build (which has no key for a setting added later) restore
-- instead of failing validation with missing_bank_setting. Every new per-MAP setting belongs here.
H.boostShapeSettingDefaults = {
  boostShapeMode = 1, boostRampUpKpaS = 300, boostRampDownKpaS = 0, boostShiftHoldMs = 250,
  limiterCutMode = 1, limiterRange = 150, limiterCutMs = 50   -- V3.1: MUST equal defaultSettings() exactly
}
for gear = 1, 8 do
  H.boostShapeSettingDefaults["boostGear" .. gear .. "TargetTrimKpa"] = 0
  H.boostShapeSettingDefaults["boostGear" .. gear .. "DutyTrimPct"] = 0
end
for point = 1, 6 do
  H.boostShapeSettingDefaults["boostSpeed" .. point .. "TargetTrimKpa"] = 0
  H.boostShapeSettingDefaults["boostSpeed" .. point .. "DutyTrimPct"] = 0
end
H.mapBanks = {}
H.activeMapBank = 1
H.pendingMapBank = nil
H.pendingMapReload = false
H.pendingMapDiscard = false
H.workingDirty = false
H.defaultMapBank = 1
H.mapBankSequence = 0
H.mapBankMutationResult = nil

function R.releaseMomentaryInputs()
  rollingHold = false
  rollingWasActive = false
  rollingHoldRpm = nil
  model.rollingActive = false
  lastAppliedLimiterRPM = -1
  return true
end

function H.denseFiniteArray(values, minCount, maxCount)
  if type(values) ~= "table" then return nil, "values_not_array" end
  local count, highest = 0, 0
  for k in pairs(values) do
    local ik = H.integerNumber(k)
    if ik == nil or ik < 1 or ik > maxCount then return nil, "invalid_array_index" end
    count = count + 1
    if ik > highest then highest = ik end
  end
  if count ~= highest then return nil, "sparse_array" end
  if count < minCount or count > maxCount then return nil, "invalid_value_count" end
  local out = {}
  for i = 1, count do
    local n = H.finiteNumber(values[i])
    if n == nil then return nil, "non_finite_value" end
    out[i] = n
  end
  return out
end

function H.parseNumberCsv(csv, minCount, maxCount)
  if type(csv) ~= "string" then return nil, "values_not_csv" end
  if #csv == 0 or #csv > 512 then return nil, "invalid_csv_length" end
  local out = {}
  for token in (csv .. ","):gmatch("(.-),") do
    token = token:match("^%s*(.-)%s*$")
    if token == "" then return nil, "empty_csv_value" end
    local n = H.finiteNumber(token)
    if n == nil then return nil, "non_finite_value" end
    out[#out + 1] = n
    if #out > maxCount then return nil, "invalid_value_count" end
  end
  if #out < minCount or #out > maxCount then return nil, "invalid_value_count" end
  return out
end

-- STFT/LTFT describe the fuel calibration that produced them. Carrying those
-- trims across a base-fuel/VE/target/sensor change recreates the old "fixed
-- the VE cell but it still shows -12" behavior, so start feedback from a clean
-- state whenever the underlying fuel model changes.
local fuelLearningSettings = {
  fuelEquationMode = true, loadSource = true, globalFuelTrim = true, masterFuel = true,
  injectorFlow = true, injectorDeadtime = true, fuelType = true, stoichRatio = true,
  cllMode = true, cllEctLockout = true, cllTpsLockout = true, cllMapLockout = true,
  crankEnrich = true, postStartEnrich = true, postStartDecay = true,
  warmupEnrich = true, warmupEndTemp = true, accelEnrich = true,
  accelDecay = true, wallWetting = true, decelFuelCut = true, decelFuel = true,
  launchFuel = true,
  sensorMapOffset = true, sensorMapScale = true, sensorTpsOffset = true,
  sensorTpsScale = true, sensorIatBias = true, sensorEctBias = true,
  sensorLambdaBias = true, sensorFuelPressureBias = true,
  cylTrim1 = true, cylTrim2 = true, cylTrim3 = true, cylTrim4 = true,
  cylTrim5 = true, cylTrim6 = true, cylTrim7 = true, cylTrim8 = true
}

local function resetFuelLearningState(reason)
  model.stft = 0
  model.ltft = 0
  model.adaptiveFuelTrim = 0
  model.closedLoop = false
  model.cllStatus = reason or "Fuel model changed - relearning"
end

function H.mapBankIndex(index)
  index = H.integerNumber(index)
  if index == nil or index < 1 or index > 4 then return nil end
  return index
end

function H.copySwitchableSettings(source)
  local out = {}
  source = source or settings
  for key in pairs(H.mapBankSettings) do out[key] = source[key] end
  return out
end

function H.copyMapSet(source)
  local out = {}
  for mapName in pairs(mapMeta) do
    out[mapName] = {}
    for r = 1, NUM_ROWS do
      out[mapName][r] = {}
      for c = 1, NUM_COLS do out[mapName][r][c] = source[mapName][r][c] end
    end
  end
  return out
end

function H.snapshotWorkingMapBank(name)
  return {
    name = name,
    maps = H.copyMapSet(maps),
    settings = H.copySwitchableSettings(settings),
    boostTableFactory = boostTableFactory and true or false
  }
end

function H.clearPendingMapBank()
  local hadPending = H.pendingMapBank ~= nil
  H.pendingMapBank = nil
  H.pendingMapReload = false
  H.pendingMapDiscard = false
  H.pendingMapBankFromInput = false
  return hadPending
end

-- A working calibration is deliberately separate from every saved MAP snapshot. Any real live
-- performance edit dirties that working copy and cancels a queued switch/reload: otherwise an edit
-- made while waiting for throttle/boost to fall could be silently discarded on the next safe frame.
function H.markWorkingChanged(preservePending)
  local changed = false
  if preservePending ~= true then
    -- A keybind-armed switch has no UI toast watching it, so its cancellation must be audible here.
    local inputPending = H.pendingMapBankFromInput and H.pendingMapBank ~= nil
    changed = H.clearPendingMapBank()
    if changed and inputPending and H.mapBankInputMessage then
      H.mapBankInputMessage("Pending MAP switch cancelled - the working tune was edited")
    end
  end
  if not H.workingDirty then H.workingDirty = true; changed = true end
  if changed then H.mapBankSequence = H.mapBankSequence + 1 end
  stateDirty = true
end

-- Cancel a pending switch because a slot mutation invalidated it, announcing the cancellation when
-- the pending was keybind-armed (the tuner app may be off screen; the keybind path never resolves
-- silently). Trusted package/session swaps keep calling clearPendingMapBank directly.
function H.cancelPendingMapBankAnnounced(reason)
  local inputPending = H.pendingMapBankFromInput and H.pendingMapBank ~= nil
  local hadPending = H.clearPendingMapBank()
  if hadPending and inputPending and H.mapBankInputMessage then
    H.mapBankInputMessage("Pending MAP switch cancelled - " .. reason)
  end
  return hadPending
end

function H.resetMapBanksFromCurrent()
  -- A whole-package reset supersedes any retained Save/Rename/Clear/Default result. Without this,
  -- the reset State could carry an old success body and let a reopening UI reattach pre-reset data.
  H.mapBankMutationResult = nil
  H.mapBanks = {}
  H.activeMapBank, H.defaultMapBank = 1, 1
  H.clearPendingMapBank()
  H.workingDirty = false
  H.mapBanks[1] = H.snapshotWorkingMapBank("MAP 1")
  H.mapBankSequence = H.mapBankSequence + 1
  stateDirty = true
end

function H.arrayShapeIsExact(value, count)
  if type(value) ~= "table" then return false end
  local seen = 0
  for key in pairs(value) do
    key = H.integerNumber(key)
    if key == nil or key < 1 or key > count then return false end
    seen = seen + 1
  end
  return seen == count
end

function H.boostMapIsFactoryFlat(boostMap)
  if type(boostMap) ~= "table" then return false end
  local firstRow = boostMap[1]
  local reference = type(firstRow) == "table" and H.finiteNumber(firstRow[1]) or nil
  if reference == nil then return false end
  for r = 1, NUM_ROWS do
    local row = boostMap[r]
    if type(row) ~= "table" then return false end
    for c = 1, NUM_COLS do
      local value = H.finiteNumber(row[c])
      if value == nil or abs(value - reference) > 0.5 then return false end
    end
  end
  return true
end

-- V3.0.2 persistence repair: older UI/controller races could leave a tuned Boost Target table
-- carrying boostTableFactory=true. That flag means "ignore this table and keep stock wastegate
-- behavior", so a non-flat table can never truthfully carry it. Readers correctly rejected the
-- contradiction, but Quick Save/Library used to write it without validation and strand the player.
--
-- Repair only the contradictory metadata; table cells/settings are never clamped or rewritten.
-- This trusted in-game hook also repairs controller-owned M1-M4 bodies so the UI and vehicle
-- authority cannot diverge after the recovered package is saved again.
function H.repairMapBankFactoryClaims()
  if not active then return false, "inactive" end
  local repaired, workingRepaired = false, false
  if boostTableFactory and not H.boostMapIsFactoryFlat(maps and maps.boost) then
    boostTableFactory = false
    repaired, workingRepaired = true, true
  end
  for index = 1, 4 do
    local bank = H.mapBanks and H.mapBanks[index]
    if bank and bank.boostTableFactory and not H.boostMapIsFactoryFlat(bank.maps and bank.maps.boost) then
      bank.boostTableFactory = false
      repaired = true
    end
  end
  if not repaired then return true, "unchanged" end
  if workingRepaired then
    H.workingDirty = true
    -- Clearing the working flag hands the wastegate to a Boost Target table the ECU was previously
    -- ignoring (the stock-passthrough branch). That is a real control-authority handover, so the
    -- closed loop must start from a clean slate exactly as it does on an M1-M4 switch - otherwise a
    -- integral wound up against the old passthrough target carries into the recovered table and can
    -- spike or bog the first boost event after the repair.
    H.resetBoostControlMemory()
  end
  -- Deliberately NOT clearing a pending MAP bank switch: repairing metadata does not invalidate a
  -- queued M1-M4 selection, and silently dropping one made a keybind-armed switch vanish with no
  -- announcement (V3.0.2 regression).
  H.mapBankSequence = H.mapBankSequence + 1
  H.markCalibrationChanged()
  stateDirty = true
  return true, "repaired"
end

function H.validateMapBankData(index, data)
  if type(data) ~= "table" then return nil, "invalid_bank_data" end
  if type(data.name) ~= "string" then return nil, "invalid_bank_name" end
  local name = data.name:match("^%s*(.-)%s*$")
  if name == "" or #name > 32 then return nil, "invalid_bank_name" end
  if type(data.maps) ~= "table" then return nil, "invalid_bank_maps" end
  if type(data.settings) ~= "table" then return nil, "invalid_bank_settings" end

  -- Build a wholly independent candidate. Nothing controller-owned is touched unless every table,
  -- every cell and every switchable setting validates, so malformed loads can never half-apply.
  local candidate = { name = name, maps = {}, settings = {} }
  for mapName, meta in pairs(mapMeta) do
    local src = data.maps[mapName]
    if not H.arrayShapeIsExact(src, NUM_ROWS) then return nil, "invalid_map_rows" end
    candidate.maps[mapName] = {}
    for r = 1, NUM_ROWS do
      if not H.arrayShapeIsExact(src[r], NUM_COLS) then return nil, "invalid_map_columns" end
      candidate.maps[mapName][r] = {}
      for c = 1, NUM_COLS do
        local value = H.finiteNumber(src[r][c])
        if value == nil then return nil, "non_finite_map_value" end
        if value < meta.min or value > meta.max then return nil, "map_value_out_of_range" end
        candidate.maps[mapName][r][c] = value
      end
    end
  end

  for key in pairs(H.mapBankSettings) do
    local value = data.settings[key]
    -- Migrate only the settings introduced with boost shaping. Older M1-M4 packages did not carry
    -- them; deterministic zero/off defaults preserve the old calibration without weakening the
    -- validator for any pre-existing field.
    if value == nil and H.boostShapeSettingDefaults[key] ~= nil then value = H.boostShapeSettingDefaults[key] end
    if key == "revLimit" then
      if value ~= nil then
        value = H.finiteNumber(value)
        if value == nil then return nil, "invalid_bank_setting" end
        if value <= 0 then value = nil
        elseif value < base.idleRPM + 700 or value > (base.revLimitCeilingRPM or base.revLimiterRPM) then
          return nil, "bank_setting_out_of_range"
        end
      end
    elseif boolSettings[key] then
      if value == nil then return nil, "missing_bank_setting" end
      value = H.booleanValue(value)
      if value == nil then return nil, "invalid_bank_setting" end
    else
      if value == nil then return nil, "missing_bank_setting" end
      value = H.finiteNumber(value)
      if value == nil then return nil, "invalid_bank_setting" end
      local range = settingRanges[key]
      if not range or value < range[1] or value > range[2] then return nil, "bank_setting_out_of_range" end
      if H.enumSettings[key] and value ~= floor(value) then return nil, "enum_must_be_integer" end
    end
    candidate.settings[key] = value
  end

  if data.boostTableFactory == nil then
    candidate.boostTableFactory = false
  else
    candidate.boostTableFactory = H.booleanValue(data.boostTableFactory)
    if candidate.boostTableFactory == nil then return nil, "invalid_boost_factory_flag" end
  end
  if candidate.boostTableFactory and not H.boostMapIsFactoryFlat(candidate.maps.boost) then
    return nil, "factory_boost_table_not_flat"
  end
  return candidate
end

function H.applyMapBankSettings(bank)
  for key in pairs(H.mapBankSettings) do settings[key] = bank.settings[key] end
end

function H.resetBoostControlMemory(rampOriginKpa)
  model.wgOffset, model.boostIntegral, model.wastegateDuty, model.boostError = 0, 0, 0, 0
  model.overboostTimer = 0   -- V2.9.7: fault-maturation timer must not carry across a MAP switch/reset
  model.boostILimitLo = 0    -- V2.9.7: published as telemetry now - never show a stale authority floor
  -- V2.9.7 wave 4: step-down detector, taper filter and loop-decomposition state start fresh
  H.prevBoostReqKpa, H.boostTaperErrFilt = nil, nil
  model.boostProtGraceTimer, model.boostProtGraceKpa = 0, 0
  model.boostPTermPsi, model.boostDTermPsi, model.boostITermPsi, model.boostBiasPsi = 0, 0, 0, 0
  model.prevBoostPSI = max(0, ((model.mapKpa or model.baroKpa or 101.325) - (model.baroKpa or 101.325)) * KPA_TO_PSI)
  model.boostMaxed, model.boostFloored = false, false
  -- Preserve the old commanded target as the ramp origin across an M1-M4 switch. Resetting this to
  -- nil would let a newly selected high-boost MAP jump past the configured rise rate for one frame.
  H.boostRampTargetKpa = clamp(H.finiteNumber(rampOriginKpa) or model.boostTargetKpa or model.baroKpa or 101.325,
    model.baroKpa or 101.325, mapMeta.boost.max)
  H.boostFilteredSpeedKmh = nil
  H.boostHeldGearTargetTrim, H.boostHeldGearDutyTrim = nil, nil
  H.boostGearTargetHoldTimer, H.boostGearDutyHoldTimer = 0, 0
  H.boostLastGearTargetRequest, H.boostLastGearDutyRequest = nil, nil
  model.boostPidDutyTrim = 0
  boostMaxedTimer, boostFloorTimer, boostLearnTimer = 0, 0, 0
  lastWastegateOffset = nil
end

function H.resetMapBankTransients()
  -- One reset per coherent bank commit: feedback learned against the previous fuel surface and boost
  -- PID memory must not bleed into the next map. Damage, DTCs and knock history are intentionally untouched.
  resetFuelLearningState("Map switched - relearning")
  H.resetBoostControlMemory()
  lastAppliedLimiterRPM, hardCutOn = -1, false
  smokeFx.reset()
  if R.resetMapBankEffects then R.resetMapBankEffects() end
  R.releaseMomentaryInputs()
end

function H.commitMapBank(index)
  index = H.mapBankIndex(index)
  local bank = index and H.mapBanks[index]
  if not bank then H.clearPendingMapBank(); stateDirty = true; return false, "bank_not_configured" end
  -- Saved banks are immutable under normal tuning (the untouched factory boost auto-seed is the
  -- one explicit controller-owned migration). Always load an independent working copy so subsequent
  -- player edits cannot mutate the stored MAP through a shared table reference.
  local announceInput = H.pendingMapBankFromInput == true
  maps = H.copyMapSet(bank.maps)
  H.dropGridRestore()   -- V3.0.7: the working copy is now a different bank's tune
  boostTableFactory = bank.boostTableFactory and true or false
  H.applyMapBankSettings(bank)
  H.activeMapBank = index
  H.clearPendingMapBank()
  H.workingDirty = false
  H.mapBankSequence = H.mapBankSequence + 1
  H.resetMapBankTransients()
  stateDirty = true
  -- Keybind-initiated switches commit here, possibly long after the press and with no tuner app on
  -- screen; a real cal-select input always reflects on the dash, so announce the landing too.
  if announceInput and H.mapBankInputMessage then
    H.mapBankInputMessage("M" .. index .. " (" .. (bank.name or ("MAP " .. index)) .. ") ACTIVE")
  end
  return true
end

function H.configureMapBank(index, data, markDirty)
  if not active then return false, "inactive" end
  index = H.mapBankIndex(index)
  if not index then return false, "invalid_bank_index" end
  local candidate, err = H.validateMapBankData(index, data)
  if not candidate then return false, err end
  H.mapBanks[index] = candidate
  -- Configuration is persistence-only: never overwrite the live working tune. A pending request
  -- might otherwise load a newly replaced snapshot the driver never selected, so cancel it.
  H.cancelPendingMapBankAnnounced("a MAP slot was reconfigured")
  if index == H.activeMapBank then H.workingDirty = true end
  H.mapBankSequence = H.mapBankSequence + 1
  if markDirty ~= false then H.markCalibrationChanged() else stateDirty = true end
  return true
end

function H.mapBankCandidateMatchesWorking(candidate)
  if type(candidate) ~= "table" or type(candidate.maps) ~= "table" or type(candidate.settings) ~= "table" then return false end
  for mapName in pairs(mapMeta) do
    local liveMap, savedMap = maps[mapName], candidate.maps[mapName]
    if type(liveMap) ~= "table" or type(savedMap) ~= "table" then return false end
    for r = 1, NUM_ROWS do
      if type(liveMap[r]) ~= "table" or type(savedMap[r]) ~= "table" then return false end
      for c = 1, NUM_COLS do
        local liveValue, savedValue = H.finiteNumber(liveMap[r][c]), H.finiteNumber(savedMap[r][c])
        if liveValue == nil or savedValue == nil or abs(liveValue - savedValue) > 0.000001 then return false end
      end
    end
  end
  for key in pairs(H.mapBankSettings) do
    local liveValue, savedValue = settings[key], candidate.settings[key]
    if type(liveValue) == "number" or type(savedValue) == "number" then
      liveValue, savedValue = H.finiteNumber(liveValue), H.finiteNumber(savedValue)
      if liveValue == nil or savedValue == nil or abs(liveValue - savedValue) > 0.000001 then return false end
    elseif liveValue ~= savedValue then return false end
  end
  return true
end

-- Atomic whole-package restore. Validate every slot into private candidates before changing a
-- single controller field, so malformed persistence can never leave a half-old/half-new bank set.
-- It also atomically associates the already-replayed live session metadata. It never rewrites live
-- maps/settings; restoring the trusted factory-boost mode is intentionally the final part of the
-- tune-load transaction and can change boost-controller behavior on the following frame.
function H.configureMapBankPackage(package, markDirty)
  if not active then return false, "inactive" end
  if type(package) ~= "table" then return false, "invalid_bank_package" end
  local slots = package.slots or package.banks
  if not H.arrayShapeIsExact(slots, 4) then return false, "invalid_bank_slots" end
  local candidates, seen = {}, {}
  for position = 1, 4 do
    local slot = slots[position]
    if type(slot) ~= "table" then return false, "invalid_bank_slot" end
    local index = H.mapBankIndex(slot.index or position)
    if not index or seen[index] then return false, "invalid_bank_index" end
    seen[index] = true
    if type(slot.name) ~= "string" then return false, "invalid_bank_name" end
    local name = slot.name:match("^%s*(.-)%s*$")
    if name == "" or #name > 32 then return false, "invalid_bank_name" end
    local configured = H.booleanValue(slot.configured)
    if configured == nil then return false, "invalid_bank_configured" end
    if configured then
      local body = slot.data or slot
      if type(body) ~= "table" then return false, "invalid_bank_data" end
      local candidate, err = H.validateMapBankData(index, {
        name = name, maps = body.maps, settings = body.settings,
        boostTableFactory = body.boostTableFactory
      })
      if not candidate then return false, err end
      candidates[index] = candidate
    end
  end
  local defaultIndex = H.mapBankIndex(package.default)
  if not defaultIndex then return false, "invalid_default_bank" end
  if not candidates[defaultIndex] then return false, "default_bank_not_configured" end
  local activeIndex = H.mapBankIndex(package.active)
  if not activeIndex then return false, "invalid_active_bank" end
  if not candidates[activeIndex] then return false, "active_bank_not_configured" end
  local packageWorkingDirty = H.booleanValue(package.workingDirty)
  if packageWorkingDirty == nil then return false, "invalid_working_dirty" end
  local packageBoostFactory = H.booleanValue(package.workingBoostTableFactory)
  if packageBoostFactory == nil then return false, "invalid_boost_factory_flag" end
  if packageBoostFactory and not H.boostMapIsFactoryFlat(maps.boost) then
    return false, "factory_boost_table_not_flat"
  end
  -- A package claiming that the working tune is clean must actually match its active immutable
  -- snapshot. Enforce this again at the controller boundary so a stale/malformed UI package cannot
  -- silently associate unrelated boost shaping (or any other performance setting) with the live tune.
  if not packageWorkingDirty then
    local activeCandidate = candidates[activeIndex]
    if not H.mapBankCandidateMatchesWorking(activeCandidate)
      or (packageBoostFactory == true) ~= (activeCandidate.boostTableFactory == true) then
      return false, "clean_bank_mismatch"
    end
  end

  -- The UI has already replayed the top-level live calibration. Associate that working body in the
  -- same indivisible package swap so no input frame can observe an old/empty active index between a
  -- saved-bank replacement and a later session-restore command.
  H.mapBankMutationResult = nil
  H.mapBanks = candidates
  H.dropGridRestore()   -- V3.0.7: a whole new bank package - the snapshot describes a dead world
  H.defaultMapBank = defaultIndex
  H.activeMapBank = activeIndex
  H.workingDirty = packageWorkingDirty
  boostTableFactory = packageBoostFactory
  H.resetBoostControlMemory()
  H.clearPendingMapBank()
  H.mapBankSequence = H.mapBankSequence + 1
  if markDirty ~= false then H.markCalibrationChanged() else stateDirty = true end
  return true
end

function H.mapBankStateMatches(expectedActive, expectedSequence, expectedEpoch)
  if expectedEpoch ~= nil and tostring(expectedEpoch) ~= tostring(H.epoch or "") then
    return false, "stale_map_bank_state"
  end
  if expectedActive ~= nil then
    expectedActive = H.mapBankIndex(expectedActive)
    if not expectedActive or expectedActive ~= H.activeMapBank then return false, "stale_map_bank_state" end
  end
  if expectedSequence ~= nil then
    expectedSequence = H.integerNumber(expectedSequence)
    if expectedSequence == nil or expectedSequence ~= H.mapBankSequence then return false, "stale_map_bank_state" end
  end
  return true
end

function H.validMapBankMutationToken(token)
  if token == nil then return nil end
  if type(token) ~= "string" or #token < 1 or #token > 64 or not token:match("^[%w_%-]+$") then
    return false, "invalid_mutation_token"
  end
  return token
end

function H.recordMapBankMutation(token, ok, err, data, operation, index, name)
  if token then
    H.mapBankMutationResult = {
      token = token, ok = ok == true,
      error = ok == true and "" or tostring(err or "map_bank_mutation_rejected"),
      data = ok == true and data or nil,
      operation = operation,
      index = index,
      name = name,
      -- Let a freshly relinked UI prove that a retained result belongs to the exact
      -- State carrying it. This is also the one permitted delta from its session cache.
      sequence = H.mapBankSequence,
      calibrationRevision = H.revision
    }
    stateDirty = true
  end
  return ok, err
end

function H.saveCurrentToMapBank(index, name, markDirty, expectedActive, expectedSequence, expectedEpoch)
  if not active then return false, "inactive" end
  local expectedOk, expectedErr = H.mapBankStateMatches(expectedActive, expectedSequence, expectedEpoch)
  if not expectedOk then return false, expectedErr end
  index = H.mapBankIndex(index)
  if not index then return false, "invalid_bank_index" end
  if name == nil then name = H.mapBanks[index] and H.mapBanks[index].name or ("MAP " .. index) end
  if type(name) ~= "string" then return false, "invalid_bank_name" end
  name = name:match("^%s*(.-)%s*$")
  if name == "" or #name > 32 then return false, "invalid_bank_name" end
  -- Snapshot first, then associate it. The live maps/settings/boost flag are untouched, so saving
  -- to M2 while driving does not create even a one-frame output discontinuity.
  H.mapBanks[index] = H.snapshotWorkingMapBank(name)
  H.dropGridRestore()   -- V3.0.7: saving re-associates the working copy with a bank
  H.activeMapBank = index
  H.cancelPendingMapBankAnnounced("a tune was saved")
  H.workingDirty = false
  H.mapBankSequence = H.mapBankSequence + 1
  if markDirty ~= false then H.markCalibrationChanged() else stateDirty = true end
  return true
end

function H.renameMapBank(index, name, markDirty, expectedSequence, expectedEpoch)
  if not active then return false, "inactive" end
  local expectedOk, expectedErr = H.mapBankStateMatches(nil, expectedSequence, expectedEpoch)
  if not expectedOk then return false, expectedErr end
  index = H.mapBankIndex(index)
  if not index then return false, "invalid_bank_index" end
  local bank = H.mapBanks[index]
  if not bank then return false, "bank_not_configured" end
  if type(name) ~= "string" then return false, "invalid_bank_name" end
  name = name:match("^%s*(.-)%s*$")
  if name == "" or #name > 32 then return false, "invalid_bank_name" end
  if bank.name == name then stateDirty = true; return true end
  bank.name = name
  H.mapBankSequence = H.mapBankSequence + 1
  if markDirty ~= false then H.markCalibrationChanged() else stateDirty = true end
  return true
end

-- Trusted persistence hook: the UI has already replayed the top-level live maps/settings and now
-- associates that working copy with one configured saved bank. This never loads or saves a bank.
-- The optional factory flag preserves a dirty factory-passthrough draft across a trusted session
-- restore; like replaying the draft itself, changing that mode can affect the following frame.
function H.restoreMapBankSession(index, workingDirty, workingBoostTableFactory)
  if not active then return false, "inactive" end
  index = H.mapBankIndex(index)
  if not index then return false, "invalid_bank_index" end
  if not H.mapBanks[index] then return false, "bank_not_configured" end
  workingDirty = H.booleanValue(workingDirty)
  if workingDirty == nil then return false, "invalid_working_dirty" end
  local resetBoostMemory = H.activeMapBank ~= index
  if workingBoostTableFactory ~= nil then
    workingBoostTableFactory = H.booleanValue(workingBoostTableFactory)
    if workingBoostTableFactory == nil then return false, "invalid_boost_factory_flag" end
    -- V3.0.3: this was the last unguarded writer of the flag, and it is the one the UI calls on
    -- every session restore. Without this check it could re-create the exact "factory=true beside a
    -- tuned table" contradiction that H.repairMapBankFactoryClaims exists to clean, turning a
    -- one-time migration into a repair that fires forever. Every sibling mutator already refuses
    -- this combination; the flag means "ignore this table", so a non-flat table can never carry it.
    if workingBoostTableFactory and not H.boostMapIsFactoryFlat(maps and maps.boost) then
      return false, "factory_boost_table_not_flat"
    end
    if boostTableFactory ~= workingBoostTableFactory then resetBoostMemory = true end
    boostTableFactory = workingBoostTableFactory
  end
  H.dropGridRestore()   -- V3.0.7: session restore re-seats the working copy against a bank
  H.activeMapBank = index
  if resetBoostMemory then H.resetBoostControlMemory() end
  H.clearPendingMapBank()
  H.workingDirty = workingDirty
  H.mapBankSequence = H.mapBankSequence + 1
  stateDirty = true
  return true
end

function H.clearMapBank(index, markDirty, expectedActive, expectedSequence, expectedEpoch)
  if not active then return false, "inactive" end
  local expectedOk, expectedErr = H.mapBankStateMatches(expectedActive, expectedSequence, expectedEpoch)
  if not expectedOk then return false, expectedErr end
  index = H.mapBankIndex(index)
  if not index then return false, "invalid_bank_index" end
  if index == H.activeMapBank then return false, "cannot_clear_active_bank" end
  if not H.mapBanks[index] then return true end
  H.mapBanks[index] = nil
  if H.pendingMapBank == index then H.cancelPendingMapBankAnnounced("the queued MAP slot was cleared") end
  if H.defaultMapBank == index then H.defaultMapBank = H.activeMapBank end
  H.mapBankSequence = H.mapBankSequence + 1
  if markDirty ~= false then H.markCalibrationChanged() else stateDirty = true end
  return true
end

function H.selectMapBank(index, discardWorking)
  if not active then return false, "inactive" end
  index = H.mapBankIndex(index)
  if not index then return false, "invalid_bank_index" end
  if not H.mapBanks[index] then return false, "bank_not_configured" end
  if discardWorking == nil then discardWorking = false
  else
    discardWorking = H.booleanValue(discardWorking)
    if discardWorking == nil then return false, "invalid_discard_flag" end
  end
  if H.workingDirty and not discardWorking and index ~= H.activeMapBank then
    return false, "working_map_unsaved"
  end
  if index == H.activeMapBank and not discardWorking then
    if H.clearPendingMapBank() then H.mapBankSequence = H.mapBankSequence + 1 end
    stateDirty = true
    return true
  end
  H.pendingMapBank = index
  H.pendingMapReload = index == H.activeMapBank
  H.pendingMapDiscard = H.workingDirty and true or false
  -- Arming defaults to UI-initiated; the keybind wrappers re-flag it right after this returns, so
  -- an app click that replaces a keybind-armed pending doesn't inherit the game-message channel.
  H.pendingMapBankFromInput = false
  H.mapBankSequence = H.mapBankSequence + 1
  stateDirty = true
  return true
end

function H.cycleMapBank(direction)
  if not active then return false, "inactive" end
  if H.workingDirty then return false, "working_map_unsaved" end
  direction = H.integerNumber(direction)
  if direction == nil or direction == 0 then return false, "invalid_direction" end
  direction = direction > 0 and 1 or -1
  local start = H.pendingMapBank or H.activeMapBank
  for offset = 1, 3 do
    local index = ((start - 1 + direction * offset) % 4) + 1
    if H.mapBanks[index] then return H.selectMapBank(index) end
  end
  return false, "bank_not_configured"
end

function H.discardWorkingMapBank()
  if not active then return false, "inactive" end
  if not H.mapBanks[H.activeMapBank] then return false, "bank_not_configured" end
  return H.selectMapBank(H.activeMapBank, true)
end

function H.setDefaultMapBank(index, markDirty, expectedSequence, expectedEpoch)
  if not active then return false, "inactive" end
  local expectedOk, expectedErr = H.mapBankStateMatches(nil, expectedSequence, expectedEpoch)
  if not expectedOk then return false, expectedErr end
  index = H.mapBankIndex(index)
  if not index then return false, "invalid_bank_index" end
  if not H.mapBanks[index] then return false, "bank_not_configured" end
  if index == H.defaultMapBank then stateDirty = true; return true end
  H.defaultMapBank = index
  H.mapBankSequence = H.mapBankSequence + 1
  if markDirty ~= false then H.markCalibrationChanged() else stateDirty = true end
  return true
end

function H.mapBankSwitchBlocked()
  H.mapBankBlockReason = nil
  if not (controlling and settings.enabled ~= false) then return false end
  local baro = model.baroKpa or 101.325
  local liveTps = clamp((ev("throttle", 0) or 0) * 100, 0, 100)
  local liveGauge = base.hasTurbo and max(0, (ev("boost", 0) or 0) * PSI_TO_KPA,
    max(0, ((engine and engine.instantEngineLoad) or 0) - 1) * baro) or 0
  -- Feature states are named before throttle/boost: anti-lag in particular ACTIVATES on lift, so a
  -- bare "lift throttle" banner would tell the driver to do the thing that keeps the switch blocked.
  if model.antilagActive then H.mapBankBlockReason = "anti-lag active - let RPM fall"
  elseif model.launchActive then H.mapBankBlockReason = "launch control active"
  elseif model.flatShiftActive then H.mapBankBlockReason = "flat-shift active"
  elseif model.rollingActive or rollingHold then H.mapBankBlockReason = "rolling anti-lag active"
  elseif model.nitrousActive then H.mapBankBlockReason = "nitrous active"
  elseif model.hardCutActive then H.mapBankBlockReason = "limiter cut active"
  elseif max(model.tps or 0, model.rawTps or 0, liveTps) >= 25 then H.mapBankBlockReason = "lift throttle to apply safely"
  elseif (model.mapKpa or model.rawMapKpa or baro) > baro + 8 or liveGauge > 8 then H.mapBankBlockReason = "waiting for boost to drop"
  end
  return H.mapBankBlockReason ~= nil
end

function H.processPendingMapBank()
  if not H.pendingMapBank or H.mapBankSwitchBlocked() then return end
  H.commitMapBank(H.pendingMapBank)
end

function H.mapBankState()
  local workingPeak = 0
  if maps.boost then
    for r = 1, #maps.boost do
      for c = 1, #maps.boost[r] do workingPeak = max(workingPeak, maps.boost[r][c] or 0) end
    end
  end
  local result = {
    active = H.activeMapBank, pending = H.pendingMapBank or 0,
    pendingMode = H.pendingMapBank and (H.pendingMapReload and "reload" or "select") or "none",
    pendingReload = H.pendingMapBank ~= nil and H.pendingMapReload == true,
    pendingDiscard = H.pendingMapBank ~= nil and H.pendingMapDiscard == true,
    workingDirty = H.workingDirty == true,
    workingBoostTableFactory = boostTableFactory == true,
    workingPeakBoostKpa = workingPeak,
    default = H.defaultMapBank, sequence = H.mapBankSequence, banks = {}
  }
  if H.mapBankMutationResult then
    result.mutationResult = {
      token = H.mapBankMutationResult.token,
      ok = H.mapBankMutationResult.ok,
      error = H.mapBankMutationResult.error,
      data = H.mapBankMutationResult.data,
      operation = H.mapBankMutationResult.operation,
      index = H.mapBankMutationResult.index,
      name = H.mapBankMutationResult.name,
      sequence = H.mapBankMutationResult.sequence,
      calibrationRevision = H.mapBankMutationResult.calibrationRevision
    }
  end
  for index = 1, 4 do
    local bank, peak = H.mapBanks[index], 0
    if bank and bank.maps and bank.maps.boost then
      for r = 1, #bank.maps.boost do
        for c = 1, #bank.maps.boost[r] do peak = max(peak, bank.maps.boost[r][c] or 0) end
      end
    end
    result.banks[index] = {
      index = index, name = bank and bank.name or ("MAP " .. index),
      configured = bank ~= nil, peakBoostKpa = peak,
      boostTableFactory = bank and bank.boostTableFactory == true or false
    }
  end
  return result
end

local function setSetting(key, value)
  if not active then return false, "inactive" end
  if type(key) ~= "string" then return false, "unknown_setting" end
  local before = settings[key]
  if boolSettings[key] then
    local b = H.booleanValue(value)
    if b == nil then return false, "invalid_boolean" end
    settings[key] = b
  else
    value = H.finiteNumber(value)
    if value == nil then return false, "non_finite_value" end
    if key == "revLimit" then
      if value <= 0 then settings.revLimit = nil
      else settings.revLimit = clamp(value, base.idleRPM + 700, base.revLimitCeilingRPM or base.revLimiterRPM) end
    elseif settingRanges[key] then
      if H.enumSettings[key] and value ~= floor(value) then return false, "enum_must_be_integer" end
      settings[key] = clamp(value, settingRanges[key][1], settingRanges[key][2])
    else
      return false, "unknown_setting"
    end
  end
  -- AFR display follows the fuel - but only on a REAL fuel change (V2.9 fix): an unchanged fuelType
  -- echo from the unpause/reconnect re-push must not clobber a player-customized Stoich AFR.
  if key == "fuelType" and settings[key] ~= before then settings.stoichRatio = fuelProps().stoich end
  -- V2.9 fix: the UI re-pushes EVERY setting verbatim on unpause / watchdog reconnect - an unchanged
  -- echo must not wipe learned STFT/LTFT (same rule setMapCell already applies to cell echoes).
  if fuelLearningSettings[key] and settings[key] ~= before then resetFuelLearningState() end
  if settings[key] ~= before and (key == "fxStyle" or key == "smokeDensity" or key == "fuelType" or key == "popsLevel") then
    smokeFx.reset()
  end
  -- dropping the rolling feature (or the master enable) releases the momentary rolling hold, so a
  -- disabled feature can never leave a latched button behind
  if (key == "rollingEnabled" or key == "enabled") and settings[key] == false then R.releaseMomentaryInputs() end
  -- the revision advances only on a REAL change (echo re-pushes keep republishing state exactly as
  -- before via the unconditional stateDirty below, but must not read as new calibration mutations)
  if settings[key] ~= before then
    if H.mapBankSettings[key] then H.markWorkingChanged() end
    H.markCalibrationChanged()
  end
  lastAppliedLimiterRPM = -1
  stateDirty = true
  return true
end

local function setMapCell(mapName, r, c, value)
  if not active then return false, "inactive" end
  local m = maps[mapName]
  r, c, value = H.integerNumber(r), H.integerNumber(c), H.finiteNumber(value)
  local meta = mapMeta[mapName]
  if not (m and meta) then return false, "unknown_map" end
  if not (r and c and m[r] and m[r][c] ~= nil) then return false, "invalid_cell" end
  if value == nil then return false, "non_finite_value" end
  value = clamp(value, meta.min, meta.max)
  local changed = abs(m[r][c] - value) > 0.000001
  -- a boost write that actually CHANGES a cell = the player (or a loaded tune) took over boost.
  -- The >0.5 tolerance keeps UI echoes of unchanged values from stealing factory status.
  if mapName == "boost" and abs(m[r][c] - value) > 0.5 then boostTableFactory = false end
  m[r][c] = value
  if changed and (mapName == "fuel" or mapName == "ve" or mapName == "lambda") then resetFuelLearningState() end
  if changed then
    H.markWorkingChanged()
    H.markCalibrationChanged()
  end   -- unchanged echoes must not read as calibration mutations
  stateDirty = true   -- publish the change even when called directly (not via setMapRow)
  return true
end

-- typed array variant for the remote boundary: the WHOLE row is validated up front (dense, finite,
-- exactly NUM_COLS values) and applied atomically - a remote client gets all-or-nothing, never a
-- half-written row. The in-game CSV path below keeps its historical tolerant per-token behavior.
function R.setMapRowValues(mapName, r, values)
  if not active then return false, "inactive" end
  local m, meta = maps[mapName], mapMeta[mapName]
  if not (m and meta) then return false, "unknown_map" end
  r = H.integerNumber(r)
  if not (r and r >= 1 and r <= NUM_ROWS and m[r]) then return false, "invalid_row" end
  local parsed, err = H.denseFiniteArray(values, NUM_COLS, NUM_COLS)
  if not parsed then return false, err end
  local changed, boostChanged = false, false
  for c = 1, NUM_COLS do
    parsed[c] = clamp(parsed[c], meta.min, meta.max)
    if parsed[c] ~= m[r][c] then
      changed = true
      if mapName == "boost" and abs(parsed[c] - m[r][c]) > 0.5 then boostChanged = true end
    end
  end
  if not changed then return true end
  for c = 1, NUM_COLS do m[r][c] = parsed[c] end
  if mapName == "fuel" or mapName == "ve" or mapName == "lambda" then resetFuelLearningState() end
  if boostChanged then boostTableFactory = false end
  H.markWorkingChanged()
  H.markCalibrationChanged()
  return true
end

local function setMapRow(mapName, r, csv)
  local c = 1
  for token in string.gmatch(csv or "", "[^,]+") do
    setMapCell(mapName, r, c, token)
    c = c + 1
  end
  stateDirty = true
  return true
end

local function resetMaps()
  if not active then return false, "inactive" end
  buildMaps()
  H.resetBoostControlMemory()
  H.markWorkingChanged()
  resetFuelLearningState("Tables reset - relearning")
  H.markCalibrationChanged()
  publishState()
  return true
end

-- Reset ONE table to its factory seed (the toolbar Reset button). resetMaps() above still rebuilds
-- ALL tables (Stock Base preset / a real full reset); this only touches the named map so the toolbar
-- Reset matches every other button in that toolbar - which all act on the CURRENT table only.
-- Community report (Liqurdz, 2026-07): the shared Reset silently wiped the whole tune when players
-- expected it to reset just the table they were editing (went to reset VE, lost the entire calibration).
local function resetMap(name)
  if not active then return false, "inactive" end
  local meta = mapMeta[name]
  if not meta or not maps[name] then return false, "unknown_map" end
  for r = 1, NUM_ROWS do
    for c = 1, NUM_COLS do
      -- REVIEW FIX 2026-07-27: H.mapSeedLoad, not mapAxis[r] - see buildMaps.
      maps[name][r][c] = clamp(defaultCell(name, rpmAxis[c], H.mapSeedLoad(name, r)), meta.min, meta.max)
    end
  end
  if name == "boost" then
    boostTableFactory = true   -- back to the factory seed -> stop chasing/repainting, exactly like buildMaps
    H.resetBoostControlMemory()
  end
  H.markWorkingChanged()
  if name == "fuel" or name == "ve" or name == "lambda" then resetFuelLearningState("Table reset - relearning") end
  H.markCalibrationChanged()
  publishState()
  return true
end

local function clearFaults()
  model.faults = {}
  model.faultCount = 0
  model.limpMode = false
  model.leanFaultTimer = 0
  return true
end

local function resetDamage()
  model.damage = 0
  model.knockCount = 0
  model.knockRetard = 0
  model.blown = false
  model.damagePower = 100
  model.damageReason = "OK"
  clearFaults()
  setEngineDamageFlag(false)
  return true
end

-- presets: mirror the Assetto tool (stock / stage1 / aggressive / bad lean demo)
local function applyPreset(id)
  if not active then return false, "inactive" end
  if id ~= "stock" and id ~= "stage1" and id ~= "aggressive" and id ~= "bad" then return false, "unknown_preset" end
  settings.enabled = true
  settings.globalFuelTrim = 0
  settings.globalIgnitionTrim = 0
  settings.knockControl = true  -- re-enabled by default; the bad-lean demo turns it off
  clearFaults()                 -- loading a calibration clears stale fault codes (ECU memory)
  local function fill(name, fn)
    local meta = mapMeta[name]
    for r = 1, NUM_ROWS do
      for c = 1, NUM_COLS do
        -- REVIEW FIX 2026-07-27: presets branch on kPa thresholds, so an Alpha-N row must be handed
        -- the pressure its throttle angle makes, not the pressure breakpoint. See H.mapSeedLoad.
        maps[name][r][c] = clamp(fn(rpmAxis[c], H.mapSeedLoad(name, r)), meta.min, meta.max)
      end
    end
  end
  if id == "stock" then
    resetDamage()               -- Stock Base = clean factory reset: wipe accumulated damage too
    settings.protection = true
    settings.revLimit = nil
    settings.launchLimit = clamp(5500, 2500, base.revLimiterRPM)
    -- Stock Base == the factory seed (one source of truth: defaultCell), so the reset button matches
    -- the on-spawn tune and the extended high-boost timing/lambda above.
    fill("fuel", function(rpm, m) return defaultCell("fuel", rpm, m) end)
    fill("ignition", function(rpm, m) return defaultCell("ignition", rpm, m) end)
    fill("boost", function(rpm, m) return defaultCell("boost", rpm, m) end)
    fill("lambda", function(rpm, m) return defaultCell("lambda", rpm, m) end)
    fill("ve", function(rpm, m) return defaultCell("ve", rpm, m) end)   -- V2.8: Stock restores the MATCHED VE model too (other presets leave your VE alone - it's your engine model, not a tune)
    fill("wgduty", function(rpm, m) return 50 end)                      -- V2.8: Stock = neutral duty table
  elseif id == "stage1" then
    settings.protection = true
    settings.revLimit = nil
    fill("fuel", function(rpm, m) return 1.0 end)
    fill("ignition", function(rpm, m) if m >= 180 then return -2 elseif m >= 140 and rpm >= 5000 then return -1 else return (m < 100 and 1 or 0) end end)
    fill("boost", function(rpm, m) if not base.hasTurbo then return 100 elseif rpm < 2500 then return 145 elseif rpm < 4000 then return 190 else return 205 end end)
    fill("lambda", function(rpm, m) if m >= 180 then return 0.80 elseif m >= 140 then return 0.84 elseif m >= 100 then return 0.94 else return 1 end end)
  elseif id == "aggressive" then
    settings.protection = true
    fill("fuel", function(rpm, m) return 1.0 end)
    fill("ignition", function(rpm, m) if m >= 180 and rpm >= 5000 then return 2 elseif m >= 140 then return 2 else return 1 end end)
    fill("boost", function(rpm, m) if not base.hasTurbo then return 100 elseif rpm < 3000 then return 165 elseif rpm < 5000 then return 225 else return 240 end end)
    fill("lambda", function(rpm, m) if m >= 180 then return 0.80 elseif m >= 140 then return 0.84 elseif m >= 100 then return 0.92 else return 0.98 end end)
  else -- bad lean demo
    settings.protection = false
    settings.knockControl = false  -- no protective retard: detonation runs unchecked
    fill("fuel", function(rpm, m) if m >= 180 then return 0.92 elseif m >= 140 then return 0.95 else return 0.98 end end)
    fill("ignition", function(rpm, m) return 7 end)  -- over-advanced everywhere (NA cars knock too)
    fill("boost", function(rpm, m) if not base.hasTurbo then return 100 elseif rpm < 3000 then return 175 elseif rpm < 5000 then return 235 else return 260 end end)
    fill("lambda", function(rpm, m) if m >= 180 then return 0.94 elseif m >= 140 then return 0.98 else return 1.02 end end)
  end
  -- Stock Base restores the factory seed = boost control back to stock-wastegate passthrough;
  -- every other preset is a deliberate boost tune the closed loop should chase.
  boostTableFactory = (id == "stock")
  H.resetBoostControlMemory()
  H.markWorkingChanged()
  resetFuelLearningState("Preset loaded - relearning")
  lastAppliedLimiterRPM = -1
  H.markCalibrationChanged()
  publishState()
  return true
end

-- FULL factory reset (the Tune Files button): EVERYTHING back to how a fresh spawn boots - every
-- settings page (sensors, fuel setup, CLL, boost control, idle, anti-lag, VVT, e-throttle, triggers,
-- CAN, aux, limits, cyl trims), all six tables, the table AXES, plus damage and faults. Stock Base
-- stays a TUNE-level reset (tables + core safety, keeps your ECU config); this is the whole-ECU one.
-- Replays init's exact seed order (buildAxes -> buildMaps -> defaultSettings) on a forgotten tune so
-- the result matches a fresh connect. Defined after applyPreset so every local it captures exists.
local function factoryReset()
  if not active then return false, "inactive" end
  frSeq = frSeq + 1                                    -- V2.9: stamp the post-reset broadcasts so the UI accepts THIS reset, not a stale echo
  R.releaseMomentaryInputs()                           -- a reset always drops a latched rolling hold
  -- A full factory reset is a brand-new calibration SESSION, exactly like a fresh spawn: mint a new epoch
  -- so the local UI and the Remote Dash mirror both accept the post-reset broadcast as authoritative
  -- (epochChanged is the V3 analog of the 2.9 frSeq stamp, which also still rides above) instead of
  -- preserving stale pending slider echoes over the reset. beginCalibrationEpoch zeroes revision/
  -- storedRevision and forces the source to "local", so capture and restore the true origin - the
  -- H.markCalibrationChanged() below then re-bumps the revision to 1 (reset reads as dirty / needs
  -- Store) with the correct changeSource, so a REMOTE reset still broadcasts changeSource="remote".
  local resetSource = H.mutationSource
  H.beginCalibrationEpoch()
  H.mutationSource = resetSource
  settings = {}                                        -- no raised rev limit: buildAxes sees the stock range, like first init
  for name in pairs(mapMeta) do maps[name] = {} end    -- forget the old tune so the MAP axis fits factory boost, not the old targets
  -- V3.0.7: LITERAL, not derived - and deliberately so. `settings` was wiped two lines up, so there
  -- is no player intent left to honour: a full Factory Reset returns the Standard 6x7 grid the same
  -- way it returns stock tables. (Reset AXES is the other one - that keeps the chosen resolution.)
  -- The restore snapshot goes with it; it described a calibration that no longer exists.
  NUM_ROWS, NUM_COLS = 6, 7
  H.gridRestore = nil
  buildAxes()
  buildMaps()
  settings = defaultSettings()
  H.resetMapBanksFromCurrent()                            -- MAP 1 is the fresh calibration; MAP 2-4 are intentionally empty
  H.resetBoostControlMemory()
  smokeFx.reset()
  resetDamage()                                        -- wipes accumulated damage + clears faults/limp
  resetFuelLearningState("Factory reset")
  lastAppliedLimiterRPM = -1
  H.markCalibrationChanged()
  publishState()
  return true
end

-- axis editing
-- Reproject every table onto a new grid. Axis changes in a real ECU preserve
-- the calibrated surface; merely relabelling row/column indexes silently moves
-- the tune to different operating points.
function M.refitMaps(newRpmAxis, newMapAxis, requestedAlphaAxis)
  local oldRpmAxis, oldMapAxis, oldAlphaNAxis = rpmAxis, mapAxis, H.alphaNAxis
  local newAlphaNAxis = requestedAlphaAxis or H.resampleAxisByIndex(oldAlphaNAxis, #newMapAxis)
  -- Axes are ECU-global. Refit every configured bank against the same old/new axes in this one call;
  -- refit the independent live working copy as well, but never replace it from the active saved bank.
  maps = H.refitMapSet(maps, oldRpmAxis, oldMapAxis, newRpmAxis, newMapAxis, oldAlphaNAxis, newAlphaNAxis)
  for index = 1, 4 do
    local bank = H.mapBanks[index]
    if bank then bank.maps = H.refitMapSet(bank.maps, oldRpmAxis, oldMapAxis, newRpmAxis, newMapAxis, oldAlphaNAxis, newAlphaNAxis) end
  end
  -- V3.0.7 ORDERING FIX - do not move these back below the mutation block. H.copyMapSet reads the
  -- GLOBAL NUM_ROWS/NUM_COLS, and by this point every map set above has already been rebuilt to the
  -- NEW shape. While every refit in the mod was count-preserving this was invisible; with a
  -- selectable grid it is not. Growing 6->16 would have silently truncated the pending bank body to
  -- 6x7, and shrinking 16->6 would have indexed past the end of a 6-row source and thrown inside
  -- refitMaps - taking the whole calibration down with it.
  rpmAxis, mapAxis = newRpmAxis, newMapAxis
  H.alphaNAxis = newAlphaNAxis
  NUM_ROWS, NUM_COLS = #mapAxis, #rpmAxis
  -- A Save/Rename confirmation may still be waiting for the trusted UI while an automatic or
  -- remote axis refit runs. Keep its authoritative body on the same grid as the controller-owned
  -- bank; otherwise the delayed result could persist old-dimension tables into the new package.
  local mutation = H.mapBankMutationResult
  local mutationBank = mutation and mutation.ok and mutation.data and H.mapBanks[mutation.index] or nil
  if mutationBank then
    mutation.data = {
      maps = H.copyMapSet(mutationBank.maps), settings = H.copySwitchableSettings(mutationBank.settings),
      boostTableFactory = mutationBank.boostTableFactory == true
    }
  end
  -- (axes + counts were adopted above, before the mutation copy - see the ordering note)
  H.mapBankSequence = H.mapBankSequence + 1
  stateDirty = true
end

function H.resampleAxisByIndex(source, targetCount)
  -- V3.0.7: the fallback curve reads H.ALPHA_FACTORY rather than a second private copy, so this
  -- refit path and H.factoryAlphaAxis can never disagree about what "factory Alpha-N" means.
  local src = type(source) == "table" and #source >= 2 and source or H.ALPHA_FACTORY
  local out = {}
  for i = 1, targetCount do
    local pos = 1 + (i - 1) * (#src - 1) / max(1, targetCount - 1)
    local lo = floor(pos)
    local hi = min(#src, lo + 1)
    local f = pos - lo
    out[i] = src[lo] + (src[hi] - src[lo]) * f
  end
  return out
end

function H.axisBracket(axis, value)
  if #axis < 2 then return 1, 1, 0 end
  local i = 1
  while i < #axis - 1 and value > axis[i + 1] do i = i + 1 end
  local j = min(i + 1, #axis)
  local den = max(0.000001, (axis[j] or axis[i]) - axis[i])
  return i, j, clamp((value - axis[i]) / den, 0, 1)
end

function H.refitMapSet(source, oldRpmAxis, oldMapAxis, newRpmAxis, newMapAxis, oldAlphaNAxis, newAlphaNAxis)
  local rebuilt = {}
  for name, meta in pairs(mapMeta) do
    local src = source[name] or {}
    local sourceLoadAxis = H.mapUsesAlphaN(name) and oldAlphaNAxis or oldMapAxis
    local targetLoadAxis = H.mapUsesAlphaN(name) and newAlphaNAxis or newMapAxis
    rebuilt[name] = {}
    for r = 1, #targetLoadAxis do
      local r0, r1, rf = H.axisBracket(sourceLoadAxis, targetLoadAxis[r])
      rebuilt[name][r] = {}
      for c = 1, #newRpmAxis do
        local c0, c1, cf = H.axisBracket(oldRpmAxis, newRpmAxis[c])
        local v00 = tonumber(src[r0] and src[r0][c0]) or meta.default
        local v01 = tonumber(src[r0] and src[r0][c1]) or v00
        local v10 = tonumber(src[r1] and src[r1][c0]) or v00
        local v11 = tonumber(src[r1] and src[r1][c1]) or v10
        local top = v00 + (v01 - v00) * cf
        local bot = v10 + (v11 - v10) * cf
        rebuilt[name][r][c] = clamp(top + (bot - top) * rf, meta.min, meta.max)
      end
    end
  end
  return rebuilt
end

-- typed array variant for the remote boundary; the in-game CSV setAxis below parses then delegates
-- here, so both paths share one validation + refit body (bilinear refitMaps, NOT a pad/trim).
function R.setAxisValues(which, values)
  if not active then return false, "inactive" end
  local target = which == "rpm" and rpmAxis or (which == "map" and mapAxis or (which == "alpha" and H.alphaNAxis or nil))
  if not target then return false, "unknown_axis" end
  local parsed, err = H.denseFiniteArray(values, which == "alpha" and NUM_ROWS or H.axisLimits.min,
                                                 which == "alpha" and NUM_ROWS or H.axisLimits.max)
  if not parsed then return false, err end
  for i = 1, #parsed do
    -- V3.0: MAP top follows the shared meta (was a hardcoded 600). This is the ONE inbound axis
    -- gate (in-game Axis Setup, browser Studio and every tune-load replay funnel through it) -
    -- auto-fit legitimately grows axes past 600 for 100+ psi targets, so rejecting them here made
    -- saved big-boost tunes reload onto the WRONG breakpoints (review-fleet critical).
    local lo = which == "rpm" and 100 or (which == "alpha" and 0 or 5)
    local hi = which == "rpm" and 30000 or (which == "alpha" and 100 or mapMeta.boost.max)
    if parsed[i] < lo or parsed[i] > hi then return false, "axis_value_out_of_range" end
    if i > 1 and parsed[i] <= parsed[i - 1] then return false, "axis_not_strictly_increasing" end
  end
  -- Tune loads re-apply their saved axes. Only a genuinely identical numeric echo (within
  -- floating-point serialization noise) may skip the refit and learning reset.
  local same = #parsed == #target
  if same then
    for i = 1, #parsed do
      if abs(parsed[i] - target[i]) > 0.000001 then same = false break end
    end
  end
  if same then return true end
  local nextRpm, nextMap = {}, {}
  for i = 1, #rpmAxis do nextRpm[i] = rpmAxis[i] end
  for i = 1, #mapAxis do nextMap[i] = mapAxis[i] end
  if which == "rpm" then nextRpm = parsed elseif which == "map" then nextMap = parsed end
  -- REVIEW FIX 2026-07-29. The alpha branch used to just overwrite H.alphaNAxis and return, so every
  -- Fuel/Ignition/Lambda/VE cell kept its value while the ROW MEANING moved underneath it - the cell
  -- tuned at 12% TPS silently became the 30% TPS cell. That is exactly what the comment above
  -- refitMaps forbids ("Axis changes in a real ECU preserve the calibrated surface; merely
  -- relabelling row/column indexes silently moves the tune to different operating points"), and the
  -- RPM and MAP branches have always honoured it. refitMaps already accepts requestedAlphaAxis and
  -- H.refitMapSet already reprojects the alpha-indexed tables plus all four M1-M4 banks, so routing
  -- the alpha edit through the same call was all that was missing.
  M.refitMaps(nextRpm, nextMap, which == "alpha" and parsed or nil)
  resetFuelLearningState(which == "alpha" and "Alpha-N axis changed - relearning" or "Axes changed - relearning")
  H.markCalibrationChanged()
  publishState()
  return true
end

local function setAxis(which, csv)
  local parsed, err = H.parseNumberCsv(csv, which == "alpha" and NUM_ROWS or H.axisLimits.min,
                                            which == "alpha" and NUM_ROWS or H.axisLimits.max)
  if not parsed then return false, err end
  return R.setAxisValues(which, parsed)
end

-- Restore the factory breakpoints while preserving the tune by bilinearly
-- resampling every table onto the new grid.
local function resetAxes()
  if not active then return false, "inactive" end
  local oldRpmAxis, oldMapAxis, oldAlphaNAxis = rpmAxis, mapAxis, H.alphaNAxis
  local savedAutoFit = settings.autoFitAxes
  -- V3.0.7: "Reset axes to factory" restores the factory BREAKPOINTS at the resolution the player
  -- chose - it must not silently drag a Fine grid back to 6x7. buildAxes now reads NUM_ROWS/NUM_COLS
  -- as INPUTS, so leaving them alone is what preserves the resolution. (Full Factory Reset still
  -- returns to 6x7; that one is a deliberate wipe and sets the counts itself.)
  settings.autoFitAxes = false -- factory reset ignores the tune's raised target; hardware ceiling still applies
  buildAxes()
  settings.autoFitAxes = savedAutoFit
  local factoryRpmAxis, factoryMapAxis, factoryAlphaNAxis = rpmAxis, mapAxis, H.alphaNAxis
  rpmAxis, mapAxis, H.alphaNAxis = oldRpmAxis, oldMapAxis, oldAlphaNAxis
  M.refitMaps(factoryRpmAxis, factoryMapAxis, factoryAlphaNAxis)
  resetFuelLearningState("Axes reset - relearning")
  H.markCalibrationChanged()
  publishState()
  return true
end

-- ---------------------------------------------------------------------------
-- V3.0.7 selectable calibration grid resolution
--
-- Going FINER is a bilinear resample onto denser breakpoints. Unlike a midpoint refinement, an
-- arbitrary count change (6 -> 16) does not place the new breakpoints on top of the old ones, so
-- the resample is lossy in both directions and repeated A/B toggling would compound the error.
-- Rather than forbid the useful shape, we make the trip home exact: the ENTIRE calibration (working
-- copy plus all four M1-M4 bank bodies plus the axes) is snapshotted before going finer, and
-- returning to that shape RESTORES the snapshot instead of decimating.
--
-- The documented trade is therefore: edits made while on the fine grid are lost when you go back.
-- That is what a player expects from a "revert to Standard" button, and it is honest - the
-- alternative is silently handing back a tune that is no longer the one they authored.
-- ---------------------------------------------------------------------------

-- REVIEW FIX 2026-08-03 (blocker). The snapshot is only meaningful while the calibration it was
-- taken from is still the one loaded, under the SAME bank association. It captures `maps` and the
-- four bank bodies, but not H.activeMapBank / H.workingDirty - and every other writer of `maps` sets
-- those three together (H.commitMapBank :3927, H.saveCurrentToMapBank :4108,
-- H.configureMapBankPackage :4043, H.restoreMapBankSession :4160). Restoring the bodies alone leaves
-- the controller claiming "MAP n is active and clean" while `maps` holds a calibration from a
-- different bank, and the UI - which owns persistence - then writes that body over the saved slot.
--
-- Rather than capture and reinstate a fourth and fifth piece of coupled state (and have to keep them
-- in sync with every future bank writer), DROP the snapshot whenever the association or the working
-- copy is replaced. "Standard" then falls through to an ordinary resample, which is honest: once you
-- have saved to a bank or switched banks, the tune you left behind is no longer the one you would be
-- coming back to. Cell edits deliberately do NOT drop it - those are the documented trade.
function H.dropGridRestore()
  H.gridRestore = nil
end

-- Snapshot taken BEFORE the grid changes: H.copyMapSet reads the live NUM_ROWS/NUM_COLS, so this
-- must run while those still describe the source grid.
function H.captureGridRestore()
  -- Bank bodies are intentionally not captured: the restore path resamples them through refitMaps
  -- so the controller and the UI (which owns and persists them) stay in agreement. See the note in
  -- R.setGridResolution's restore branch.
  local rpmCopy, mapCopy, alphaCopy = {}, {}, {}
  for i = 1, #rpmAxis do rpmCopy[i] = rpmAxis[i] end
  for i = 1, #mapAxis do mapCopy[i] = mapAxis[i] end
  for i = 1, #(H.alphaNAxis or {}) do alphaCopy[i] = H.alphaNAxis[i] end
  H.gridRestore = {
    rows = NUM_ROWS, cols = NUM_COLS,
    rpmAxis = rpmCopy, mapAxis = mapCopy, alphaNAxis = alphaCopy,
    maps = H.copyMapSet(maps)
  }
end

-- Densify/coarsen one axis by index interpolation, then re-round to the axis's natural step and
-- force strict monotonicity (rounding a dense axis can otherwise collide, and R.setAxisValues
-- rejects a non-increasing axis outright).
-- REVIEW FIX 2026-08-03. Takes the SAME lo/hi bounds R.setAxisValues enforces, because
-- R.setGridResolution writes axes straight into M.refitMaps and is otherwise the one axis writer
-- that never passes the range gate. Two ways it could produce a rejected axis: a source already
-- sitting on the ceiling (a 1000 kPa boost axis) that ensureIncreasing then bumps to 1001, and a
-- narrow custom axis where rounding to `step` collapses neighbours and the +1 bumps march past the
-- top. Clamping into the window before AND after the monotonic pass makes both impossible, and
-- squeezing from the top when the window is full keeps the axis strictly increasing without
-- exceeding hi. An axis this function emits must always satisfy the gate, or the player gets a grid
-- the ECU accepts in memory and every save path then refuses.
function H.rescaleAxis(source, targetCount, step, lo, hi)
  local out = H.resampleAxisByIndex(source, targetCount)
  -- REVIEW FIX 2026-08-03: only round when the new mean spacing can actually carry the step. On a
  -- narrow custom axis (say 20..60 across 16 rows, 2.7 kPa apart) rounding to 5 collapses neighbours
  -- into the same bucket and the monotonic repair below then emits breakpoints 1 unit apart - a grid
  -- chosen for resolution that is half artefact. Unrounded interpolated values are perfectly valid
  -- axis values; the Alpha-N axis has always worked that way. ensureIncreasing stays as the belt,
  -- but it should now be the impossible case rather than the normal outcome.
  local span = (source[#source] or 0) - (source[1] or 0)
  if step and step > 0 and targetCount > 1 and (span / (targetCount - 1)) < (2 * step) then step = nil end
  for i = 1, #out do
    if step and step > 0 then out[i] = floor(out[i] / step + 0.5) * step end
    if lo then out[i] = max(lo, out[i]) end
    if hi then out[i] = min(hi, out[i]) end
  end
  H.ensureIncreasing(out)
  -- ensureIncreasing only ever pushes UP, so a run that hit the ceiling can overshoot it. Walk back
  -- down from the top, pulling each cell strictly below its successor.
  if hi then
    if out[#out] > hi then out[#out] = hi end
    for i = #out - 1, 1, -1 do
      if out[i] >= out[i + 1] then out[i] = out[i + 1] - 1 end
    end
  end
  return out
end

-- rows/cols are the REQUESTED grid. Returns false plus a reason when nothing changed or the request
-- is out of bounds, so the UI can toast honestly instead of implying work happened.
function R.setGridResolution(rows, cols)
  if not active then return false, "inactive" end
  rows, cols = floor(tonumber(rows) or 0), floor(tonumber(cols) or 0)
  local lo, hi = H.axisLimits.min, H.axisLimits.max
  if rows < lo or rows > hi or cols < lo or cols > hi then return false, "grid_out_of_range" end
  if rows == NUM_ROWS and cols == NUM_COLS then return false, "grid_unchanged" end

  local restore = H.gridRestore
  if restore and restore.rows == rows and restore.cols == cols then
    -- Exact return trip for the WORKING COPY only.
    --
    -- REVIEW FIX 2026-08-03: the bank bodies are deliberately NOT restored from the snapshot, even
    -- though it holds them. Bank bodies are never broadcast - H.mapBankState publishes only
    -- index/name/configured/peakBoostKpa/boostTableFactory, and app.js:184 records that the full
    -- slot bodies live UI-side. So the UI reconstructs its own copies by bilinearly resampling on
    -- the axis-changed path. If the controller table-copied an exact snapshot while the UI
    -- resampled, the two sides would hold different numbers for the same four slots - and the UI's
    -- is the one that persists. Letting M.refitMaps resample the banks means both sides run the
    -- same algorithm on the same inputs and agree.
    --
    -- The working copy IS broadcast cell-by-cell, so restoring it exactly is safe and is the thing
    -- the player actually asked for. Saved M-slots were never the promise.
    M.refitMaps(restore.rpmAxis, restore.mapAxis, restore.alphaNAxis)   -- moves the grid + banks
    maps = H.copyMapSet(restore.maps)
    H.gridRestore = nil
    H.mapBankSequence = H.mapBankSequence + 1
    -- REVIEW FIX 2026-08-03: the Boost Target surface is wholly replaced here in one frame, so the
    -- closed loop's integral, wastegate offset and taper filter are all wound up against a table
    -- that no longer exists. Every other whole-calibration swap resets it (commitMapBank via
    -- resetMapBankTransients, applyPreset, factoryReset, resetMaps); the RESAMPLE branch below
    -- deliberately does not, because there the surface shape is preserved - same as the setAxis family.
    H.resetBoostControlMemory()
    resetFuelLearningState("Grid resolution restored - relearning")
    H.markCalibrationChanged()
    publishState()
    return true, "restored"
  end

  -- Going to a shape we have no snapshot for: keep the CURRENT axis span and layout (including any
  -- auto-fit growth toward the player's boost target) and simply put more/fewer breakpoints across
  -- it. Regenerating factory axes here would silently throw away manual breakpoint work.
  if rows > NUM_ROWS or cols > NUM_COLS then H.captureGridRestore() end
  -- Bounds mirror R.setAxisValues exactly (rpm 100..30000, map 5..mapMeta.boost.max, alpha 0..100).
  local nextRpm = H.rescaleAxis(rpmAxis, cols, 100, 100, 30000)
  local nextMap = H.rescaleAxis(mapAxis, rows, 5, 5, mapMeta.boost.max)
  local nextAlpha = H.rescaleAxis(H.alphaNAxis, rows, nil, 0, 100)
  M.refitMaps(nextRpm, nextMap, nextAlpha)
  resetFuelLearningState("Grid resolution changed - relearning")
  H.markCalibrationChanged()
  publishState()
  return true, "resampled"
end

-- V3.0 (Liqurdz's "sometimes the axis auto-fits, sometimes I set it manually"): keep the table
-- axes fitted to the player's INTENT - the top BOOST TARGET and the (raised) rev limit. GROW-ONLY
-- (never shrinks or re-spreads a manual layout), DEBOUNCED ~0.6s (so a slider drag / a run of cell
-- edits fits once when you settle, not per keystroke), and COUNT-PRESERVING: the row/column count
-- never changes here, so tuned cell values are never moved or lost.
-- Toggle it off with the 'autoFitAxes' setting for fully-manual control.
--
-- V2.9 fixes (from the pre-release review):
--  * growing only triggers when the player's own tune demands it (no 220 kPa floor - the floor
--    rewrote ANY manual axis topping under ~216 kPa 0.6s after it was applied)
--  * a factory-shaped axis re-spreads its rows evenly like buildAxes (best resolution); a CUSTOM
--    Axis Setup layout keeps every breakpoint the player placed and only the TOP one is raised
--  * the maps are pad/trimmed to the axis counts exactly like setAxis - the old code forced the
--    MAP axis back to 6 rows without resizing the maps, and lookupMap then indexed a nil row every
--    frame on any 4/5-row custom grid (controller death until reload)
local axisFitTimer = 0
-- REVIEW FIX 2026-08-03. This used to hardcode the pre-3.0.7 factory shape: mapAxis[1..2] == {20,60}
-- followed by (n-2) boost rows spread over (n-3) intervals. H.factoryMapAxis now scales the vacuum
-- share with the row count, so a 16-row factory axis reads 20,30,40,50,60,100,... - mapAxis[2] is 30,
-- and the old test returned false on EVERY fine grid. Auto-fit then fell through to its custom-layout
-- branch and moved only the last breakpoint, defeating the whole reason to pick a finer grid the
-- first time the player raised their boost target. Comparing against the generator's own output means
-- the test and the axis can never disagree again.
--
-- Returns (isCanonical, isLegacy). The legacy 40/80 family predates the generator and is checked with
-- the ORIGINAL formula on purpose: a loaded pre-2.8 axis must keep auto-fitting exactly as it does
-- today rather than being silently converted to the modern 20/60 layout.
local function mapAxisIsCanonical()
  local n = #mapAxis
  if n < 4 then return false, false end
  local want = H.factoryMapAxis(n, mapAxis[n], base.hasTurbo)
  local modern = true
  for i = 1, n do
    if abs((mapAxis[i] or 0) - (want[i] or -1e9)) > 0.5 then modern = false end
  end
  if modern then return true, false end
  if abs((mapAxis[1] or 0) - 40) <= 0.5 and abs((mapAxis[2] or 0) - 80) <= 0.5 then
    local top = mapAxis[n]
    for i = 0, n - 3 do
      local w = floor((100 + (top - 100) * i / (n - 3)) / 5 + 0.5) * 5
      if abs(mapAxis[i + 3] - w) > 0.5 then return false, false end
    end
    return true, true
  end
  return false, false
end
local function rpmAxisIsCanonical()
  local n = #rpmAxis
  if n < 4 then return false end
  -- anchor: buildAxes always starts the factory axis at the idle-derived low end. Without this
  -- check any evenly spaced CUSTOM axis (e.g. 2000..7000 for high-rpm resolution) would be
  -- misjudged as factory and re-spread from idle, moving every breakpoint the player placed.
  local lo = max(800, base.idleRPM or 800)
  if abs(rpmAxis[1] - floor(lo / 100 + 0.5) * 100) > 0.5 then return false end
  local hi = rpmAxis[n]
  for c = 1, n do
    local want = floor((lo + (hi - lo) * (c - 1) / (n - 1)) / 100 + 0.5) * 100
    if abs(rpmAxis[c] - want) > 0.5 then return false end
  end
  return true
end
local function maybeAutoFitAxes(dt)
  if settings.autoFitAxes == false then axisFitTimer = 0; return end
  local growMap, wantMapTop = false, 0
  if base.hasTurbo and maps.boost then
    for r = 1, #maps.boost do local row = maps.boost[r]; for c = 1, #row do if row[c] > wantMapTop then wantMapTop = row[c] end end end
    -- A factory turbo tune regulates from the physical spring baseline even when its untouched table
    -- is lower. Modern shaping deliberately prevents learnBoost from repainting that table, so fit
    -- from the real commanded baseline before adding positive gear/speed trims.
    if boostTableFactory and base.hasTurbocharger and floor(settings.boostMode or 2) ~= 1 and base.wgSpringTargetPSI then
      wantMapTop = max(wantMapTop, (model.baroKpa or 101.325) + base.wgSpringTargetPSI * PSI_TO_KPA)
    end
    -- Positive closed-loop trims can command above the raw Boost Target table. Grow the shared MAP
    -- axis to the worst possible shaped target so fuel/ignition/lambda do not silently extrapolate.
    local shapeMode = floor(settings.boostShapeMode or 1)
    if floor(settings.boostMode or 2) ~= 1 and shapeMode ~= 1 then
      local gearExtra, speedExtra = 0, 0
      if shapeMode == 2 or shapeMode == 4 then
        for gear = 1, 8 do gearExtra = max(gearExtra, settings["boostGear" .. gear .. "TargetTrimKpa"] or 0) end
      end
      if shapeMode == 3 or shapeMode == 4 then
        for point = 1, #H.boostSpeedAxisKmh do speedExtra = max(speedExtra, settings["boostSpeed" .. point .. "TargetTrimKpa"] or 0) end
      end
      wantMapTop = wantMapTop + gearExtra + speedExtra
    end
    wantMapTop = clamp(wantMapTop, 0, mapMeta.boost.max)   -- V3.0: auto-fit axis top follows the shared meta (was 600) so the tables can index a 130 psi target
    if #mapAxis >= 4 and wantMapTop > (mapAxis[#mapAxis] or 0) + 4 then growMap = true end
  end
  local wantRpmHi = max(base.revLimiterRPM or 7000, settings.revLimit or 0)
  wantRpmHi = min(wantRpmHi, base.revLimitCeilingRPM or wantRpmHi)
  local growRpm = #rpmAxis >= 4 and wantRpmHi > (rpmAxis[#rpmAxis] or 0) + 50
  if not (growMap or growRpm) then axisFitTimer = 0; return end
  axisFitTimer = axisFitTimer + (dt or 0)
  if axisFitTimer < 0.6 then return end
  axisFitTimer = 0
  local nextRpm, nextMap = {}, {}
  for i = 1, #rpmAxis do nextRpm[i] = rpmAxis[i] end
  for i = 1, #mapAxis do nextMap[i] = mapAxis[i] end
  if growMap then
    local n = #mapAxis
    local canon, isLegacy = mapAxisIsCanonical()
    if canon and not isLegacy then
      -- V3.0.7: re-spread through the generator, so the grown axis is the factory layout for THIS
      -- row count instead of the hardcoded 2-vacuum-row shape.
      nextMap = H.factoryMapAxis(n, floor(wantMapTop / 5 + 0.5) * 5, base.hasTurbo)
    elseif canon then
      nextMap = { mapAxis[1], mapAxis[2] } -- loaded legacy 40/80 family: original formula, unchanged
      for i = 0, n - 3 do nextMap[#nextMap + 1] = floor((100 + (wantMapTop - 100) * i / (n - 3)) / 5 + 0.5) * 5 end
    else
      nextMap[n] = floor(wantMapTop / 5 + 0.5) * 5
    end
  end
  if growRpm then
    local n = #rpmAxis
    if rpmAxisIsCanonical() then
      local lo = max(800, base.idleRPM or 800)
      local hi = max(lo + 1000, wantRpmHi)
      for c = 1, n do nextRpm[c] = floor((lo + (hi - lo) * (c - 1) / (n - 1)) / 100 + 0.5) * 100 end
    else
      nextRpm[n] = floor(wantRpmHi / 100 + 0.5) * 100
    end
  end
  M.refitMaps(nextRpm, nextMap)
  resetFuelLearningState("Axes auto-fitted - relearning")
  H.markCalibrationChanged()
  publishState()
end

local function requestState()
  publishState()
  -- V2.9.x dyno replay: the done+rows envelope used to broadcast exactly ONCE, so any UI or
  -- browser that linked after the pull (hub-dyno rig seat swap, dash opened late, WS reconnect)
  -- could never paint the run - the field "dyno shows no graph" reports. While the completed
  -- pull is still on the dyno (state=done keeps dyno.rows intact until the next arm), re-send
  -- it with the state poll; runSeq lets every listener dedupe. Throttled because the GE relay
  -- polls requestState every 2s while a browser is connected.
  if dyno.state == "done" and dyno.rows and #dyno.rows > 1 then
    if not dyno.replayAt or (simTime - dyno.replayAt) >= 10 then
      dyno.replayAt = simTime
      sendDyno(true)
    end
  end
  return true
end

local function armDyno()
  if not active then return false, "inactive" end
  dyno.state = "armed"; dyno.rows = {}; sendDyno(false)
  return true
end
local function setDynoAuto(on, gear, rpm)
  if not active then return false, "inactive" end
  if on == nil then on = false end
  local enabled = H.booleanValue(on)
  gear = gear == nil and 0 or H.integerNumber(gear)
  rpm = rpm == nil and 0 or H.finiteNumber(rpm)
  if enabled == nil then return false, "invalid_enabled" end
  if gear == nil or gear < 0 or gear > 32 then return false, "invalid_gear" end
  if rpm == nil then return false, "invalid_start_rpm" end
  dyno.auto = enabled
  dyno.startGear = gear
  dyno.startRpm = clamp(floor(rpm), 0, 11000)
  if dyno.auto and (dyno.state == "idle" or dyno.state == "done") then dyno.state = "armed"; dyno.rows = {} end
  if (not dyno.auto) and dyno.state == "armed" then dyno.state = "idle" end
  sendDyno(false)
  return true
end
local function cancelDyno()
  if not active then return false, "inactive" end
  -- Cancel is a hard stop, including Auto Pull (accepted V3 change): leaving dyno.auto armed lets
  -- updateDyno immediately re-arm after a remote lease/dead-man release.
  dyno.auto = false; dyno.startGear = 0; dyno.startRpm = 0
  dyno.state = "idle"; sendDyno(false)
  return true
end

-- Connect / Disconnect: gate whether the tuner actually controls the engine.
-- Disconnected = passive monitor (live data, car untouched); the edge in
-- updateGFX handles applying / releasing the outputs.
local function setControl(on)
  if not active then return false, "inactive" end
  if on == nil then on = 0 end   -- legacy direct call: omitted argument meant Disconnect
  local want = H.booleanValue(on)
  if want == nil then return false, "invalid_enabled" end
  if want then
    if not controlling and electrics.values.oet_active == 1 and log then
      if syslog then syslog("W", "oeProTune", "Connected while the OrlandoEdits Tuner is also live - two tuners will fight over the engine. Disable one.") end
    end
    controlling = true
    if settings.enabled ~= true then
      settings.enabled = true   -- connecting always re-arms the master ECU enable
      H.markCalibrationChanged()
    end
  else
    controlling = false
    R.releaseMomentaryInputs()   -- disconnecting always drops a latched rolling hold
  end
  stateDirty = true
  publishTelemetry()
  return true
end

-- ---------------------------------------------------------------------------
-- lifecycle
-- ---------------------------------------------------------------------------

-- ---------------------------------------------------------------------------
-- OE ProTune EFFECTS PACK: custom flame + smoke particle datablocks shipped in
-- art/datablocks/oepParticles*.datablocks.json (BNGP_9601-9631 Flame V5.4,
-- BNGP_9616-9618 legacy Show / Coal smoke), loaded by
-- scripts/oeProTune/modScript.lua at mod mount (the game's own art scan runs at BOOT before mods
-- mount, so a modScript is the only reliable loader - beamng.log gets a clear line either way).
-- NO availability gate (2026-07-04, Orlando's call): the old design gated fx() on a GE round-trip
-- confirming the datablocks, which added races, startup windows and a whole day of ghost-chasing -
-- and on heavily modded installs (200+ mods) could keep the pack on stock particles indefinitely.
-- The style selector alone decides now. Worst case on a broken half-install: the 96xx ids emit
-- nothing visible and the modScript error line in beamng.log says exactly why.
smokeFx.flameMap = {
  [61] = 9602,  -- generated curled orange micro-wisp
  [62] = 9601,  -- generated compact white-hot kernel
  [63] = 9603,  -- generated fragmented red/orange cooling lobe
  [64] = 9604,  -- generated compact blue ignition kernel
  [65] = 9605   -- restrained stock radial amber bloom
}
-- Moving flame particles receive the exhaust node's velocity when spawned, but
-- they are world-space particles after that instant.  The normal OE assets are
-- intentionally readable at a standstill; once the car is genuinely moving
-- (5 km/h) use the 15-24 ms variants so even parking-lot driving cannot carry
-- an old fragment away. The continuously refreshed micro-stream still reads as attached.
smokeFx.fastFlameMap = {
  [61] = 9610,  -- fast body
  [62] = 9609,  -- fast core
  [63] = 9611,  -- fast tongue
  [64] = 9612,  -- fast blue
  [65] = 9613   -- fast glow
}
-- SIZE TIERS. A bigger overrun event burns a physically bigger parcel of unburnt charge, so the
-- flame should be FATTER, not just longer. BeamNG gives no per-emit size parameter to do that with:
-- addParticleByNodesRelative(nodeA, nodeB, velocity, type, spread, count) - verified against the
-- game's own calls, where surfaceMaskWheels.lua:320 names arg 6 `grassParticleCount` and
-- fire.lua:191 passes 100. OE always passes (0, 1). Sprite diameter therefore comes ENTIRELY from
-- the ParticleData `sizes` array, so a different size means a different datablock. That is exactly
-- how the base game does it - fire.lua picks between fireParticleSmall / Medium / Large by
-- intensity rather than scaling one block.
--
-- ONLY `sizes` differs between the tiers (x0.70 small, x1.35 large). Lifetimes are copied
-- byte-identical from the stock tier on purpose: OEP_flame_tongue already sits at 66 + 11 = 77 ms,
-- exactly the ceiling set by smokeFx.nativeSuppressWindow below, and the note at the vk block warns
-- that the suppression window, the jet-reach decay and the launch cadence are all sized in
-- milliseconds and all silently broke the last time lifetimes moved. Size alone touches none of it.
--
-- Tiers cover the DEFAULT body and the TONGUE. The tongue is never variant-swapped, so tiering it
-- gives size variation on every flame event whichever variant rolled. Variants 1-3 keep their
-- single size; tiering those too would be 3 x 3 x 2 = 18 more datablocks for a 35% case.
-- Indexed [tier][1 = slow, 2 = fast]; tier 2 is the stock pair, so an untiered install is unchanged.
smokeFx.bodyTier = {
  { 9624, 9625 },   -- small
  { 9602, 9610 },   -- stock
  { 9626, 9627 }    -- large
}
smokeFx.tongueTier = {
  { 9628, 9629 },   -- small
  { 9603, 9611 },   -- stock
  { 9630, 9631 }    -- large
}
-- popFlameScale thresholds, picked by sweeping the actual roll distribution rather than by eye.
-- Two constraints: (a) tier 2 must stay MODAL at every overrun strength, or adding tiers would
-- systematically SHRINK the flames of anyone running a mild tune - a regression dressed up as a
-- feature; (b) tier 3 must stay unreachable at strength 0, so the big flames are still earned by
-- tuning, matching the rule the pop-size roll already follows.
-- At 0.48 / 0.95 the overrun runs small/stock/large 45/55/0 at strength 0, 28/59/13 at 0.4,
-- and 16/44/40 at 1.3; the hard-cut limiter sits at 0/60/40 independent of tune.
-- Note movingRealistic caps sz at 1.0, so tier 3 is unreachable once you are moving in Realistic -
-- deliberate: moving flames stay small so nothing large is left hanging in the air behind the car.
function smokeFx.sizeTier(sz)
  if sz < 0.48 then return 1 elseif sz > 0.95 then return 3 else return 2 end
end
smokeFx.realSmokeMap = {
  [44] = 9606, [45] = 9606,   -- short light-gray haze
  [42] = 9607, [43] = 9607    -- realistic coal: diesel / true flooding only
}
smokeFx.showSmokeMap = {
  [44] = 9616, [45] = 9616,   -- legacy large gray burst
  [42] = 9617, [43] = 9617    -- legacy dense lingering coal
}
function smokeFx.style() return clamp(floor(settings.fxStyle or 2), 1, 3) end
local function oepFxActive() return smokeFx.style() >= 2 end
function smokeFx.isShow() return smokeFx.style() == 3 end
local function fx(id, fastFlame)
  if oepFxActive() then
    local smokeMap = smokeFx.isShow() and smokeFx.showSmokeMap or smokeFx.realSmokeMap
    local flameMap = fastFlame and smokeFx.fastFlameMap or smokeFx.flameMap
    return flameMap[id] or smokeMap[id] or id
  end
  return id
end

-- Realistic is deliberately conservative. Show / Coal uses the legacy large
-- assets and a higher, but still bounded, vehicle-wide rate.
smokeFx.realProfile = { gasRate = 1.6, dieselRate = 7.0, eventRate = 0.75, bang = 0.22, maxRate = 8, backlog = 2.5, maxPerFrame = 3 }
-- V3.0.8 (Orlando's coal report): dieselRate 16 -> 21 on the SHOW profile only.
-- A1 correctly stopped the Smoke-limit slider from inflating soot FORMATION, and the side effect was
-- that a limit-24 coal tune lost about a quarter of its visible cloud: the same over-fuelling now
-- reports smoke 68.8 instead of 86.7, and the ^1.45 curve in smokeFx.rates turns that into a 28% drop
-- in particle rate. Rolling coal is the feature diesel players came for, so the drama is restored
-- HERE - in the profile whose entire job is exaggeration - rather than by putting a physics lie back
-- in model.smoke. At density 2 this returns full coal to the maxRate ceiling exactly as before.
-- The Realistic profile is deliberately NOT touched: there the reduced number IS the honest one.
smokeFx.showProfile = { gasRate = 4.5, dieselRate = 21.0, eventRate = 3.0, bang = 0.90, maxRate = 24, backlog = 6, maxPerFrame = 6 }
smokeFx.kinds = { "haze", "fuel", "coal" }

function smokeFx.sootFactor(fuelType)
  fuelType = floor(tonumber(fuelType) or 1)
  if fuelType == 5 then return 0 end       -- M1: virtually no soot
  if fuelType == 2 then return 0.20 end    -- E85: strongly reduced particulates
  if fuelType == 3 then return 0.50 end    -- E30: between ethanol and gasoline
  if fuelType == 4 then return 0.80 end    -- C16: hydrocarbon fuel, slightly cleaner visual
  return 1
end

function smokeFx.context()
  return {
    diesel = effDiesel(), fuelType = floor(settings.fuelType or 1),
    lambdaTrue = model.lambdaTrue or 1, dieselSmoke = clamp((model.smoke or 0) / 100, 0, 1),
    richMisfire = model.misfireTimer > 0 and model.misfireStatus == "Rich misfire",
    popsLevel = clamp(settings.popsLevel or 1, 0, 3), density = clamp(settings.smokeDensity or 1, 0, 2),
    launch = model.launchActive and true or false, rolling = model.rollingActive and true or false,
    hardCut = model.hardCutActive and true or false, flatShift = model.flatShiftActive and true or false,
    antilag = model.antilagActive and true or false, overrun = model.overrunActive and true or false,
    rotIdle = model.rotIdleActive and true or false,
    launchFuel = clamp((settings.launchFuel or 0) / 30, 0, 1),
    launchRetard = clamp((settings.launchRetard or 0) / 15, 0, 1),
    antilagFuel = clamp((settings.antilagFuel or 0) / 30, 0, 1),
    antilagRetard = clamp((settings.antilagRetard or 0) / 35, 0, 1),
    overrunStrength = clamp(model.overrunPopStrength or 0, 0, 1),
    rotIdleStrength = clamp(model.rotIdleStrength or 0, 0, 1), show = smokeFx.isShow()
  }
end

-- Exhaust heat is deliberately derived from the simulated EGT rather than a
-- hidden timer. A cold system gives only a faint lick; sustained load and
-- retard heat the pipe enough for a brighter, longer flame. Diesel transient
-- state lives here too so smoke follows turbo lag rather than pedal position
-- alone.
function smokeFx.updateEnvironment(dt)
  local step = clamp(dt or 0, 0, 0.1)
  local running = (model.rpm or 0) > 600
  local ect = model.ectC or 20
  local egt = model.egtC or 300
  local warm = clamp((ect - 20) / 55, 0.08, 1)
  local heatTarget = clamp((egt - 300) / 450, 0, 1) * (0.35 + warm * 0.65)
  smokeFx.heat = approach(smokeFx.heat or heatTarget, heatTarget, heatTarget > (smokeFx.heat or 0) and 1.8 or 0.35, step)

  if running and not smokeFx.engineWasRunning and effDiesel() and ect < 60 then
    smokeFx.coldHaze = max(smokeFx.coldHaze or 0, clamp((60 - ect) / 40, 0.2, 1))
  end
  smokeFx.engineWasRunning = running
  smokeFx.coldHaze = max(0, (smokeFx.coldHaze or 0) - step * (0.12 + warm * 0.28))

  if effDiesel() and running then
    local boostCaught = clamp((model.boostKpa or 0) / 85, 0, 1)
    local tipIn = clamp(max(0, model.tpsDelta or 0) / 230, 0, 1)
    if (model.tps or 0) > 12 and tipIn > 0.05 then
      smokeFx.spoolPuff = max(smokeFx.spoolPuff or 0, tipIn * (1 - boostCaught))
    end
    smokeFx.spoolPuff = max(0, (smokeFx.spoolPuff or 0) - step * (1.2 + boostCaught * 3.5))
    local braking = type(model.compBrakeStatus) == "string" and model.compBrakeStatus:sub(1, 7) == "Braking"
    smokeFx.compPuff = approach(smokeFx.compPuff or 0, braking and 1 or 0, braking and 7 or 10, step)
  else
    smokeFx.spoolPuff, smokeFx.compPuff = 0, 0
  end

  local sootIn = effDiesel() and (clamp((model.smoke or 0) / 100, 0, 1) + (smokeFx.spoolPuff or 0) * 0.7)
    or smokeFx.flooding(smokeFx.context()) * 0.25
  smokeFx.sootLoad = clamp((smokeFx.sootLoad or 0) + sootIn * step * 0.018
    - ((smokeFx.heat or 0) > 0.75 and sootIn < 0.08 and step * 0.0025 or 0), 0, 1)

  smokeFx.mpSendTimer = (smokeFx.mpSendTimer or 0) + step
  smokeFx.mpContinuousTimer = (smokeFx.mpContinuousTimer or 0) + step
  if (smokeFx.mpEventLife or 0) > 0 then
    smokeFx.mpEventLife = max(0, smokeFx.mpEventLife - step)
    if smokeFx.mpEventLife <= 0 and electrics and electrics.values then
      electrics.values.oepFxCode = 0
    end
  end
  if electrics and electrics.values and smokeFx.mpContinuousTimer >= 0.10 then
    smokeFx.mpContinuousTimer = 0
    electrics.values.oepFxDiesel = effDiesel() and 1 or 0
    electrics.values.oepFxSmoke = floor(clamp(((model.smoke or 0) / 100) + (smokeFx.spoolPuff or 0) * 0.55, 0, 1) * 10 + 0.5) / 10
    electrics.values.oepFxCold = floor(clamp(smokeFx.coldHaze or 0, 0, 1) * 10 + 0.5) / 10
    electrics.values.oepFxComp = floor(clamp(smokeFx.compPuff or 0, 0, 1) * 10 + 0.5) / 10
    electrics.values.oepFxShow = smokeFx.isShow() and 1 or 0
  end
end

function smokeFx.eventEnergy(c)
  if c.launch or c.rolling or c.hardCut then
    return clamp(0.08 + c.launchFuel * 0.46 + c.launchRetard * 0.36, 0, 1)
  elseif c.flatShift then
    return 0.06
  elseif c.antilag then
    return clamp(0.06 + c.antilagFuel * 0.42 + c.antilagRetard * 0.34, 0, 1)
  elseif c.overrun then
    return c.overrunStrength
  elseif c.rotIdle then
    return c.rotIdleStrength * 0.30
  end
  return 0
end

function smokeFx.flooding(c)
  if c.diesel then return 1 end
  local flooding = clamp((0.64 - c.lambdaTrue) / 0.06, 0, 1) * smokeFx.sootFactor(c.fuelType)
  if c.richMisfire then flooding = max(flooding, 0.35 * smokeFx.sootFactor(c.fuelType)) end
  return clamp(flooding, 0, 1)
end

function smokeFx.coalShare(c, energy)
  local flooding = smokeFx.flooding(c)
  if not c.show or c.diesel then return flooding end
  -- Show / Coal intentionally restores the old dramatic look, but alcohol fuels
  -- still cannot create gasoline/diesel soot out of nowhere.
  local showShare = (0.20 + 0.55 * clamp(energy or 0, 0, 1)) * smokeFx.sootFactor(c.fuelType)
  return clamp(max(flooding, showShare), 0, 0.85)
end

function smokeFx.rates(c)
  local rates = { haze = 0, fuel = 0, coal = 0 }
  if c.density <= 0 then return rates end
  local cfg = c.show and smokeFx.showProfile or smokeFx.realProfile
  if c.diesel then
    -- Base smoke is the air/fuel smoke limiter. The separate spool impulse
    -- supplies one brief black cloud before boost catches up, while a cold
    -- start and compression brake use the pale/gray channels.
    local black = clamp(c.dieselSmoke + (smokeFx.spoolPuff or 0) * 0.7, 0, 1)
    if black > 0.03 then rates.coal = cfg.dieselRate * (black ^ 1.45) * c.density end
    rates.fuel = cfg.dieselRate * 0.18 * (smokeFx.coldHaze or 0) * c.density
    rates.haze = cfg.dieselRate * 0.07 * (smokeFx.compPuff or 0) * c.density
  else
    -- Warm gasoline stays visually clean through normal best-power enrichment.
    -- Haze begins only at lambda <~0.70; coal requires actual flooding (<~0.64).
    local richness = clamp((0.70 - c.lambdaTrue) / 0.12, 0, 1)
    if c.richMisfire then richness = max(richness, 0.55) end
    local baseRate = cfg.gasRate * (richness ^ 1.5) * c.density
    local baseCoal = c.show and smokeFx.coalShare(c, richness) or smokeFx.flooding(c)
    rates.haze = baseRate * (1 - baseCoal)
    rates.coal = baseRate * baseCoal

    local energy = smokeFx.eventEnergy(c)
    if energy > 0 then
      if c.show then energy = max(0.40, energy) end   -- preserve an intentionally dramatic Show / Coal floor
      local eventRate = cfg.eventRate * energy * c.popsLevel * c.density
      local coalShare = smokeFx.coalShare(c, energy)
      rates.fuel = eventRate * (1 - coalShare)
      rates.coal = rates.coal + eventRate * coalShare
    end
  end
  local total = rates.haze + rates.fuel + rates.coal
  if total > cfg.maxRate then
    local k = cfg.maxRate / total
    rates.haze, rates.fuel, rates.coal = rates.haze * k, rates.fuel * k, rates.coal * k
  end
  return rates
end

-- Called exactly once per bang. Flame sustain is intentionally flame-only, so
-- a 0.2-second fireball can no longer manufacture smoke every render frame.
-- Bang demand waits in a short bounded queue; updateExhaustSmoke releases it
-- through the same particles-per-second ceiling as the continuous haze.
function smokeFx.queueBang(size)
  local c = smokeFx.context()
  if c.density <= 0 or c.popsLevel <= 0 or c.diesel then return end
  local energy = smokeFx.eventEnergy(c)
  if energy <= 0 then return end
  if c.show then energy = max(0.40, energy) end
  local cfg = c.show and smokeFx.showProfile or smokeFx.realProfile
  local amount = cfg.bang * energy * clamp(size or 1, 0.5, 1.5) * c.popsLevel * c.density
  local coalShare = smokeFx.coalShare(c, energy)
  smokeFx.pending.fuel = smokeFx.pending.fuel + amount * (1 - coalShare)
  smokeFx.pending.coal = smokeFx.pending.coal + amount * coalShare
  local queued = smokeFx.pending.fuel + smokeFx.pending.coal
  if queued > cfg.backlog then
    local k = cfg.backlog / queued
    smokeFx.pending.fuel, smokeFx.pending.coal = smokeFx.pending.fuel * k, smokeFx.pending.coal * k
  end
end

function smokeFx.nextNode(nodes)
  local count = #nodes
  if count <= 0 then return nil end
  for _ = 1, count do
    smokeFx.tipCursor = smokeFx.tipCursor % count + 1
    local n = nodes[smokeFx.tipCursor]
    if n and n.finish and n.start then return n end
  end
  return nil
end

function smokeFx.emitNodes(allowEngineFallback)
  local th = engine and engine.thermals
  if type(th) == "table" and type(th.exhaustEndNodes) == "table" and #th.exhaustEndNodes > 0 then
    return th.exhaustEndNodes
  end
  if allowEngineFallback ~= false then
    local eb = engine and engine.engineBlockNodes
    if type(eb) == "table" and eb[1] and eb[2] then return { { finish = eb[1], start = eb[2] } } end
  end
  return nil
end

function smokeFx.particleId(kind, slow)
  if kind == "coal" then return fx(slow and 42 or 43) end
  if kind == "fuel" and oepFxActive() then return smokeFx.isShow() and 9618 or 9608 end
  return fx(slow and 44 or 45)
end

-- The controller runs before powertrain.updateGFX.  When OE has supplied a
-- guaranteed flame pulse, mute only the native VISUAL coefficient for a short
-- window before BeamNG processes the same afterfire fuel; native audio remains
-- untouched.  Outside that window the exact fitted-exhaust coefficient is
-- restored, so ordinary stock shift flames still work.
-- REVIEW FIX 2026-07-29: this window must OUTLAST the longest flame particle OE emits, otherwise the
-- suppression lifts while OE's plume is still fading and BeamNG re-fires its own afterfire flash into
-- the tail - the exact double-flame artifact the suppression exists to prevent. Stock re-arms in
-- 10-150 ms (combustionEngineThermals.lua:469/492), well inside that gap.
-- Sized to the longest SLOW flame in oepParticles00_data: OEP_flame_tongue, 66 ms + 11 ms variance =
-- 77 ms, plus margin. Keep these two numbers together - a lifetime edit must move this too. (The
-- ember at 105+35 ms is deliberately excluded: it is a rare, 1-3 cm single spark, not plume body.)
smokeFx.nativeSuppressWindow = 0.09
function smokeFx.updateNativeFlameSuppression(dt, enabled)
  if smokeFx.manualFlameThisFrame then
    smokeFx.nativeSuppressTimer = smokeFx.nativeSuppressWindow
  else
    smokeFx.nativeSuppressTimer = max(0, smokeFx.nativeSuppressTimer - (dt or 0))
  end
  smokeFx.manualFlameThisFrame = false
  if not enabled then smokeFx.nativeSuppressTimer = 0 end

  local th = engine and engine.thermals
  local nodes = type(th) == "table" and th.exhaustEndNodes or nil
  if type(nodes) ~= "table" then return end
  local suppress = enabled and smokeFx.nativeSuppressTimer > 0
  for _, n in pairs(nodes) do
    if type(n) == "table" and n.afterFireVisualCoef ~= nil then
      if n.oepStockAFVisual == nil then n.oepStockAFVisual = n.afterFireVisualCoef end
      if enabled then
        n.afterFireVisualCoef = suppress and 0 or n.oepStockAFVisual
      else
        n.afterFireVisualCoef = n.oepStockAFVisual
        n.oepStockAFVisual = nil
      end
    end
  end
end

local function updateExhaustSmoke(dt)
  if model.blown then smokeFx.reset(); return end
  local nodes = smokeFx.emitNodes(true)
  if not (nodes and #nodes > 0) or (model.rpm or 0) < 600 then smokeFx.reset(); return end
  local c = smokeFx.context()
  if c.density <= 0 then smokeFx.reset(); return end
  local cfg = c.show and smokeFx.showProfile or smokeFx.realProfile
  local rates = smokeFx.rates(c)
  local step = clamp(dt or 0, 0, 0.1)
  local budget = cfg.maxRate * step

  -- Bang impulses get first use of the rate budget so each crack remains
  -- recognizable. Any excess waits briefly instead of punching through the
  -- profile ceiling; continuous haze uses whatever budget remains.
  local queued = smokeFx.pending.fuel + smokeFx.pending.coal
  if queued > 0 and budget > 0 then
    local take = min(queued, budget)
    local k = take / queued
    local fuelTake, coalTake = smokeFx.pending.fuel * k, smokeFx.pending.coal * k
    smokeFx.pending.fuel = smokeFx.pending.fuel - fuelTake
    smokeFx.pending.coal = smokeFx.pending.coal - coalTake
    smokeFx.acc.fuel = min(12, smokeFx.acc.fuel + fuelTake)
    smokeFx.acc.coal = min(12, smokeFx.acc.coal + coalTake)
    budget = budget - take
  end

  local continuous = rates.haze + rates.fuel + rates.coal
  if continuous > 0 and budget > 0 then
    local wanted = continuous * step
    local k = min(1, budget / wanted)
    smokeFx.acc.haze = min(12, smokeFx.acc.haze + rates.haze * step * k)
    smokeFx.acc.fuel = min(12, smokeFx.acc.fuel + rates.fuel * step * k)
    smokeFx.acc.coal = min(12, smokeFx.acc.coal + rates.coal * step * k)
  end

  -- readBoostGroundSpeed already tries airspeed, obj:getGroundSpeed and then
  -- wheelspeed.  Reuse that validated result so a mod car with no airspeed
  -- channel cannot stay on the standstill smoke/flame profile while driving.
  local slow = H.flameGroundSpeedKmh() < 36
  local offset = (model.rpm or 0) * -0.0004 - 3
  for _ = 1, cfg.maxPerFrame do
    local kind
    for _ = 1, #smokeFx.kinds do
      smokeFx.kindCursor = smokeFx.kindCursor % #smokeFx.kinds + 1
      local candidate = smokeFx.kinds[smokeFx.kindCursor]
      if smokeFx.acc[candidate] >= 1 then kind = candidate; break end
    end
    if not kind then break end
    local n = smokeFx.nextNode(nodes)
    if not n then smokeFx.reset(); break end
    -- Consume the scheduled puff even when the outlet is submerged;
    -- otherwise a hidden backlog erupts all at once when the tip leaves water.
    smokeFx.acc[kind] = smokeFx.acc[kind] - 1
    -- REVIEW FIX 2026-07-27 (diesel smoke regression). This used to also require
    -- `afterFireVisualCoef > 0` on the tip. That field is BeamNG's coefficient for its own NATIVE
    -- afterfire/backfire VISUAL, and combustionEngineThermals multiplies it down the exhaust branch
    -- (:1205), so one quiet part zeroes the end node. Every D-Series muffler declares 0.0 - so on the
    -- game's main diesel truck the exhaust-end coefficient is exactly 0 and OE emitted NO SOOT AT ALL,
    -- no matter how hard the engine was over-fuelled. Bastion, ETK 800/K, Roamer, Van, Sunburst and
    -- others ship 0.0 mufflers too, so they lost OE smoke the same way.
    -- Soot is not afterfire: it is unburnt fuel leaving the pipe, and it exists precisely BECAUSE the
    -- charge did not burn. Whether the vehicle models a native backfire flash at that tip says nothing
    -- about whether it should smoke. The real "is this a genuine outlet" test is the exhaust-END-node
    -- requirement, which nodes already satisfies.
    if not obj:inWater(n.finish) then
      obj:addParticleByNodesRelative(n.finish, n.start, offset, smokeFx.particleId(kind, slow), 0, 1)
    end
  end
end

-- ---------------------------------------------------------------------------
-- 2-step / rolling anti-lag BARK (vehicle sound engine, V2_1)
-- ---------------------------------------------------------------------------
-- Played by the CONTROLLER through BeamNG's own sound system, NOT the UI's HTML5 <audio>. The old UI
-- audio was (a) unreliable - CEF/Chromium autoplay locks made it randomly drop to just the stock
-- afterfire - and (b) local-only, so other players in BeamMP never heard it. Playing it as a vehicle
-- sound fixes both: no browser autoplay rules, and BeamNG/BeamMP run the vehicle Lua on every client
-- so the bark is a real positional sound everyone hears. Every sound call is pcall-guarded so a missing
-- API can NEVER break a vehicle (the trailer-crash lesson).
local launchSnd = nil       -- BeamNG SFX source id for the bark (nil = none created)
local launchSndVol = 0      -- smoothed bark volume
local launchSndOn = false   -- is the source currently PLAYING (vs created-but-stopped)?
-- ROLLING anti-lag gets its OWN looping sound (the anti-lag 2JZ braap), separate from the standstill
-- 2-step's Honda bark, and FADES OUT on release rather than cutting.
local rollingSnd = nil
local rollingSndVol = 0
local rollingSndOn = false
local ROLLING_SOUND = "/ui/modules/apps/OEProTune/sounds/antilag_2jz.wav"
-- REVIEW FIX 2026-07-29. These are per-sample TRIMS in 0..1 now, not amplifiers. See H.soundFader.
local ROLLING_TRIM = 0.90   -- anti-lag loop sits UNDER the bark rather than on top of it
-- ENGINE-FAMILY 2-step sound: V8 / LS / muscle engines get a deep V8 2-step for BOTH the launch 2-step and
-- the rolling anti-lag (set by the UI via setEngineClass); everything else keeps the Honda / anti-lag braap.
local engineIsV8 = false
local HONDA_2STEP = "/ui/modules/apps/OEProTune/Honda_D16T_Ext_2step_H.wav"
local V8_2STEP    = "/ui/modules/apps/OEProTune/sounds/2step_v8.wav"
local V8_TRIM     = 0.85    -- the V8 sample is the hottest and harshest of the three; keep it under the Honda
local LAUNCH_TRIM = 1.00    -- Honda 2-step, the reference

-- REVIEW FIX 2026-07-29 - the 2-step "very loud even on volume 1, and it sounds weird" report.
--
-- The old code multiplied the slider by up to 8 (LAUNCH_GAIN) and clamped the result to 8, not to 1,
-- on the belief - written in the comment that used to live here - that "the WAV is recorded quiet".
-- It is not. Measured peaks: Honda 2-step 94.8% of full scale, 2step_v8 100%, antilag_2jz 100%,
-- 2jz6 100%. They are peak-normalised; what is low is their RMS (4-15%), which is simply what a
-- percussive bark looks like. So the multiplier was solving the wrong problem:
--
--   slider 1  -> 0.8 linear  = 76% of full scale   <- already loud; there was no quiet setting
--   slider 2  -> 1.6 linear  = 152% of full scale  <- clipping starts here
--   slider 10 -> 8.0 linear  = 758% of full scale  <- square-wave distortion, not a 2-step
--
-- BeamNG's own convention is a 0..1 linear gain - combustionEngineThermals passes radiatorFanVolume
-- (default 0.5) and hydraulicSuspension passes a literal 0.5. Nothing stock goes over unity.
--
-- This is now a real fader: exactly 2 dB per step, -18 dB at slider 1 rising to 0 dB at slider 10, so
-- the control is useful across its whole range and can never drive a sample past full scale. The
-- volume CEILING is unchanged if BeamNG was already clamping to 1.0 internally; if it was not, the
-- top end gets quieter but stops distorting - which is the point.
--
-- rangeDb is the one knob to turn if this needs adjusting by ear. It is deliberately 18 rather than a
-- wider 24: a 2-step bark has to carry over a loud engine at launch rpm, and a wider range would make
-- the default slider 5 feel broken. Larger = quieter at the bottom of the slider. Kept as a function
-- local, not a chunk local - this file sits at 194 of LuaJIT's 200 main-chunk locals.
function H.soundFader(slider, trim)
  local rangeDb = 18
  local s = clamp(tonumber(slider) or 0, 0, 10)
  if s <= 0 then return 0 end
  return 10 ^ ((-rangeDb + rangeDb * (s - 1) / 9) / 20) * clamp(tonumber(trim) or 1, 0, 1)
end

local function exhaustSoundNode()
  local th = engine and engine.thermals
  if type(th) == "table" and type(th.exhaustEndNodes) == "table" and th.exhaustEndNodes[1] then
    local n = th.exhaustEndNodes[1]
    return n.finish or n.start
  end
  if type(engine) == "table" and type(engine.engineBlockNodes) == "table" and engine.engineBlockNodes[1] then
    return engine.engineBlockNodes[1]
  end
  return nil
end

-- SELF-HEALING source lifecycle (V2_1 fix): a single looping source kept SILENTLY alive between launches
-- got reclaimed by BeamNG/FMOD (it steals idle/silent voices) or cut by a vehicle reset, leaving a stale
-- handle we never recreated -> the bark "went away" until a respawn. Fix: only PLAY while a bark is
-- actually happening, STOP the source the instant it ends (a stopped source is no idle voice to reclaim),
-- and DROP the handle on reset so the next bark builds a guaranteed-fresh one.
local function ensureLaunchPlaying()
  if not (obj and obj.createSFXSource2) then return false end
  if not launchSnd then
    local node = exhaustSoundNode()
    if not node then return false end
    local ok, id = pcall(function()
      return obj:createSFXSource2(engineIsV8 and V8_2STEP or HONDA_2STEP, "AudioDefaultLoop3D", "oeProTune2Step", node, 0)
    end)
    if ok and type(id) == "number" and id >= 0 then
      launchSnd = id; launchSndOn = false
      pcall(function() obj:setVolumePitch(launchSnd, 0, 1) end)   -- start silent so the create frame can't blast at full volume
    else
      return false
    end
  end
  if not launchSndOn then
    pcall(function() obj:playSFX(launchSnd) end)
    launchSndOn = true
  end
  return true
end

local function quietLaunchSound()   -- bark over: STOP the source (not just volume 0) so it can't be reclaimed idle; keep the handle
  launchSndVol = 0
  if launchSnd and launchSndOn then
    pcall(function() obj:setVolumePitch(launchSnd, 0, 1) end)
    pcall(function() obj:stopSFX(launchSnd) end)
    launchSndOn = false
  end
end

local function destroyLaunchSound()   -- reset: drop the handle entirely so the next bark builds a fresh source (covers a reset cutting it)
  if launchSnd then pcall(function() obj:stopSFX(launchSnd) end) end
  launchSnd = nil; launchSndOn = false; launchSndVol = 0
end

-- rolling anti-lag's own looping braap (mirrors the launch-sound lifecycle, separate source/handle)
local function ensureRollingPlaying()
  if not (obj and obj.createSFXSource2) then return false end
  if not rollingSnd then
    local node = exhaustSoundNode()
    if not node then return false end
    local ok, id = pcall(function()
      return obj:createSFXSource2(engineIsV8 and V8_2STEP or ROLLING_SOUND, "AudioDefaultLoop3D", "oeProTuneRollAls", node, 0)
    end)
    if ok and type(id) == "number" and id >= 0 then
      rollingSnd = id; rollingSndOn = false
      pcall(function() obj:setVolumePitch(rollingSnd, 0, 1) end)
    else
      return false
    end
  end
  if not rollingSndOn then pcall(function() obj:playSFX(rollingSnd) end); rollingSndOn = true end
  return true
end

local function quietRollingSound()
  rollingSndVol = 0
  if rollingSnd and rollingSndOn then
    pcall(function() obj:setVolumePitch(rollingSnd, 0, 1) end)
    pcall(function() obj:stopSFX(rollingSnd) end)
    rollingSndOn = false
  end
end

local function destroyRollingSound()
  if rollingSnd then pcall(function() obj:stopSFX(rollingSnd) end) end
  rollingSnd = nil; rollingSndOn = false; rollingSndVol = 0
end

local function updateLaunchSound(dt)
  -- HONDA 2-step bark: standstill launch + staging ONLY. Rolling anti-lag has its OWN sound below, so the
  -- two never play over each other (they're mutually exclusive states anyway). V8 engines use a lower gain.
  local lg = engineIsV8 and V8_TRIM or LAUNCH_TRIM
  local maxVol = H.soundFader(settings.launchSoundVol, lg)
  local target = 0
  -- no turbo gate (V2_3): a fuel-cut 2-step barks on NA engines too; the launch limiter itself never
  -- needed a turbo, only its sound did
  if controlling and settings.enabled ~= false and maxVol > 0 and not model.rollingActive then
    if model.launchActive then
      target = maxVol                                                 -- bouncing on the standstill 2-step: full bark
    elseif settings.launchEnabled == true then
      local speedKmh = (ev("wheelspeed", 0) or 0) * 3.6
      if speedKmh < 16 and (model.tps or 0) > 45 then                 -- staging: fade in toward launch rpm
        local limit = settings.launchLimit or 5500
        target = clamp((model.rpm - (limit - 1400)) / 1400, 0, 1) * maxVol
      end
    end
  end
  launchSndVol = launchSndVol + (target - launchSndVol) * 0.35
  if target <= 0 and launchSndVol < 0.004 then launchSndVol = 0 end
  if launchSndVol > 0.004 then
    if ensureLaunchPlaying() then
      pcall(function() obj:setVolumePitch(launchSnd, clamp(launchSndVol, 0, lg), 1) end)
    end
  elseif launchSndOn then
    quietLaunchSound()   -- idle: stop the source so BeamNG can't reclaim it
  end

  -- ROLLING ANTI-LAG braap (its own sound): full while the button is held, then a slow FADE-OUT on release
  -- (fast fade-in, slow fade-out for the tail Orlando asked for).
  local rg = engineIsV8 and V8_TRIM or ROLLING_TRIM
  local rMaxVol = H.soundFader(settings.launchSoundVol, rg)
  -- no turbo gate (V2.6): rollingActive works on any engine now (V8 muscle cars get the V8 bark);
  -- antilagActive is still turbo-gated at its source
  local rTarget = (controlling and settings.enabled ~= false and (model.rollingActive or model.antilagActive) and rMaxVol > 0) and rMaxVol or 0
  local rRate = (rTarget > rollingSndVol) and 0.30 or 0.05   -- snappy in, gentle out (~0.5s tail)
  rollingSndVol = rollingSndVol + (rTarget - rollingSndVol) * rRate
  if rTarget <= 0 and rollingSndVol < 0.004 then rollingSndVol = 0 end
  if rollingSndVol > 0.004 then
    if ensureRollingPlaying() then
      pcall(function() obj:setVolumePitch(rollingSnd, clamp(rollingSndVol, 0, rg), 1) end)
    end
  elseif rollingSndOn then
    quietRollingSound()
  end
end

local function stopLaunchSound()   -- disconnect: stop both holds' sound but keep the handles (reconnect replays)
  quietLaunchSound()
  quietRollingSound()
end

-- ---------------------------------------------------------------------------
-- OVERRUN POPS & CRACKLES (tune-it-like-real-life, V2_1)
-- ---------------------------------------------------------------------------
-- Real pops/bangs happen on throttle LIFT (overrun): instead of cutting fuel, raw fuel dumps into the
-- hot exhaust and ignites, and cut/retarded ignition makes it crackle. The player TUNES it: richer +
-- more-retarded overrun cells = bigger, more-frequent bangs. The FEEL is realistic, not a steady
-- machine-gun: after you lift there's a BEAT of silence (the exhaust loading up), then pops "here and
-- there" - sometimes a quick pair, sometimes a gap. Drives BeamNG's native afterfire (flames) in a kick
-- per pop AND fires custom deep-2JZ backfire one-shots. All sound calls are pcall-guarded so nothing can
-- break a vehicle.
local popSamples = {
  "/ui/modules/apps/OEProTune/sounds/2jz1.wav", "/ui/modules/apps/OEProTune/sounds/2jz2.wav",
  "/ui/modules/apps/OEProTune/sounds/2jz3.wav", "/ui/modules/apps/OEProTune/sounds/2jz4.wav",
  "/ui/modules/apps/OEProTune/sounds/2jz5.wav", "/ui/modules/apps/OEProTune/sounds/2jz6.wav",
}
local popTimer = 0
local popArmed = false   -- false until the "beat of silence" after a lift has been armed (re-armed each new lift)
local popFlame = 0       -- decaying flame intensity so the exhaust flame KICKS with each pop, not a constant sheet
local popFlameHold = 0   -- seconds left in the current flame JET (each pop keeps spitting flame for a sustain window so it READS as a flame, not a 1-frame spark)
local popFlameScale = 1  -- per-pop RANDOM size (rolled at each bang, held for that whole jet): most pops small/medium, occasional big fireball
local popFlameHold0 = 0  -- the jet's INITIAL duration, so the sustain loop can decay the reach over the jet's life (fireball licks out, then contracts)
local popFlameBlue = 0   -- 0..1 flame colour from the OVERRUN TUNE: 0 = sooty orange (rich), 1 = hot blue crackle (lean + heavy retard)
local launchFlameTimer = 0  -- machine-gun flame cadence for the 2-step / rolling anti-lag (flames synced to the bark)
-- POP SOUND POOL: sounds.playSoundOnceFollowNode(file,...) does NOT play a WAV path (confirmed in-game -
-- the feature fires but is silent). So fire the samples through the SAME proven mechanism as the 2-step
-- bark (createSFXSource2 looping sources), but as ONE-SHOTS: reset+play a source, set its volume, then
-- stop it after the clip so the loop doesn't repeat. Round-robin over the pool for variety. All pcall-guarded.
local POP_POOL_N = #popSamples
local popDur = { 0.920, 0.845, 0.740, 0.966, 0.708, 0.878 }   -- bf 2jz deep 1..6 clip lengths (s) so each one-shot stops at its NATURAL end
local popPool = {}        -- created source ids (one per sample)
local popStopAt = {}      -- per-slot simTime to stop it
local popVol = {}         -- per-slot play volume (for the fade-out)
local popPitch = {}       -- per-slot pitch
local popPoolIdx = 0

local function ensurePopPool()
  if #popPool >= POP_POOL_N or not (obj and obj.createSFXSource2) then return end
  local node = exhaustSoundNode()
  if not node then return end
  for i = #popPool + 1, POP_POOL_N do
    local ok, id = pcall(function()
      return obj:createSFXSource2(popSamples[i], "AudioDefaultLoop3D", "oeProTunePop" .. i, node, 0)
    end)
    if ok and type(id) == "number" and id >= 0 then
      popPool[i] = id
      pcall(function() obj:setVolumePitch(id, 0, 1) end)
      pcall(function() obj:stopSFX(id) end)
    else
      break   -- not ready yet, retry next frame
    end
  end
end

local function tickPopPool()   -- ride each fired pop to its natural end, fading the last 100ms for a clean cut
  for i, t in pairs(popStopAt) do
    local id = popPool[i]
    if not id then popStopAt[i] = nil
    else
      local remain = t - simTime
      if remain <= 0 then
        pcall(function() obj:setVolumePitch(id, 0, popPitch[i] or 1) end); pcall(function() obj:stopSFX(id) end)
        popStopAt[i] = nil
      elseif remain < 0.1 then
        pcall(function() obj:setVolumePitch(id, (popVol[i] or 1) * (remain / 0.1), popPitch[i] or 1) end)
      end
    end
  end
end

local function firePop(vol, pitch)
  -- REVIEW FIX 2026-07-29: same over-unity problem as the 2-step, one choke point for all three
  -- callers. The overrun/hard-cut/anti-lag sites can compose up to ~3.0 (0.4+strength, x overrunPopVol/6
  -- which itself clamps to 2), and the 2jz pop samples peak at 67-100% of full scale, so anything past
  -- 1.0 was clipping them. Clamp here rather than reshaping each caller's own intensity curve, so the
  -- relative dynamics between a light overrun crackle and a hard-cut bang are preserved.
  vol = clamp(tonumber(vol) or 0, 0, 1)
  ensurePopPool()
  if #popPool == 0 then return end
  popPoolIdx = (popPoolIdx % #popPool) + 1
  local id = popPool[popPoolIdx]
  if not id then return end
  pcall(function() obj:stopSFX(id) end)                 -- reset to clip start
  pcall(function() obj:setVolumePitch(id, vol, pitch) end)
  pcall(function() obj:playSFX(id) end)
  popVol[popPoolIdx] = vol
  popPitch[popPoolIdx] = pitch
  popStopAt[popPoolIdx] = simTime + (popDur[popPoolIdx] or 0.7) / pitch   -- stop at the clip's NATURAL end (pitch-adjusted)
end

local function stopAllPops()   -- silence the pool (disconnect) without dropping the handles
  for _, id in pairs(popPool) do pcall(function() obj:setVolumePitch(id, 0, 1) end); pcall(function() obj:stopSFX(id) end) end
  popStopAt = {}; popVol = {}; popPitch = {}
  popArmed = false; popTimer = 0; popFlame = 0; popFlameHold = 0   -- re-arm the after-lift delay on the next overrun
  smokeFx.popFlameTimer = 0
  model.overrunActive = false
end

local function destroyPopPool()   -- reset: drop the handles so the pool rebuilds fresh
  stopAllPops(); popPool = {}; popPoolIdx = 0
end

-- ---------------------------------------------------------------------------
-- KNOCK AUDIO (V3.0, rebuilt on real recordings - the V2.8 synthetic ping was cut for
-- sounding robotic). Four recorded ticking samples ship with the mod; each CAR picks one
-- deterministically from its name, so different cars knock with different voices but a
-- given car always sounds like itself. Two real layers:
--   DETONATION - the car's ticking sample LOOPS while knock is over threshold, volume
--   rising with severity and pitch following rpm. Lift, richen or pull timing and it
--   stops - detonation is the layer tuning can fix.
--   ROD KNOCK - once the bottom end is genuinely hurt (health < 70%), the same sample
--   runs deep (pitched down) and the game's own bearing-knock event ticks with the revs
--   underneath. It keeps knocking CONNECTED OR NOT and no tune change can silence it -
--   like real life, a rod-knocking engine is only fixed by repairing it (vehicle reset).
-- Audio only, zero tune/HP impact. 'Knock & damage sounds' toggle lives on Knock Control.
local KNOCK_SOUNDS = {
  "/ui/modules/apps/OEProTune/sounds/knock/ticking1.wav",
  "/ui/modules/apps/OEProTune/sounds/knock/ticking2.wav",
  "/ui/modules/apps/OEProTune/sounds/knock/ticking3.wav",
  "/ui/modules/apps/OEProTune/sounds/knock/ticking4.wav"
}
local knockSnd = { det = nil, rod = nil, detOn = false, rodOn = false, pick = nil, basePitch = 1, rodTick = 0 }

local function destroyKnockAudio()
  if knockSnd.det then pcall(function() obj:stopSFX(knockSnd.det) end) end
  if knockSnd.rod then pcall(function() obj:stopSFX(knockSnd.rod) end) end
  knockSnd.det, knockSnd.rod = nil, nil
  knockSnd.detOn, knockSnd.rodOn = false, false
  knockSnd.rodTick = 0
end

local function updateKnockAudio(dt, rpm)
  if settings.knockAudio == false or model.blown then
    if knockSnd.detOn or knockSnd.rodOn then destroyKnockAudio() end
    return
  end
  if not (obj and obj.createSFXSource2) then return end
  if not knockSnd.pick then
    -- per-car voice: hash the car name so the pick is stable for this car but varies across cars
    local s = 0
    for i = 1, #carName do s = s + carName:byte(i) end
    knockSnd.pick = KNOCK_SOUNDS[(s % #KNOCK_SOUNDS) + 1]
  end
  local node = (engine and engine.engineBlockNodes and engine.engineBlockNodes[1]) or 0

  -- layer 1: detonation ticking while knock is live (only while the ECU runs the tune)
  local detActive = controlling and (model.knockLevel or 0) > (settings.knockThreshold or 1.2) and rpm > 1200
  if detActive then
    if not knockSnd.det then
      local ok, id = pcall(function() return obj:createSFXSource2(knockSnd.pick, "AudioDefaultLoop3D", "oeProTuneKnockDet", node, 0) end)
      if ok and type(id) == "number" and id >= 0 then knockSnd.det = id end
    end
    if knockSnd.det then
      local sev = clamp(((model.knockLevel or 0) - (settings.knockThreshold or 1.2)) / 2.5, 0, 1)
      if not knockSnd.detOn then
        knockSnd.basePitch = 0.92 + random() * 0.12   -- fresh character each knock episode
        pcall(function() obj:playSFX(knockSnd.det) end)
        knockSnd.detOn = true
      end
      pcall(function() obj:setVolumePitch(knockSnd.det, 0.22 + sev * 0.55, knockSnd.basePitch + clamp(rpm / 18000, 0, 0.35)) end)
    end
  elseif knockSnd.detOn then
    pcall(function() obj:stopSFX(knockSnd.det) end)
    knockSnd.detOn = false
  end

  -- layer 2: rod knock once the damage is done - deep, rev-synced, permanent until repair
  local health = 100 * (1 - (model.damage or 0))
  local rodActive = health < 70 and rpm > 300
  if rodActive then
    if not knockSnd.rod then
      local ok, id = pcall(function() return obj:createSFXSource2(knockSnd.pick, "AudioDefaultLoop3D", "oeProTuneKnockRod", node, 0) end)
      if ok and type(id) == "number" and id >= 0 then knockSnd.rod = id end
    end
    local vol = clamp((70 - health) / 35, 0.3, 1)
    if knockSnd.rod then
      if not knockSnd.rodOn then
        pcall(function() obj:playSFX(knockSnd.rod) end)
        knockSnd.rodOn = true
      end
      pcall(function() obj:setVolumePitch(knockSnd.rod, vol, 0.72 + clamp(rpm / 20000, 0, 0.2)) end)
    end
    -- the game's own bearing-knock one-shot on its own rev cadence (~once per 2 revs) for body
    knockSnd.rodTick = knockSnd.rodTick > 1 and 0 or knockSnd.rodTick + dt * rpm * 0.008333
    if knockSnd.rodTick > 1 and engine and engine.engineBlockNodes and engine.engineBlockNodes[1] and sounds and sounds.playSoundOnceFollowNode then
      pcall(function() sounds.playSoundOnceFollowNode("event:>Vehicle>Failures>failure_engine_knock", engine.engineBlockNodes[1], vol) end)
    end
  elseif knockSnd.rodOn then
    pcall(function() obj:stopSFX(knockSnd.rod) end)
    knockSnd.rodOn = false
  end
end

-- ---------------------------------------------------------------------------
-- POP SOUND SELECTOR (V2_3): the player picks what voices the tune's pops.
--   1 = Both: OE samples layered over the car's own native afterfire (the pre-selector behaviour).
--   2 = Car's own only: no OE one-shots. The car's native afterfire events - INCLUDING a sound mod's
--       custom pop samples, since the jbeam defines them - voice everything, BOOSTED louder and less
--       muffled as the Pops/flames slider rises. Verified in the game source: the thermals audio loop
--       iterates the SAME node table it exports as engine.thermals.exhaustEndNodes, and each entry's
--       afterFireVolumeCoef / afterFireMufflingCoef are plain fields, so writing them works live.
--   3 = OE samples only: native BANGS muted (node volume 0) so only the OE pool voices pops. Native
--       FLAMES keep working - the visual fuel path doesn't go through the audio volume.
-- Stock per-node values are stamped on first touch (oepStockAFVol/oepStockAFMuf) and restored on
-- disconnect/reset. The 2-step / rolling BARK is separate (its own volume slider) and unaffected.
local lastPopAudioKey = nil   -- change detector (mode|popsLevel|nodeCount) so node coefs are only rewritten on change
local function applyNativePopAudio()
  local th = engine and engine.thermals
  local nodes = th and th.exhaustEndNodes
  if type(nodes) ~= "table" then return end
  local mode = floor(settings.popSoundMode or 1)
  local pl = clamp(settings.popsLevel or 1, 0, 3)
  local key = mode .. "|" .. pl .. "|" .. #nodes   -- node count in the key: a broken/changed exhaust rebuilds the list with fresh entries
  if key == lastPopAudioKey then return end
  lastPopAudioKey = key
  for _, n in pairs(nodes) do
    if type(n) == "table" and n.afterFireVolumeCoef ~= nil then
      if n.oepStockAFVol == nil then   -- stamp the jbeam originals the first time we touch this node
        n.oepStockAFVol = n.afterFireVolumeCoef
        n.oepStockAFMuf = n.afterFireMufflingCoef
      end
      if mode == 2 then
        -- car's own pops, made aggressive: louder + less muffled as Pops/flames rises.
        -- pl 0 = stock character untouched; pl 3 ~ 3x volume and mostly unmuffled (straight-pipe bark).
        n.afterFireVolumeCoef = n.oepStockAFVol * (1 + pl * 0.7)
        if type(n.oepStockAFMuf) == "number" then
          n.afterFireMufflingCoef = n.oepStockAFMuf * clamp(1 - pl * 0.3, 0.1, 1)
        end
      elseif mode == 3 then
        n.afterFireVolumeCoef = 0   -- mute native bangs; OE samples voice the pops (flames unaffected)
        if type(n.oepStockAFMuf) == "number" then n.afterFireMufflingCoef = n.oepStockAFMuf end
      else
        n.afterFireVolumeCoef = n.oepStockAFVol
        if type(n.oepStockAFMuf) == "number" then n.afterFireMufflingCoef = n.oepStockAFMuf end
      end
    end
  end
end

local function restoreNativePopAudio()   -- disconnect/reset: hand the exhaust's own sound character back
  lastPopAudioKey = nil
  local th = engine and engine.thermals
  local nodes = th and th.exhaustEndNodes
  if type(nodes) ~= "table" then return end
  for _, n in pairs(nodes) do
    if type(n) == "table" and n.oepStockAFVol ~= nil then
      n.afterFireVolumeCoef = n.oepStockAFVol
      if type(n.oepStockAFMuf) == "number" then n.afterFireMufflingCoef = n.oepStockAFMuf end
      n.oepStockAFVol = nil
      n.oepStockAFMuf = nil
    end
  end
end

-- where to emit flame particles from. Prefer the real exhaust-tip nodes; but a lot of engine MODS ship a
-- CUSTOM engine (e.g. the D&O Civic's docivic4combustionEngine, custom S2000 engines) whose exhaust never
-- registers exhaust-END nodes - on those, engine.thermals.exhaustEndNodes is empty, so the flames had
-- nothing to emit from (the sound still works because its own lookup can fall back to an engine node).
-- Never put visual fire on that fallback: it produces an under-hood flame, and at speed looks like a
-- detached fireball.  A car must expose real thermals exhaust-end nodes for custom flames. Returns a
-- list of {finish,start} node pairs, or nil if we can't find any usable exhaust tips.
local function flameEmitNodes()
  return smokeFx.emitNodes(false)
end

function smokeFx.pickFlameNode(nodes, newEvent)
  local count = type(nodes) == "table" and #nodes or 0
  if count <= 0 then return nil end
  if newEvent or not nodes[smokeFx.flameTipIndex or 0] then
    smokeFx.flameTipCursor = (smokeFx.flameTipCursor or 0) % count + 1
    smokeFx.flameTipIndex = smokeFx.flameTipCursor
  end
  return nodes[smokeFx.flameTipIndex]
end

-- BeamMP's stock electrics synchronizer forwards unknown numeric channels.
-- Publish a tiny event packet instead of requiring a server plugin. The owner
-- renders immediately; replicated vehicles replay the packet below.
function smokeFx.publishEvent(kind, strength, blue, duration)
  if H.isBeamMpRemote() or not (electrics and electrics.values) then return end
  local priority = kind == 2 or kind == 6
  if (smokeFx.mpSendTimer or 0) < (priority and 0.035 or 0.075) then return end
  smokeFx.mpSendTimer = 0
  smokeFx.mpEventLife = max(0.18, (duration or 0) + 0.12)
  smokeFx.mpSeq = (smokeFx.mpSeq or 0) % 9999 + 1
  electrics.values.oepFxCode = floor(clamp(kind or 1, 1, 7))
  electrics.values.oepFxStrength = floor(clamp(strength or 1, 0.2, 2.5) * 100 + 0.5) / 100
  electrics.values.oepFxBlue = floor(clamp(blue or 0, 0, 1) * 100 + 0.5) / 100
  electrics.values.oepFxHeat = floor(clamp(smokeFx.heat or 0, 0, 1) * 100 + 0.5) / 100
  electrics.values.oepFxFuel = effDiesel() and 0 or floor(clamp(settings.fuelType or 1, 1, 5))
  -- 0..3, NOT 0..2. A braided variant (3) crushed to 2 is still a VALID variant, so nothing
  -- errors and nothing logs - the owner sees the braid and every remote player sees the amber
  -- jet. This bound and the receive bound in updateRemote must always move together.
  electrics.values.oepFxVariant = floor(clamp(smokeFx.flameVariant or 0, 0, 3))
  electrics.values.oepFxShow = smokeFx.isShow() and 1 or 0
  electrics.values.oepFxDuration = floor(clamp(duration or 0, 0, 0.35) * 100 + 0.5) / 100
  -- Write the sequence last so a receiver never observes a new sequence with
  -- fields left over from the previous event.
  electrics.values.oepFxSeq = smokeFx.mpSeq
end

function smokeFx.pulseLight(n, blue, strength)
  if not (n and n.finish and obj and obj.getPosition and obj.getNodePosition and obj.queueGameEngineLua) then return end
  pcall(function()
    local p = obj:getPosition() + obj:getNodePosition(n.finish)
    local b = clamp(blue or 0, 0, 1)
    local k = clamp(strength or 1, 0.3, 2)
    local id = obj:getID()
    local cmd = string.format(
      "if oeProTuneEffects and oeProTuneEffects.pulse then oeProTuneEffects.pulse(%d,%.4f,%.4f,%.4f,%.3f,%.3f,%.3f,%.3f,%.3f,%.3f) end",
      id, p.x, p.y, p.z, 1 - b * 0.35, 0.34 + b * 0.38, 0.10 + b * 0.78,
      0.32 + k * 0.16, 2.0 + k * 0.55, 0.055 + k * 0.018)
    obj:queueGameEngineLua(cmd)
  end)
end

-- Event codes: 1 overrun flicker, 2 shift snap, 3 launch pulse,
-- 4 rolling anti-lag, 5 anti-lag crackle, 6 limiter pulse, 7 rotational idle.
-- Each event gets a different layer rhythm. Round blue/orange kernels stay
-- small; the curled fragments and their travel form the visible jet.
local function emitPopFlame(kind, blue, vMul, newEvent, fromNetwork)
  if not (obj and engine and settings) then return end
  local pl = clamp(settings.popsLevel or 1, 0, 3)
  if pl <= 0 then return end
  local emitNodes = flameEmitNodes()
  if not emitNodes then return end
  kind = floor(clamp(kind or 1, 1, 7))
  local b = clamp(blue or popFlameBlue, 0, 1)
  local ft = fromNetwork and (smokeFx.remoteFuel or 1) or (effDiesel() and 0 or settings.fuelType or 1)
  if ft == 5 then b = max(b, 0.82)
  elseif ft == 2 then b = max(b, 0.34)
  elseif ft == 3 then b = max(b, 0.14) end
  local heat = clamp(fromNetwork and (smokeFx.remoteHeat or 0) or (smokeFx.heat or 0), 0, 1)
  local show = fromNetwork and smokeFx.remoteShow == true or smokeFx.isShow()
  if show then heat = max(heat, 0.35) end
  if heat < 0.07 then return end
  if newEvent and not fromNetwork then
    local wovenChance = show and 0.24 or 0.16
    local amberChance = show and 0.16 or 0.10
    local braidChance = show and 0.14 or 0.09
    if kind == 5 then wovenChance, amberChance, braidChance = wovenChance * 0.6, amberChance * 0.6, braidChance * 0.6 end
    if ft == 0 or ft == 5 then
      smokeFx.flameVariant = 0
    else
      -- Cumulative bands, APPENDED in order: [0,woven)=1, [woven,+amber)=2, [+amber,+braid)=3,
      -- remainder = 0. A new band must always go on the END - prepending it would silently shift
      -- the existing windows and change P(woven) and P(amber) while all three still appear, so
      -- nothing would look broken. Running total stays under 1 (0.54 show / 0.35 realistic) or
      -- the default body becomes unreachable.
      local roll = random()
      smokeFx.flameVariant = roll < wovenChance and 1
        or (roll < wovenChance + amberChance and 2
        or (roll < wovenChance + amberChance + braidChance and 3 or 0))
    end
  end
  -- Diesel normally makes smoke, not a petrol-style fireball. Only a very hot,
  -- aggressive cut may light a small charge, and even then it remains rare.
  if ft == 0 then
    if heat < 0.72 or (kind ~= 3 and kind ~= 5 and kind ~= 6) then return end
    if newEvent and random() > (show and 0.18 or 0.08) then return end
    b = max(b, 0.18)
  end
  local speedKmh = H.flameGroundSpeedKmh()
  local fastFlame = speedKmh >= 5
  local movingRealistic = fastFlame and not show
  local f1, f2, f3, fb, fg = fx(61, fastFlame), fx(62, fastFlame), fx(63, fastFlame), fx(64, fastFlame), fx(65, fastFlame)
  local sz = movingRealistic and min(popFlameScale, 1.0) or popFlameScale
  -- REVIEW FIX 2026-07-29 (revised). Axial velocity for the flame layers. Video review showed the
  -- flame reading as a round BALL centred on the tip rather than a jet: the particles are 0.7-0.9 m
  -- across but were travelling only ~10-30 cm inside their lifetime, so the sprites piled up.
  --
  -- The RIGHT reference for these magnitudes is BeamNG's own afterfire emitter, which does the same job
  -- on the same node pair and the same exhaustEnds set - combustionEngineThermals.lua:410-418,
  -- `emitBigAfterFireParticles(n.finish, n.start, ...)`, which uses -15 / -10 / -20 / -8 / -12 for
  -- particle types 61-65. (fire.lua's ~2 is NOT the peer: different node pair, different axis, and
  -- body-fire particles that live 20x longer.) OE's per-layer offsets are -2.2 for the core, -6..-10
  -- for the body and -13..-17 for the tongue, all scaled by vk - so at the old vk of ~0.35-0.6 the
  -- effective magnitudes were -0.8..-10, i.e. WELL BELOW what the game itself uses for a stock bang.
  -- That headroom, not particle lifetime, is the correct lever: it works on the short _fast variants
  -- too, and it introduces none of the timing couplings that lengthening lifetimes did (the native
  -- afterfire suppression window, the jet-reach decay window, and the launch-family emission cadence
  -- are all sized in milliseconds and all silently broke when the lifetimes moved).
  --
  -- vk now runs ~0.72-1.11 (was ~0.35-0.6), putting the tongue at -12..-19 against the stock -20 for
  -- the same layer. Note this also re-activates the movingRealistic `min(vk, 0.72)` cap below, which
  -- was unreachable at the old scale and therefore doing nothing.
  local vk = clamp(0.72 + (sz - 0.35) * 0.30, 0.85, 1.18) * (vMul or 1) * (0.70 + heat * 0.30)
  if movingRealistic then
    vk = min(vk, 0.72)
  elseif fastFlame and show and sz > 1.25 then
    vk = vk * (1 + 0.22 * clamp((sz - 1.25) / 1.34, 0, 1))
  end
  smokeFx.flamePhase = (smokeFx.flamePhase + 1) % 12
  local phase = smokeFx.flamePhase
  local n = smokeFx.pickFlameNode(emitNodes, newEvent == true)
  if not n then return end
  -- REVIEW FIX 2026-07-27: same regression as the smoke path - this required the tip's NATIVE
  -- afterfire visual coefficient to be non-zero. That coefficient describes whether BeamNG draws its
  -- OWN backfire flash there, not whether OE's simulated 2-step / anti-lag / overrun flame may exist,
  -- and it is zeroed by any quiet muffler in the branch. It silently disabled OE flames on every
  -- D-Series and on a long list of stock petrol cars. The genuine restriction - only fire from a
  -- registered exhaust END node, never an under-hood fallback - is enforced by pickFlameNode and the
  -- finish/start check below, which is what actually prevents under-bonnet fireballs.
  if not (n.finish and n.start and not obj:inWater(n.finish)) then return end
  local emittedAny = false
  pcall(function()
    emittedAny = true
    local methanol = ft == 5
    local hotEnough = heat > 0.28
    local bodyFx, tongueFx = methanol and fb or f1, methanol and fb or f3
    local variant = smokeFx.flameVariant or 0
    if not methanol and ft ~= 0 and oepFxActive() then
      -- SIZE TIER first, so the variant override below still wins for the body. Variants 1-3 carry
      -- their own single size, but the TONGUE stays tiered on every event whichever variant rolled -
      -- which is what makes the flame read as a different SIZE rather than just a different texture.
      -- Tier 2 is the stock pair and resolves to exactly what fx(61)/fx(63) already returned, so
      -- this is a no-op in the middle band.
      local tier = smokeFx.sizeTier(sz)
      if tier ~= 2 then
        local col = fastFlame and 2 or 1
        bodyFx = smokeFx.bodyTier[tier][col]
        tongueFx = smokeFx.tongueTier[tier][col]
      end
      -- NOTE the id order: the FAST member is the HIGHER number in every pair
      -- (9619 fast / 9615 slow, 9621 fast / 9620 slow, 9623 fast / 9622 slow).
      if variant == 1 then
        bodyFx = fastFlame and 9619 or 9615
      elseif variant == 2 then
        bodyFx = fastFlame and 9621 or 9620
      elseif variant == 3 then
        bodyFx = fastFlame and 9623 or 9622
      end
    end
    -- A very small ignition kernel anchors the effect at the tip. Methanol
    -- skips the orange kernel and remains pale/blue.
    if not methanol and b < 0.90 and (newEvent or phase % 3 == 0) then
      obj:addParticleByNodesRelative(n.finish, n.start, -2.2 * vk, f2, 0, 1)
    end
    if b > 0.20 or methanol then
      obj:addParticleByNodesRelative(n.finish, n.start, -2.0 * vk, fb, 0, 1)
    end

    if kind == 1 then
      -- Overrun: loose, irregular licks which collapse instead of a solid jet.
      obj:addParticleByNodesRelative(n.finish, n.start, -(7 + phase % 3) * vk, bodyFx, 0, 1)
      if hotEnough and phase % 3 == 0 then obj:addParticleByNodesRelative(n.finish, n.start, -13 * vk, tongueFx, 0, 1) end
    elseif kind == 2 then
      -- Flat shift: exactly one sharp layered burst.
      obj:addParticleByNodesRelative(n.finish, n.start, -7 * vk, bodyFx, 0, 1)
      if hotEnough then obj:addParticleByNodesRelative(n.finish, n.start, -15 * vk, tongueFx, 0, 1) end
    elseif kind == 3 then
      -- Launch control: alternating rooted pulses, never every outlet at once.
      obj:addParticleByNodesRelative(n.finish, n.start, -((variant > 0 or phase % 2 == 0) and 9 or 13) * vk, (variant > 0 or phase % 2 == 0) and bodyFx or tongueFx, 0, 1)
    elseif kind == 4 then
      -- Rolling anti-lag: a slightly longer, less regular tongue.
      obj:addParticleByNodesRelative(n.finish, n.start, -(10 + phase % 4) * vk, variant == 0 and phase % 3 == 0 and tongueFx or bodyFx, 0, 1)
    elseif kind == 5 then
      -- Anti-lag: compact rapid crackles with rare individual embers.
      obj:addParticleByNodesRelative(n.finish, n.start, -(6 + phase % 4) * vk, (variant > 0 or phase % 2 == 0) and bodyFx or tongueFx, 0, 1)
      if heat > 0.72 and sz > 0.8 and random() < 0.16 then
        obj:addParticleByNodesRelative(n.finish, n.start, -10 * vk, 9614, 0, 1)
      end
    elseif kind == 6 then
      -- Limiter: one strong pulse per real hard-cut bounce.
      obj:addParticleByNodesRelative(n.finish, n.start, -8 * vk, bodyFx, 0, 1)
      if hotEnough then obj:addParticleByNodesRelative(n.finish, n.start, -16 * vk, tongueFx, 0, 1) end
    else
      -- Rotational idle: tiny lick, close to the outlet.
      obj:addParticleByNodesRelative(n.finish, n.start, -5 * vk, bodyFx, 0, 1)
    end

    -- Show / Coal adds a longer cooling fragment, not a duplicated white-hot
    -- core. That makes it bigger while keeping texture detail readable.
    if show and heat > 0.25 and kind ~= 7 then
      obj:addParticleByNodesRelative(n.finish, n.start, -(17 + phase % 4) * vk, tongueFx, 0, 1)
    end
    if newEvent and heat > 0.20 and phase % 2 == 0 then
      obj:addParticleByNodesRelative(n.finish, n.start, -4 * vk, fg, 0, 1)
    end
  end)
  if emittedAny then
    smokeFx.manualFlameThisFrame = true
    if newEvent then
      local duration = kind == 1 and popFlameHold or (kind == 7 and (smokeFx.rotEventDuration or 0) or 0)
      if not fromNetwork then smokeFx.publishEvent(kind, sz, b, duration) end
      smokeFx.pulseLight(n, b, sz * (0.45 + heat * 0.55))
    end
  end
end

function smokeFx.remoteIntervalForKind(kind)
  if kind == 1 then return 1 / 30 end
  if kind == 7 then return 1 / 24 end
  if kind == 5 then return 1 / 22 end
  return 1 / 18
end

function smokeFx.updateRemote(dt)
  local evs = electrics and electrics.values
  if not evs then return end
  local step = clamp(dt or 0, 0, 0.1)
  local seq = floor(tonumber(evs.oepFxSeq) or 0)
  if seq > 0 and seq ~= smokeFx.mpRecvSeq then
    smokeFx.mpRecvSeq = seq
    local receivedKind = floor(tonumber(evs.oepFxCode) or 0)
    if receivedKind < 1 or receivedKind > 7 then return end
    smokeFx.remoteKind = receivedKind
    smokeFx.remoteFuel = floor(clamp(tonumber(evs.oepFxFuel) or 1, 0, 5))
    smokeFx.remoteVariant = floor(clamp(tonumber(evs.oepFxVariant) or 0, 0, 3))
    smokeFx.flameVariant = smokeFx.remoteVariant
    smokeFx.remoteHeat = clamp(tonumber(evs.oepFxHeat) or 0.4, 0, 1)
    smokeFx.remoteShow = (tonumber(evs.oepFxShow) or 0) > 0.5
    smokeFx.remoteTimer = clamp(tonumber(evs.oepFxDuration) or 0, 0, 0.35)
    smokeFx.remoteInterval = smokeFx.remoteIntervalForKind(smokeFx.remoteKind)
    smokeFx.remoteClock = smokeFx.remoteInterval
    popFlameScale = clamp(tonumber(evs.oepFxStrength) or 1, 0.2, 2.5)
    emitPopFlame(smokeFx.remoteKind, tonumber(evs.oepFxBlue) or 0, 1, true, true)
  end
  if (smokeFx.remoteTimer or 0) > 0 then
    smokeFx.remoteTimer = max(0, smokeFx.remoteTimer - step)
    smokeFx.remoteClock = (smokeFx.remoteClock or 0) - step
    local emitted = 0
    while smokeFx.remoteTimer > 0 and smokeFx.remoteClock <= 0 and emitted < 2 do
      emitPopFlame(smokeFx.remoteKind, tonumber(evs.oepFxBlue) or 0, 1, false, true)
      smokeFx.remoteClock = smokeFx.remoteClock + max(smokeFx.remoteInterval or 0.04, 0.025)
      emitted = emitted + 1
    end
  end

  -- Diesel smoke is a slowly-changing continuous channel, quantized by the
  -- owner to ten steps. Remote clients reconstruct the puffs locally.
  if (tonumber(evs.oepFxDiesel) or 0) > 0.5 then
    local show = (tonumber(evs.oepFxShow) or 0) > 0.5
    local coal = clamp(tonumber(evs.oepFxSmoke) or 0, 0, 1)
    local cold = clamp(tonumber(evs.oepFxCold) or 0, 0, 1)
    local comp = clamp(tonumber(evs.oepFxComp) or 0, 0, 1)
    smokeFx.acc.coal = min(4, smokeFx.acc.coal + (show and 15 or 6.5) * coal * step)
    smokeFx.acc.fuel = min(3, smokeFx.acc.fuel + 1.3 * cold * step)
    smokeFx.acc.haze = min(3, smokeFx.acc.haze + 0.55 * comp * step)
    local nodes = smokeFx.emitNodes(true)
    if nodes then
      for _ = 1, 2 do
        local kindName = smokeFx.acc.coal >= 1 and "coal" or (smokeFx.acc.fuel >= 1 and "fuel" or (smokeFx.acc.haze >= 1 and "haze" or nil))
        if not kindName then break end
        local n = smokeFx.nextNode(nodes)
        if not n then break end
        smokeFx.acc[kindName] = smokeFx.acc[kindName] - 1
        local id = kindName == "coal" and (show and 9617 or 9607)
          or (kindName == "fuel" and (show and 9618 or 9608) or (show and 9616 or 9606))
        if n.finish and n.start and not obj:inWater(n.finish) then
          obj:addParticleByNodesRelative(n.finish, n.start, (model.rpm or 900) * -0.0004 - 3, id, 0, 1)
        end
      end
    end
  end
end

-- A smoke-only pop still contributes exactly one fuel-aware bang impulse. The
-- accumulator decides when that fractional amount becomes a visible particle.
local function emitPopSmoke(sz)
  smokeFx.queueBang(sz)
end

-- ---------------------------------------------------------------------------
-- ROTATIONAL IDLE (V2_3, community request): the drag/show-scene "rowdy idle" - rhythmic ignition
-- cuts at a STANDSTILL make the engine lope, surge and rock on its mounts, with barks and small
-- flames. Real rotational idle is ECU-made (cut patterns + heavy idle retard - NOT a cam), so it
-- belongs in a tuner and is TUNE-DRIVEN like the pops: retard (and richen) the IDLE cells and the
-- chop gets nastier. The lope itself emerges from physics: our torque cuts fight BeamNG's laggy idle
-- P-governor (idleThrottleTarget + smoother in combustionEngine.lua), which overshoots on release =
-- the surge, and the engine's reaction torque rocks the chassis. SOUND: the game queues ZERO native
-- afterfire fuel below 2x idle rpm (combustionEngine.lua ~810), so idle bangs can only be voiced by
-- the OE sample pool (skipped in pop-sound mode 2 = "Car's own only"); the chop RHYTHM is free -
-- the cuts modulate the engine's own audio, so sound-mod cars chop in their own voice.
local rotCutting = false
local rotGapTimer = 0
local rotCutTimer = 0
local rotFlameHold = 0
local function updateRotationalIdle(dt)
  model.rotIdleActive = false
  if settings.rotIdleEnabled ~= true or model.blown then
    model.rotIdleStatus = "Off"
    model.rotIdleStrength = 0
    rotCutting = false
    rotFlameHold = 0
    smokeFx.rotFlameTimer = 0
    return
  end
  -- chop character from the IDLE CELLS of the tune (idle rpm, vacuum row): heavy ignition retard is
  -- the main ingredient (that IS rotational idle), idle richness fattens the barks
  local lowMap = mapAxis[1] or 40
  local idleRpmTgt = clamp(settings.rotIdleRpm or 1300, 900, 2000)
  local iLambda = clamp(lookupMap("lambda", idleRpmTgt, lowMap, 0), 0.68, 1.10)
  local iIgn = lookupMap("ignition", idleRpmTgt, lowMap, 0)
  local rich = clamp((1.0 - iLambda) / 0.28, 0, 1)
  local retard = clamp(-iIgn / 14, 0, 1)
  local strength = clamp(retard * 0.9 + rich * 0.45, 0, 1.2)
  model.rotIdleStrength = strength
  local speedKmh = (ev("wheelspeed", 0) or 0) * 3.6
  local rpm = model.rpm or 0
  if strength < 0.1 then
    model.rotIdleStatus = "Tune it - retard (and richen) the idle cells"
    rotCutting = false
    return
  elseif speedKmh >= 5 then
    model.rotIdleStatus = "Armed - come to a stop"
    rotCutting = false
    return
  elseif (model.tps or 0) >= 4 then
    model.rotIdleStatus = "Armed - release the throttle"
    rotCutting = false
    return
  elseif simTime < 3 or rpm < 300 then
    model.rotIdleStatus = "Armed"
    rotCutting = false
    return
  end
  model.rotIdleActive = true
  model.rotIdleStatus = "CHOPPING " .. floor(strength * 100 + 0.5) .. "%"
  model.idleStatus = "Rotational idle"

  -- own the idle while chopping: real rotational tunes idle high (1100-1500) so the lope has room to
  -- fall through. The normal idle logic takes it back the moment we deactivate (it re-writes each frame).
  if engine.idleRPM and abs(engine.idleRPM - idleRpmTgt) > 1 then
    engine.idleRPM = idleRpmTgt
    -- V3.0 clutch fix (same as the main idle path): rot idle sits high (1100-1500), so writing it into
    -- engine.idleAV raised BeamNG's auto-clutch anti-stall floor and slipped the clutch on launch.
    -- Raise the governor via idleAVOverwrite; leave idleAV (the clutch floor) at stock.
    if engine.idleAVOverwrite ~= nil then
      if idleRpmTgt > (base.idleRPM or idleRpmTgt) then
        engine.idleAVOverwrite = idleRpmTgt * rpmToAV
        if engine.idleAV then engine.idleAV = (base.idleRPM or idleRpmTgt) * rpmToAV end
      else
        engine.idleAVOverwrite = 0
        if engine.idleAV then engine.idleAV = idleRpmTgt * rpmToAV end
      end
    elseif engine.idleAV then
      engine.idleAV = idleRpmTgt * rpmToAV
    end
  end

  -- THE CHOP: torque cuts with jittered gaps; a hotter tune chops tighter. Anti-stall twice over:
  -- never START a cut with rpm already fallen, and RELEASE an active cut if rpm drops too far.
  local chop = clamp(settings.rotIdleChop or 6, 1, 10)
  if rotCutting then
    rotCutTimer = rotCutTimer - dt
    if rotCutTimer <= 0 or rpm < idleRpmTgt * 0.5 then
      rotCutting = false
      rotGapTimer = (0.13 + random() * 0.32) * (1.15 - strength * 0.5)
    end
  else
    rotGapTimer = rotGapTimer - dt
    if rotGapTimer <= 0 and rpm > idleRpmTgt * 0.62 then
      rotCutting = true
      rotCutTimer = 0.05 + random() * 0.06 + chop * 0.004
      -- BARKS ARE RARE, like the real thing (Orlando's feedback vs real rotational-idle clips): the
      -- LOPE is constant but an audible pop only cracks off every few seconds, and only on HOT tunes.
      -- Barks need strength past 0.55 (deep idle retard) and stay occasional even maxed out; fire only
      -- ever rides a bark. Native afterfire can't voice idle bangs (see above), so the OE pool is the
      -- voice - skipped in "Car's own only" pop-sound mode (the visual bark still happens).
      local barked = false
      if strength > 0.55 and random() < (strength - 0.55) * 0.45 then
        barked = true
        if floor(settings.popSoundMode or 1) ~= 2 then
          firePop((0.3 + strength * 0.35) * clamp((settings.overrunPopVol or 6) / 6, 0, 2), 0.75 + random() * 0.3)
        end
      end
      popFlameScale = 0.35 + random() * random() * (0.4 + strength * 0.9)
      if barked and random() < 0.5 then
        rotFlameHold = 0.05 + strength * 0.05   -- small flame lick riding the bark
        smokeFx.rotEventDuration = rotFlameHold
        emitPopFlame(7, nil, 0.8, true)
        smokeFx.rotFlameTimer = smokeFx.flameInterval
        smokeFx.queueBang(popFlameScale)
      elseif barked then
        rotFlameHold = 0
        smokeFx.rotFlameTimer = 0
        emitPopSmoke(popFlameScale)             -- bark without fire: just the smoke slug
      else
        rotFlameHold = 0
        smokeFx.rotFlameTimer = 0
        if random() < 0.12 then emitPopSmoke(popFlameScale * 0.7) end   -- quiet chop: rare faint puff
      end
    end
  end
  if rotCutting then
    -- deeper Chop depth = harder cut = rowdier (and stall-riskier on weak engines)
    model.coef = min(model.coef, clamp(0.32 - chop * 0.027, 0.05, 0.35))
  end
  if rotFlameHold > 0 then
    rotFlameHold = max(0, rotFlameHold - dt)
    smokeFx.rotFlameTimer = smokeFx.rotFlameTimer - dt
    local emitted = 0
    while rotFlameHold > 0 and smokeFx.rotFlameTimer <= 0 and emitted < 4 do
      emitPopFlame(7, nil, 0.72, false)
      smokeFx.rotFlameTimer = smokeFx.rotFlameTimer + smokeFx.flameInterval
      emitted = emitted + 1
    end
    if emitted >= 4 and smokeFx.rotFlameTimer < 0 then smokeFx.rotFlameTimer = 0 end
  else
    smokeFx.rotFlameTimer = 0
  end
end

local function updateOverrunPops(dt)
  -- tickPopPool() now runs UNCONDITIONALLY in updateGFX (every frame), not here inside the doControl
  -- gate. See the V2.9.x stuck-pop note there: a pop is a looping source played as a one-shot, so its
  -- stopper must keep running even after the connect gate closes, or nothing stops the loop.
  applyNativePopAudio()   -- enforce the Pop sound selector on the car's native afterfire audio (cheap: rewrites only on change)
  -- V2.9.7 hard-cut voice (drag beta "flames but no noise"): applyLimiter queues one request per
  -- bounce. Skipped in Pop sound mode 2 (Car's own only - the real ignition cut now makes native
  -- afterfire bang deterministically there); mode 3 (OE samples only) NEEDS this voice, since
  -- applyNativePopAudio mutes the native nodes and nothing else voices hard cut.
  local hardCutBang = H.hardCutPopQueued and true or false
  if hardCutBang then
    H.hardCutPopQueued = nil
    if floor(settings.popSoundMode or 1) ~= 2 then
      firePop(clamp(0.55 + 0.3 * clamp(settings.popsLevel or 1, 0, 3), 0.4, 1.5)
        * clamp((settings.overrunPopVol or 6) / 6, 0, 2), 0.88 + random() * 0.24)
    end
  end
  -- POP STRENGTH from the OVERRUN CELL tune directly (the lowest-MAP = vacuum row, at the current rpm),
  -- so the meter climbs as you TUNE - even parked. rich = how far the overrun lambda TARGET is below
  -- stoich (only counts if decel fuel cut is OFF, so raw fuel actually reaches the exhaust); retard =
  -- ignition pulled in that cell. A dialled overrun tune bangs hard; stock decel stays quiet. The bangs
  -- FIRE when you're off-throttle, above the Min RPM, warm - the live status below says exactly why.
  local lowMap = mapAxis[1] or 40
  local fuelFlows = settings.decelFuelCut ~= true
  local ovLambda = clamp(lookupMap("lambda", model.rpm, lowMap, 0), 0.68, 1.10)
  local ovIgn = lookupMap("ignition", model.rpm, lowMap, 0)
  local rich = fuelFlows and clamp((1.0 - ovLambda) / 0.28, 0, 1) or 0
  local retard = clamp(-ovIgn / 14, 0, 1)
  local strength = clamp(rich * 0.85 + retard * 0.4, 0, 1.3)
  model.overrunPopStrength = strength
  -- FLAME COLOUR from the tune (V2_3 Tier 0): a RICH overrun burns sooty ORANGE; leaning it back out
  -- while keeping heavy ignition RETARD burns hotter in the pipe = BLUE crackle. So the colour is one
  -- more thing you tune: lambda toward 0.80 in the vacuum rows = orange fireballs, lambda near 1.0
  -- with big retard = blue - exactly how real flames colour with mixture.
  popFlameBlue = clamp(retard * 1.1 - rich * 0.9, 0, 1)

  -- live status so you can SEE why it is / isn't popping
  local rpm = model.rpm or 0
  local busy = model.rollingActive or model.launchActive or model.flatShiftActive or model.antilagActive
  -- NA cars pop too (V2_3): overrun pops are fuel + timing driven, not a turbo feature - the old
  -- "N/A (naturally aspirated)" gate here was artificial. Any engine with a rich, retarded overrun
  -- tune bangs on the lift.
  local st
  if model.blown or busy then st = "Off"
  elseif settings.decelFuelCut == true then st = "Decel fuel cut is ON - turn it off (Transient Fuel)"
  elseif strength < 0.05 then st = "Tune it - richen + retard the top (vacuum) rows"
  elseif (model.ectC or 80) <= 45 then st = "Engine cold - warm it up first"
  elseif (model.tps or 0) >= (settings.overrunThrottle or 8) then st = "Ready - lift off the throttle to pop"
  elseif rpm < (settings.overrunMinRpm or 2800) or rpm <= (base.idleRPM or 800) + 400 then st = "Ready - rev past the Min RPM, then lift"
  else st = "POPPING " .. floor(strength * 100 + 0.5) .. "%" end
  model.overrunStatus = st

  -- Each cut mode has its own visual signature. Flat shift is one sharp
  -- leading-edge burst; hard cut is one pulse per limiter bounce; launch,
  -- rolling anti-lag and ALS have distinct cadences.
  local plLaunch = clamp(settings.popsLevel or 1, 0, 3)
  if plLaunch > 0 and hardCutBang then
    popFlameScale = 0.7 + random() * random() * 1.0
    emitPopFlame(6, 0.32, 0.95, true)
    smokeFx.queueBang(popFlameScale)
  end
  if plLaunch > 0 and model.flatShiftActive and not smokeFx.prevFlatShift then
    popFlameScale = 0.65 + random() * random() * 0.9
    emitPopFlame(2, 0.28, 1.0, true)
    smokeFx.queueBang(popFlameScale)
  end
  smokeFx.prevFlatShift = model.flatShiftActive and true or false

  local launchKind = model.antilagActive and 5 or (model.rollingActive and 4 or (model.launchActive and 3 or 0))
  if plLaunch > 0 and launchKind > 0 then
    launchFlameTimer = launchFlameTimer - dt
    if launchFlameTimer <= 0 then
      popFlameScale = (launchKind == 5 and 0.42 or 0.55) + random() * random() * (launchKind == 5 and 0.85 or 1.05)
      local flowK = 0.8 + 0.35 * clamp((model.rpm or 0) / (base.revLimiterRPM or 7000), 0, 1)
      emitPopFlame(launchKind, launchKind == 5 and 0.48 or 0.34, flowK, true)
      smokeFx.queueBang(popFlameScale)
      launchFlameTimer = launchKind == 5 and (0.045 + random() * 0.055)
        or (launchKind == 4 and (0.085 + random() * 0.075) or (0.075 + random() * 0.065))
    end
  else
    launchFlameTimer = 0
  end

  if st:sub(1, 7) ~= "POPPING" then
    popArmed = false   -- back on the throttle: re-arm the after-lift silence so the NEXT lift starts with a beat, not mid-stream
    popTimer = 0
    popFlameHold = 0   -- don't let a leftover flame jet leak into the next lift's beat of silence
    smokeFx.popFlameTimer = 0
    model.overrunActive = false   -- stops the tune-driven overrun haze in updateExhaustSmoke
    return
  end
  model.overrunActive = true      -- lets updateExhaustSmoke roll a continuous smoke cloud while we pop

  local pl = clamp(settings.popsLevel or 1, 0, 3)

  -- FLAMES kick WITH each pop, then SUSTAIN for a short jet so they're genuinely visible (a single-pulse
  -- emit was barely a spark). popFlameHold is set on each bang and bled down here; while it is alive a
  -- fixed-rate clock emits at 30 Hz, so visual density no longer changes with frame rate. Also drives
  -- the native afterfire-audio crackle envelope (popFlame).
  popFlame = max(0, popFlame - dt * 3)
  if engine then
    local af = (0.25 + popFlame * (1.5 + strength * 2.5)) * pl
    engine.instantAfterFireCoef = max(base.instantAfterFireCoef or 0, 1.0) * af
    engine.sustainedAfterFireCoef = max(base.sustainedAfterFireCoef or 0, 1.0) * af
  end
  if popFlameHold > 0 then
    popFlameHold = max(0, popFlameHold - dt)
    smokeFx.popFlameTimer = smokeFx.popFlameTimer - dt
    local emitted = 0
    while popFlameHold > 0 and smokeFx.popFlameTimer <= 0 and emitted < 4 do
      -- the jet DECAYS: full reach on the bang, contracting to ~60% by the end - a fireball that
      -- licks out and collapses instead of a constant-length blowtorch
      local prog = (popFlameHold0 > 0) and clamp(popFlameHold / popFlameHold0, 0, 1) or 1
      emitPopFlame(1, nil, 0.6 + 0.4 * prog, false)
      smokeFx.popFlameTimer = smokeFx.popFlameTimer + smokeFx.flameInterval
      emitted = emitted + 1
    end
    if emitted >= 4 and smokeFx.popFlameTimer < 0 then smokeFx.popFlameTimer = 0 end
  else
    smokeFx.popFlameTimer = 0
  end

  -- REALISTIC TIMING: after you lift there's a ~half-second beat of silence (the exhaust loading with raw
  -- fuel), THEN pops fire on an EXPONENTIAL random schedule - mostly short gaps that naturally CLUSTER,
  -- with the occasional longer pause, so it sounds like real overrun ("here and there") instead of a
  -- robotic machine-gun. A richer / more-retarded tune (strength) shrinks the mean gap = more pops.
  if not popArmed then
    popArmed = true
    popTimer = 0.4 + random() * 0.35   -- the beat of silence after the lift, before the first pop
  end
  popTimer = popTimer - dt
  if popTimer <= 0 then
    -- Pop sound selector: mode 2 = "Car's own only" - no OE one-shot; the flame + native afterfire
    -- envelope below still fire on this schedule, voiced by the car's own (boosted) samples
    if floor(settings.popSoundMode or 1) ~= 2 then
      firePop(clamp(0.4 + strength, 0.4, 1.5) * clamp((settings.overrunPopVol or 6) / 6, 0, 2), 0.85 + random() * 0.22)
    end
    popFlame = 1                                  -- ride the native afterfire-audio crackle envelope
    -- roll THIS pop's size. random()*random() biases small, so most pops are little crackles. The
    -- SPREAD scales with the tune (strength = rich+retard on the overrun): a mild tune can only make small
    -- flames, while a hard-tuned overrun dumps more raw fuel so the big fireballs get bigger AND more
    -- frequent. So you EARN the huge flames by tuning. Range ~0.4 (tiny) up to ~0.4+0.5+strength*1.3.
    popFlameScale = 0.4 + random() * random() * (0.5 + strength * 1.3)
    -- FIRE OR SMOKE-ONLY (V2_3, Orlando): real overrun crackle doesn't flame on every bang - most pops
    -- are just a bark and a puff of smoke; the visible fireball is the occasional richer bang. Roll each
    -- pop: a mild tune mostly smokes, a hard (rich + retarded) tune flames more often - but even a maxed
    -- tune keeps ~1 in 8 pops smoke-only so the stream reads natural instead of a constant flamethrower.
    -- V2.6.x: the old 30% FLOOR meant even a zero-strength tune threw fire on a third of pops -
    -- part of the tester's "10-foot flames or nothing at all". Fire is EARNED by the tune now:
    -- untuned overrun flickers rarely (6%), a hot tune flames on most bangs (caps 85%).
    local fireChance = clamp(0.06 + strength * 0.62, 0.06, 0.85)
    if random() < fireChance then
      popFlameHold = (0.055 + strength * 0.055 + pl * 0.01) * (0.55 + min(popFlameScale, 1.5) * 0.45)
      popFlameHold0 = popFlameHold                -- remember the jet's full length so the sustain loop can decay the reach
      emitPopFlame(1, nil, 1, true)               -- first flicker lands with the bang
      smokeFx.popFlameTimer = smokeFx.flameInterval
      smokeFx.queueBang(popFlameScale)
    else
      popFlameHold = 0                            -- no flame jet on this one
      popFlameHold0 = 0
      smokeFx.popFlameTimer = 0
      emitPopSmoke(popFlameScale)                 -- just the bark + a puff of smoke out the pipe
    end
    -- exponential gap: gap = base - log(rand)*mean. log(rand) is negative so this adds a positive,
    -- fat-tailed delay (lots of short gaps, a few long ones). Capped so it never goes fully quiet.
    local mean = clamp(0.5 - strength * 0.28, 0.16, 0.5)
    popTimer = clamp(0.05 - log(max(1e-4, random())) * mean, 0.05, 1.4)
  end
end

function R.resetMapBankEffects()
  -- A bank may change pop voicing and rotational-idle behavior. Drop any old one-shot/tail and chop
  -- phase now; updateOverrunPops/updateRotationalIdle re-apply the newly selected behavior this frame.
  stopAllPops()
  restoreNativePopAudio()
  rotCutting, rotGapTimer, rotCutTimer, rotFlameHold = false, 0, 0, 0
  smokeFx.rotFlameTimer = 0
  model.rotIdleActive = false
end

-- hand every output back to the stock engine (called on the falling edge of Connect)
local function releaseOutputs()
  smokeFx.reset()
  H.releaseCompressionBrake()
  H.releaseTorqueLimit()
  H.releaseShiftFirmness()
  H.releaseClutchPackage()
  H.releaseConverterKFactor()
  H.releaseConverterLockup()
  if engine then
    if engine.resetTempRevLimiter then pcall(function() engine:resetTempRevLimiter() end) end
    -- hand back the stock hard limiter if we raised it
    if base.canRaiseLimiter and base.stockRevLimiterAV then engine.revLimiterAV = base.stockRevLimiterAV end
    if engine.idleRPM and base.idleRPM then
      engine.idleRPM = base.idleRPM
      if engine.idleAV then engine.idleAV = base.idleRPM * rpmToAV end
      if engine.idleAVOverwrite ~= nil then engine.idleAVOverwrite = 0 end   -- V3.0: drop our idle-up governor override on release
    end
    engine.instantAfterFireCoef = base.instantAfterFireCoef or 0
    engine.sustainedAfterFireCoef = base.sustainedAfterFireCoef or 0
  end
  if base.hasTurbo and turbo then
    if turbo.setWastegateOffset then pcall(function() turbo.setWastegateOffset(0) end) end
    if turbo.setAntilagCoef then pcall(function() turbo.setAntilagCoef(0) end) end
  end
  model.coef = 1
  model.wgOffset = 0
  model.torqueLimitStatus = "Off"
  H.updateClutchTelemetry()
  model.compBrakeStatus = base.hasCompBrake and "Stock control" or "Not fitted"
  if H.converter and type(base.stockKFactorCoef) == "number" then
    model.converterStall = H.converterStallForCoef(base.stockKFactorCoef, 2000)
  end
  H.releaseEthrottle()   -- hand the DBW plate back too (never leave a stale throttle override)
  H.resetBoostControlMemory()
  lastAppliedLimiterRPM = base.revLimiterRPM
  lastWastegateOffset = nil
  stopLaunchSound()   -- silence the 2-step bark on disconnect (no stale sound)
  stopAllPops()       -- silence any overrun pops too
  restoreNativePopAudio()   -- hand the exhaust's own afterfire volume/muffling back (Pop sound selector)
  rotCutting = false; rotGapTimer = 0; rotCutTimer = 0; rotFlameHold = 0   -- drop any rotational-idle chop mid-cut
  smokeFx.rotFlameTimer = 0
  hardCutOn = false; H.hardCutPopQueued = nil   -- V2.9.7 wave-2 review: telemetry reads the latch directly - a mid-bounce disconnect must not report hardCutActive all session
  model.rotIdleActive = false
  electrics.values.oep_shiftlight = 0
  electrics.values.oep_checkengine = 0
  electrics.values.oep_fuelpump = 0
end

-- passive monitor: read the real sensor data so the gauges stay live, but apply
-- nothing to the engine and don't accumulate any consequences (knock / damage).
local function updateMonitor(dt)
  local rpm, tps, load, boostKpa, mapKpa, baroKpa = readInputs(dt)
  model.rawTps, model.rawMapKpa = tps, mapKpa
  local sensedTps = clamp(tps * (settings.sensorTpsScale or 1) + (settings.sensorTpsOffset or 0), 0, 100)
  local sensedMap = clamp(mapKpa * (settings.sensorMapScale or 1) + (settings.sensorMapOffset or 0), 20, mapMeta.boost.max)   -- V3.0: mirrors the updateModel sensedMap clamp (updateMonitor duplicates the preview math on purpose)
  model.rpm, model.tps, model.load = rpm, sensedTps, load
  model.mapKpa, model.boostKpa = sensedMap, boostKpa
  model.baroKpa = baroKpa
  model.mapGaugePsi = (sensedMap - baroKpa) * KPA_TO_PSI
  model.vacuumInHg = max(0, baroKpa - sensedMap) * 0.295299875
  learnBoost(mapKpa, dt)   -- raw MAP, same as the controlling path
  updateThermal(dt, rpm, sensedTps, boostKpa, load)

  -- show the table targets so the user can preview what WOULD be commanded
  model.lambdaTarget = clamp(lookupMap("lambda", rpm, sensedMap, sensedTps), 0.68, 1.10)
  model.lambdaActual, model.lambdaTrue = model.lambdaTarget, model.lambdaTarget
  model.lambdaUncorrected, model.lambdaBaseTrue, model.adaptiveFuelTrim = model.lambdaTarget, model.lambdaTarget, 0
  model.boostTargetKpa = base.hasTurbo and lookupMap("boost", rpm, sensedMap) or 0
  if boostTableFactory and base.hasTurbocharger and (settings.boostMode or 2) ~= 1 and base.wgSpringTargetPSI then
    model.boostTargetKpa = baroKpa + base.wgSpringTargetPSI * PSI_TO_KPA
  end
  model.boostTargetKpa = H.shapeBoostTarget(dt, model.boostTargetKpa)
  if base.hasTurbocharger then
    local previewDutyOffset = maps.wgduty and (clamp(lookupMap("wgduty", rpm, model.boostTargetKpa), 0, 100) - 50) or 0
    local previewElectricGate = floor(settings.wgType or 1) == 2
    model.boostBaseWastegateDuty = clamp((settings.boostBaseDuty or 45) + previewDutyOffset, 0, 100)
    model.boostFeedforwardDuty = clamp(model.boostBaseWastegateDuty * (1 + clamp(model.boostDutyTrimPct or 0, -100, 100) / 100), 0, 100)
    model.boostHardwareFloorKpa = baroKpa + (previewElectricGate and 0 or max(0, base.wgSpringStartPSI or base.wgSpringTargetPSI or 0) * PSI_TO_KPA)
    model.boostTargetBelowSpring = not previewElectricGate and model.boostTargetKpa < model.boostHardwareFloorKpa - 0.5
  else
    model.boostBaseWastegateDuty, model.boostFeedforwardDuty = 0, 0
    model.boostHardwareFloorKpa, model.boostTargetBelowSpring = baroKpa, false
  end
  model.boostPidDutyTrim = 0

  -- diesel preview (#4): show live AFR / smoke while monitoring (no consequences), like the lambda preview
  model.isDiesel = effDiesel()
  if model.isDiesel then
    local fvm = lookupMap("fuel", rpm, sensedMap)
    local dfv = clamp((fvm > 0.4) and fvm or 1, 0.5, 2.0)
    local fuelInj = clamp(sensedTps / 100, 0, 1) * dfv * (clamp(settings.dieselMaxFuel or 100, 60, 260) / 100)
    -- REVIEW FIX 2026-08-04. This preview is what the player sees while DISCONNECTED, which is the
    -- app's default state and the state the diesel lessons are read in. It duplicates the controlling
    -- model on purpose, and A1/A3 were applied to the other copy only - so the slider still moved the
    -- smoke readout here, falsifying the new lesson line "that number is combustion chemistry, not a
    -- setting" in about two seconds. It also fed the new CSV Smoke/AFR columns, and pressing Connect
    -- made the reading jump. Mirrors the controlling path exactly now, including which air model
    -- applies (A3: corrected air follows EITHER V3.0.4 switch) and the fixed 17:1 soot point (A1).
    local corrected = (settings.dieselNozzleEnabled == true) or (settings.dieselSmokeLimiter == true)
    local air
    if corrected then
      air = clamp(sensedMap / 100, 0.25, 4.5)
    else
      local railFactor = clamp(0.85 + clamp(settings.dieselRailPressure or 1200, 300, 2200) / 6000, 0.7, 1.35)
      air = clamp((sensedMap / 100) * railFactor, 0.25, 4.5)
    end
    if settings.dieselSmokeLimiter == true then
      fuelInj = min(fuelInj, 17 * air / clamp(settings.dieselSmokeLimit or 17, 12, 24))
    end
    local atom = corrected and clamp(0.88 + clamp(settings.dieselRailPressure or 1200, 300, 2200) / 9000, 0.85, 1.15) or 1
    model.afr = clamp(17 * air / max(0.05, fuelInj), 8, 99)
    model.smoke = clamp((17 - model.afr) / 17 * 130 / atom, 0, 100)
    model.dieselStatus = "Monitoring"
  else
    model.smoke = 0
  end

  -- real engine output straight from BeamNG (the car is untouched)
  local baseTq = engine and (engine.outputTorque1 or 0) or 0
  model.engineTorqueNm = max(0, baseTq)
  H.updateClutchTelemetry()
  local drivetrainLoss = clamp(settings.drivetrainLoss or 15, 0, 35) / 100
  model.wheelTorqueNm = model.engineTorqueNm * (1 - drivetrainLoss)
  model.enginePowerHp = model.engineTorqueNm * 0.73756 * rpm / 5252
  model.wheelPowerHp = model.enginePowerHp * (1 - drivetrainLoss)
  model.torqueDelta = 0
  model.ignitionAdvance = (settings.baseTiming or 18) - load * 8

  -- control / consequence channels are inert while disconnected
  model.coef = 1
  model.knockLevel = 0
  model.knockRetard = approach(model.knockRetard, 0, 1, dt)
  model.misfireStatus, model.misfireSeverity, model.misfireTimer, model.misfireCut = "OK", 0, 0, 1
  model.damageRate, model.damageReason = 0, "OK"
  setEngineDamageFlag(model.damage > 0.03)   -- keep the icon honest while only monitoring
  model.stft, model.ltft, model.closedLoop, model.cllStatus = 0, 0, false, "Not connected"
  model.accelActive, model.decelCut, model.transientTrim, model.startupTrim = false, false, 0, 0
  model.injectorDuty = 0
  model.injFactor = 1
  model.fuelPressureKpa, model.fuelDiffPressureKpa = 300, 300   -- passive: nominal rail/differential
  model.boostError, model.wastegateDuty = 0, 0
  model.boostStatus = base.hasTurbo and "Monitoring" or "N/A (naturally aspirated)"
  model.protectionStatus = "Not connected"
  model.limiterStatus = "Not connected"
  model.limiterActive = false
  model.launchActive, model.flatShiftActive, model.rollingActive = false, false, false   -- clear holds when disconnected (no stale bark/flames)
  -- V2.9 fix: hardCutActive is owned by applyLimiter (controlling-only) - disconnecting mid hard-cut
  -- bounce left it latched, so telemetry reported launchFuelActive=true for the whole monitoring
  -- session. The fuel-display channels below likewise froze at their last controlling values.
  model.hardCutActive = false
  model.effFuelTrim, model.injPulseMs = 0, 0
  model.veTableCell, model.veTrueCell = nil, nil
  rollingWasActive = false; rollingHoldRpm = nil   -- drop the rolling-2-step hold so it re-captures fresh on the next press
  model.idleStatus = "Not connected"
  model.rotIdleStatus = "Not connected"
  model.rotIdleActive = false
  model.antilagStatus = base.hasTurbocharger and "Not connected" or (base.isSupercharger and "N/A (supercharger - no turbine)" or "N/A (NA)")
  model.vvtStatus = "Not connected"
  model.ethrottleStatus, model.ethrottleResp, model.ethrottleFault = "Cable", 1, false
  model.faults, model.faultCount, model.limpMode = {}, 0, false
  model.boostMaxed, model.boostFloored = false, false
  model.auxCheckEngineOn = false
  model.auxShiftLightOn = rpm >= (settings.auxShiftLight or 6800)
  model.auxFuelPumpOn = rpm > 300
  electrics.values.oep_shiftlight = 0
  electrics.values.oep_checkengine = 0
  electrics.values.oep_fuelpump = 0
end

local function updateGFX(dt)
  if not active or not engine then return end
  simTime = simTime + dt
  if H.isBeamMpRemote() then
    -- Remote BeamMP vehicles are visual replicas. Replaying the owner's
    -- electrics packet here prevents duplicate locally-derived flames and,
    -- critically, never writes remote engine control outputs.
    tickPopPool()
    updateMonitor(dt) -- supplies remote speed/RPM so moving particles stay attached
    smokeFx.updateRemote(dt)
    smokeFx.updateNativeFlameSuppression(dt, false)
    return
  end
  -- Bank requests are resolved before this frame reads a single calibration value. While controlling,
  -- an under-load request stays pending until the driver lifts; monitoring/disconnected switches commit
  -- on this next frame immediately.
  H.processPendingMapBank()

  -- V2.9.x stuck-pop fix: ride any in-flight overrun pop to its natural end EVERY frame, OUTSIDE the
  -- doControl gate below. Pops are looping sources played as one-shots; when tickPopPool lived only
  -- inside updateOverrunPops (doControl-only), a pop firing as the connect gate closed - or during a
  -- pause, or on a frame that errored before updateOverrunPops - had no stopper left and the loop
  -- repeated forever, even engine-off. The 2-step bark + knock loops are already ticked every frame
  -- further down for exactly this reason; the pop pool now matches them. (Internally pcall-guarded.)
  tickPopPool()

  -- Connect gate: only touch the engine while the user is connected (and the
  -- master ECU enable is on). Otherwise just monitor, so two tuners never fight.
  local doControl = controlling and settings.enabled ~= false
  if doControl then
    if not wasControlling then
      -- just (re)connected: force a fresh apply of the limiter + wastegate
      lastAppliedLimiterRPM = -1
      lastWastegateOffset = nil
      -- The disconnected preview advances its own displayed target. On takeover, seed the real
      -- controller ramp from physical MAP instead so connecting under load cannot bypass rise rate.
      H.resetBoostControlMemory(max(model.mapKpa or 0, model.baroKpa or 101.325))
    end
    updateModel(dt)
    smokeFx.updateEnvironment(dt)
    applyLimiter(dt)
    updateOverrunPops(dt)   -- automatic pops/crackles on throttle lift (tuned by the player)
    updateRotationalIdle(dt)   -- drag-scene rowdy idle: rhythmic cuts at standstill (tuned by the player)
    updateExhaustSmoke(dt)  -- drain fuel-aware smoke after this frame's bang impulses are queued
  else
    if wasControlling then releaseOutputs() end
    updateMonitor(dt)
    if electrics and electrics.values then
      electrics.values.oepFxDiesel, electrics.values.oepFxSmoke = 0, 0
      electrics.values.oepFxCold, electrics.values.oepFxComp = 0, 0
    end
  end
  wasControlling = doControl
  -- Runs after this frame's OE emission decisions but before BeamNG's
  -- powertrain.updateGFX, which is where native afterfire particles are made.
  smokeFx.updateNativeFlameSuppression(dt, doControl)

  updateLaunchSound(dt)   -- 2-step / rolling bark via the vehicle sound engine (reliable + heard in BeamMP)
  updateKnockAudio(dt, model.rpm or 0)   -- V3.0: detonation ticking + rod knock (audio only)
  maybeAutoFitAxes(dt)    -- V3.0: keep the table axes fitted to boost target + rev limit (debounced, grow-only)
  updateDyno(dt)

  telemetryTimer = telemetryTimer + dt
  if telemetryTimer >= 0.05 then
    telemetryTimer = 0
    publishTelemetry()
  end
  if remoteDash then   -- Remote Dash: 10 Hz is plenty for gauges and half the UI's own rate
    remoteTimer = remoteTimer + dt
    if remoteTimer >= 0.1 then
      remoteTimer = 0
      pushRemoteTelemetry()
    end
  end
  if stateDirty then publishState() end
end

local function reset()
  -- BeamNG recovery repairs/repositions the current vehicle; it is not a tune-load command. Keep the
  -- exact active MAP association and live working draft (including its dirty flag). A switch queued
  -- before recovery is cancelled so the reset itself cannot unexpectedly change MAP on the first
  -- safe frame; the driver can request it again after recovery.
  if H.clearPendingMapBank() then H.mapBankSequence = H.mapBankSequence + 1 end
  -- BeamNG device reset functions do not restore these static calibration fields. Hand them back
  -- before the next connected frame re-applies the active tune.
  H.releaseCompressionBrake()
  H.releaseTorqueLimit()
  H.releaseShiftFirmness()
  H.releaseClutchPackage()
  H.releaseConverterKFactor()
  H.releaseConverterLockup()
  lastAppliedLimiterRPM = -1
  hardCutOn = false
  -- V3.0.9: a vehicle reset repairs the engine, so the floated head is fresh again. Cleared here
  -- alongside the other per-life state - without this, accumulated float wear would follow the car
  -- through a reset and quietly rob power on a "repaired" engine.
  model.floatSeverity = 0
  model.floatPowerCut = 1
  model.floatWear = 0
  model.floatStatus = "OK"
  -- a stale roll timer would satisfy the launch-release test on the first frame after a respawn,
  -- killing the 2-step before the car has moved
  H.launchRollTimer = 0
  lastWastegateOffset = nil
  wasControlling = false   -- re-apply cleanly next frame if still connected
  R.releaseMomentaryInputs()   -- same clears as the old inline rollingHold/rollingWasActive/rollingHoldRpm lines
  boostLearnTimer = 0   -- drop any half-built boost-learn streak (the learned reference itself is kept)
  model.wgOffset = 0
  H.resetBoostControlMemory()
  simTime = 0
  model.damage = 0
  model.knockCount = 0
  model.knockRetard = 0
  model.blown = false
  model.damagePower = 100
  H.releaseEthrottle()
  H.ethNoActuator = false   -- a reset can follow a repair/part swap - re-probe the DBW actuator
  smokeFx.reset()
  smokeFx.sootLoad = 0      -- recovery also acts as the safe universal "clean exhaust" action
  clearFaults()
  resetFuelLearningState("Vehicle reset - relearning")
  if base.hasTurbo and turbo and turbo.setWastegateOffset then pcall(function() turbo.setWastegateOffset(0) end) end
  if base.hasTurbo and turbo and turbo.setAntilagCoef then pcall(function() turbo.setAntilagCoef(0) end) end
  if engine and engine.idleRPM and base.idleRPM then
    engine.idleRPM = base.idleRPM
    if engine.idleAV then engine.idleAV = base.idleRPM * rpmToAV end
    if engine.idleAVOverwrite ~= nil then engine.idleAVOverwrite = 0 end   -- V3.0: clear idle-up governor override on reset
  end
  setEngineDamageFlag(false)
  destroyLaunchSound()   -- drop the bark source on reset; the next launch builds a fresh one (a reset can cut it)
  destroyRollingSound()  -- and the rolling anti-lag braap source
  destroyPopPool()       -- drop the pop pool on reset too; rebuilds fresh on the next overrun
  destroyKnockAudio()    -- V3.0: reset = repaired engine, and a repaired engine stops rod-knocking (damage is zeroed below)
  restoreNativePopAudio()   -- reset: native afterfire audio back to the exhaust's own values (re-applied next frame if still connected)
  smokeFx.updateNativeFlameSuppression(0, false)
  rotCutting = false; rotGapTimer = 0; rotCutTimer = 0; rotFlameHold = 0   -- fresh rotational-idle state after a reset
  smokeFx.rotFlameTimer = 0
  stateDirty = true
end

local function init(jbeamData)
  jbeamData = jbeamData or {}
  local engineName = jbeamData.engineName or "mainEngine"
  engine = powertrain.getDevice(engineName)
  -- COMBUSTION ENGINE ONLY. intakeAirDensityCoef is the combustion-only field this controller tunes
  -- through; an electric/hydraulics motor (trailer legs, EV drive, crane) has setTempRevLimiter but no
  -- intakeAirDensityCoef, and wrapping it would crash the whole vehicle. Guard here too in case a car
  -- attaches the controller via jbeam (bypassing the autoload's detection).
  if not engine or not engine.setTempRevLimiter or type(engine.intakeAirDensityCoef) ~= "number" then
    active = false
    return
  end

  H.beginCalibrationEpoch()   -- every fresh controller is a new calibration session (new epoch, revision 0)
  carName = jbeamData.carName or "BeamNG Vehicle"
  -- REVIEW FIX 2026-07-29: the pre-fix (0.39-bug) name for this car, so the UI can fall back to a
  -- durable tune file written by a build that had the bug. Nil when it would equal carName.
  H.carNameFallback = type(jbeamData.carNameFallback) == "string" and jbeamData.carNameFallback ~= "" and jbeamData.carNameFallback or nil
  local revLimiterAV = engine.revLimiterAV or engine.maxAV or (7000 * rpmToAV)
  base = {
    engineName = engineName,
    revLimiterRPM = floor(revLimiterAV * avToRPM + 0.5),
    maxRPM = engine.maxRPM or floor((engine.maxAV or revLimiterAV) * avToRPM + 0.5),
    idleRPM = engine.idleRPM or floor((engine.idleAV or (800 * rpmToAV)) * avToRPM + 0.5),
    hasTurbo = hasTurbo(),
    instantAfterFireCoef = engine.instantAfterFireCoef or 0,
    sustainedAfterFireCoef = engine.sustainedAfterFireCoef or 0
  }
  H.stockMaxTorqueLimit = engine.maxTorqueLimit
  H.torqueLimitOwned = false
  H.torqueLimitRestoreValue = nil
  H.compBrakeOwned = false
  H.shiftFirmnessOwned = false
  H.converterKFactorOwned = false
  H.converterLockupOwned = false
  H.converterStallActive = false
  H.clutches = {}
  H.clutchPackageTier = 1
  -- RAISED rev limiter support (V2_3): setTempRevLimiter can only LOWER the limit, so raising means
  -- writing engine.revLimiterAV directly (applyLimiter does the write, releaseOutputs restores; all
  -- three vanilla limiter types read revLimiterAV live, so the write moves the actual cut).
  -- CEILING GOTCHA (found on the Covet): when a rev limiter exists, BeamNG OVERWRITES engine.maxRPM/
  -- maxAV to EQUAL the limiter rpm (combustionEngine.lua "make sure the reported max RPM is the one
  -- from the revlimiter") - so maxRPM is useless as a raise ceiling (it made every raise clamp back
  -- to stock). The real bounds the game exposes: engine.maxPhysicalAV = valve-float threshold (the
  -- game's own overRev damage starts above it) and engine.maxAvailableRPM = the torque curve's true
  -- end. Ceiling = lowest of those and stock+1500. Torque past the jbeam redline fades through the
  -- game's natural drop-off (torqueCurve[x] or 0 - safe), so a raised limit pulls progressively less
  -- up there. Damage keys off the engine's physical threshold below, never the fitted ECU's limiter.
  base.canRaiseLimiter = type(engine.revLimiterAV) == "number"
  base.stockRevLimiterAV = engine.revLimiterAV
  base.maxPhysicalRPM = (type(engine.maxPhysicalAV) == "number")
    and floor(engine.maxPhysicalAV * avToRPM + 0.5) or nil
  -- V3.0.9 VALVE FLOAT. The old ceiling was min(stock + 1500, physical, curve end) and it was a WALL:
  -- type a bigger number and it was silently swallowed. Two things were wrong with that. It modelled no
  -- mechanism (a real ECU accepts any limit you give it; the valvetrain is what says no), and the
  -- stock+1500 term was pure invention - on a Covet with an Ultra HD long block it pinned the ceiling
  -- at 8900 while the engine was physically good to ~9400.
  -- Now there are TWO numbers:
  --   base.floatRPM             where the valves start to float. Torque falls off from here.
  --   base.revLimitCeilingRPM   the highest limit you may ASK for. Now the TOP of the float band,
  --                             not the bottom - you can deliberately run into float and pay for it.
  -- The torque-curve end stays an absolute clamp because past it the game's own lookup returns 0
  -- (combustionEngine.lua: `torqueCurve[tableRPM] or 0`), so there is genuinely nothing up there.
  -- V3.0.9 STEP 3: where float STARTS is a property of the fitted hardware, and we say where the
  -- number came from. engine.jbeamData is the MERGED engine section, so its maxRPM already includes
  -- the long block's "$+maxRPM" - which is why fitting Stage 1 on a Covet DOHC moves the redline from
  -- 7500 to 8300 with no work from us. That ladder was always there; it was just never labelled.
  --   declared  - the engine author stated it outright via oepValveFloatRPM
  --   jbeam     - the engine's own redline, including fitted internals
  --   estimated - nothing to read, fall back to the game's maxPhysicalRPM
  local eSec = engine.jbeamData
  local declaredFloat = eSec and tonumber(eSec.oepValveFloatRPM) or nil
  local jbeamRedline  = eSec and tonumber(eSec.maxRPM) or nil
  local floatRPM
  if declaredFloat and declaredFloat > 0 then
    floatRPM, base.floatSource = declaredFloat, "declared"
  elseif jbeamRedline and jbeamRedline > 0 then
    floatRPM, base.floatSource = jbeamRedline, "jbeam"
  else
    floatRPM, base.floatSource = (base.maxPhysicalRPM or base.revLimiterRPM), "estimated"
  end
  -- STAGGERED, not stacked. Float used to start at maxPhysicalRPM - the SAME rpm as OE's rpmRisk and
  -- BeamNG's overRevDamage - so the very first hint of fade arrived together with the damage. Starting
  -- at the redline instead puts a warning band UNDER the damage: on a Stage 1 Covet DOHC, fade from
  -- 8300 and damage from 8715. You get 400 rpm of "this is wrong" before it costs you anything.
  -- Floored at the stock ECU limiter so a car can never float inside its own factory rev range.
  base.floatRPM = max(base.revLimiterRPM, floor(floatRPM + 0.5))
  -- ...but the fade band MUST stay under the damage threshold, and that outranks the floor above.
  -- Caught by simulating real configs: an author declaring oepValveFloatRPM 9200 on an engine whose
  -- maxPhysicalRPM is 7875, or a race ECU whose limiter (8200) already sits above it, both produced a
  -- float onset ABOVE the damage point - so the engine started hurting itself 300-1300 rpm before the
  -- first hint of fade, which is precisely the ordering this change exists to prevent. BeamNG's
  -- overRevDamage keys on maxPhysicalAV and we do not move it, so it is the harder authority: when the
  -- two disagree, the warning band gives way and sits just underneath it.
  if base.maxPhysicalRPM and base.floatRPM > base.maxPhysicalRPM - 150 then
    base.floatRPM = max(1000, floor(base.maxPhysicalRPM - 150))
  end
  -- Band width. 900 was a guess and a real log killed it: on a Covet DOHC the engine ran out of torque
  -- 243 rpm past onset, so severity peaked at 0.27, power never fell below 85%, and the whole upper
  -- half of the model was unreachable. Past float the engine stops pulling QUICKLY - that is what float
  -- does - so the ramp has to finish inside the few hundred rpm it can still climb. 400 puts that same
  -- 243 rpm at severity 0.61 and 66% power, which is felt rather than merely logged.
  base.floatBandRPM = 400   -- width of the progressive-float region above floatRPM
  -- The ceiling is DECOUPLED from the band. It used to be floatRPM + band, which was fine at 900 but
  -- would have quietly halved how high you can ask once the band narrowed to 400 - and being able to
  -- ask for more than is wise is the entire point of the rework. The only real bound left is the
  -- torque curve, past which the game returns 0 and there is genuinely nothing to have. 1500 rpm of
  -- rope past the damage threshold is plenty to hang yourself with; the 12k test already proved the
  -- engine self-limits long before a silly number matters.
  base.revLimitCeilingRPM = base.revLimiterRPM
  if base.canRaiseLimiter then
    local ceilRPM = (base.maxPhysicalRPM or base.floatRPM) + 1500
    local curveRPM = tonumber(engine.maxAvailableRPM) or 0
    if curveRPM > 0 then ceilRPM = min(ceilRPM, curveRPM) end
    base.revLimitCeilingRPM = max(base.revLimiterRPM, ceilRPM)
  end

  base.hasTurbocharger = (engine and engine.turbocharger and engine.turbocharger.isExisting) and true or false
  base.isSupercharger = (not base.hasTurbocharger) and engine and engine.supercharger and engine.supercharger.isExisting and true or false
  -- V2.9.x (community server-fairness report): nitrous is HARDWARE-GATED like every boost feature.
  -- The ECU can only control a N2O system the car actually has (vanilla nitrousOxideInjection
  -- powertrain device - same detection the stock controller uses). Without the part the Nitrous
  -- page is inert, so a server that bans N2O kits automatically bans app nitrous too.
  base.hasNitrous = (engine and engine.nitrousOxideInjection and engine.nitrousOxideInjection.isExisting) and true or false
  -- Phase 2: rate the installed kit from ITS real torque table (getAddedTorque) - peak added hp for
  -- display, and the equivalent shot% (added vs base engine power) that scales the knock/EGT hazard
  -- model: a 50 hp wet shot on a 500 hp build is mild, the same bottle on a 120 hp hatch is spicy.
  -- pcall-guarded; a kit without the API keeps the old default hazard scale (25).
  base.nitrousKitPowerHp, base.nitrousKitShotPct = 0, 25
  if base.hasNitrous then
    pcall(function()
      local kitPeakHp, engPeakHp = 0, 0
      local added = engine.nitrousOxideInjection.getAddedTorque and engine.nitrousOxideInjection.getAddedTorque() or nil
      if type(added) == "table" then
        for rpmK, tq in pairs(added) do
          local hp = (tonumber(tq) or 0) * (tonumber(rpmK) or 0) / 7127
          if hp > kitPeakHp then kitPeakHp = hp end
        end
      end
      if type(engine.torqueCurve) == "table" then
        for rpmK, tq in pairs(engine.torqueCurve) do
          local hp = (tonumber(tq) or 0) * (tonumber(rpmK) or 0) / 7127
          if hp > engPeakHp then engPeakHp = hp end
        end
      end
      if kitPeakHp > 0 then
        base.nitrousKitPowerHp = floor(kitPeakHp + 0.5)
        if engPeakHp > 20 then base.nitrousKitShotPct = clamp(kitPeakHp / engPeakHp * 100, 5, 60) end
      end
    end)
  end
  -- Forward-gear count so the Boost by Gear table can gray rows this gearbox can never reach.
  -- pcall-guarded: CVTs and exotic powertrains may not expose maxGearIndex; nil keeps all 8 rows live.
  base.forwardGears = nil
  -- V3.0.5: does this engine ACTUALLY have a compression brake, or just BeamNG's placeholder?
  -- combustionEngine.lua:1943 assigns every engine a default curve when its part declares none:
  --   {{"rpm","torque"},{0,0},{1000,500},{3000,1500}}   -- marked "todo remove defaults"
  -- Only six vanilla parts declare a real one (us_semi x3, md_series, wl40). Sampling the two
  -- placeholder knees identifies it. If BeamNG ever removes the defaults this still behaves: the
  -- curve is then empty, the samples read 0, and the feature stays correctly hidden.
  base.hasCompBrake = false
  pcall(function()
    local curve = engine and engine.compressionBrakeCurve
    if not curve then return end
    local at1000, at3000 = curve[1000] or 0, curve[3000] or 0
    if at1000 <= 0 and at3000 <= 0 then return end                      -- no curve at all
    if abs(at1000 - 500) < 1 and abs(at3000 - 1500) < 1 then return end -- the placeholder
    base.hasCompBrake = true
  end)

  -- V3.0.5: torque converter handle + its as-fitted kFactorCoef, so "stock" can always be restored
  -- and the stall solve has a real baseline. Absent on manual/DCT cars, which is the correct signal
  -- that stall tuning does not apply there.
  H.converter, base.stockKFactorCoef = nil, nil
  pcall(function()
    local tc = powertrain and powertrain.getDevice and powertrain.getDevice("torqueConverter")
    if tc and tc.kFactorCoef then
      H.converter = tc
      base.stockKFactorCoef = tc.kFactorCoef
      base.stockLockupTorque = tc.lockupClutchTorque
    end
  end)
  base.hasConverter = H.converter ~= nil

  -- Manual/sequential friction clutch discovery. BeamNG auto-sizes an unspecified lockTorque during
  -- powertrain validation, so capture that fitted value exactly and never recompute it from OE torque.
  -- An explicit fixed package may raise it later; disconnect/reset always restores this value.
  base.fittedClutchCapacityNm = nil
  pcall(function()
    local devices = powertrain and powertrain.getDevicesByType and powertrain.getDevicesByType("frictionClutch") or {}
    for _, clutchDevice in ipairs(devices or {}) do
      if clutchDevice and clutchDevice.parent == engine and type(clutchDevice.lockTorque) == "number" then
        local fitted = max(0, clutchDevice.lockTorque)
        H.clutches[#H.clutches + 1] = { device = clutchDevice, fittedLockTorque = fitted, owned = false }
        base.fittedClutchCapacityNm = base.fittedClutchCapacityNm
          and min(base.fittedClutchCapacityNm, fitted) or fitted
      end
    end
  end)
  base.hasFrictionClutch = #H.clutches > 0

  H.gearbox = nil
  local okGearbox, forwardGears = pcall(function()
    local gearboxDevice = powertrain and powertrain.getDevice and powertrain.getDevice("gearbox")
    H.gearbox = gearboxDevice   -- V3.0.4: kept for shift firmness (H.* rather than a new chunk local)
    return gearboxDevice and tonumber(gearboxDevice.maxGearIndex) or nil
  end)
  base.stockGearChangeTime = nil
  if H.gearbox and type(H.gearbox.minimumGearChangeTime) == "number" then
    base.stockGearChangeTime = H.gearbox.minimumGearChangeTime
  end
  if okGearbox and forwardGears and forwardGears >= 1 and forwardGears <= 10 then base.forwardGears = floor(forwardGears) end
  turbo = base.hasTurbocharger and engine.turbocharger or nil
  local initEnvPa = tonumber(powertrain and powertrain.currentEnvPressure) or 101325
  if initEnvPa ~= initEnvPa or initEnvPa < 1000 or initEnvPa > 200000 then initEnvPa = 101325 end
  local initBaroKpa = initEnvPa * 0.001
  model.baroKpa = initBaroKpa
  base.turboMaxKpa = 100
  base.turboMaxRead = false
  if base.hasTurbo then
    -- combined channels so SUPERCHARGERS read their real max at init too (superchargerBoostMax is
    -- published in PSI on the normal path - the SC ceiling table then seeds at natural boost exactly).
    -- Guard the bypass-less blower sentinel (supercharger.lua can publish a bogus huge "no limit"
    -- value): anything over 80 psi is not a real reading -> treat as unread, learnBoost takes over.
    local maxPSI = max(ev("boostMax", 0) or 0, ev("boost", 0) or 0)
    if maxPSI > 80 then maxPSI = 0 end
    base.turboMaxKpa = 100 + max(0, maxPSI) * PSI_TO_KPA
    base.turboMaxRead = maxPSI > 1.5
  end
  -- Stable feedforward reference for the boost controller. learnBoost() grows base.turboMaxKpa as the car
  -- makes boost, so the boost TARGET/AXIS cover the real range. But the boost CONTROLLER's feedforward must
  -- NOT drift with it, or the base-duty<->boost calibration shifts mid-tune (tester report: needed 67% base
  -- duty to hit 22psi on a big turbo). Freeze the controller reference at the init read for cars that report
  -- their boost; only let it follow the learned peak for cars that DON'T report it at init (custom/ALS).
  base.turboRefKpa = base.turboMaxKpa

  -- REAL WASTEGATE SPRING READ (V2.6 realism pass). The game's Tuning-tab "boost pressure" slider
  -- writes jbeam wastegateStart/wastegateLimit [psi] - that IS the spring, the hardware. The game's
  -- own gate then regulates boost at start + range/2 (+ our setWastegateOffset, 1:1 psi - verified
  -- in turbocharger.lua:267/549), so:
  --   wgSpringStartPSI  = where the gate cracks open = the honest FLOOR a pneumatic gate can run
  --   wgSpringTargetPSI = the stock regulated boost (what the car makes with the ECU hands-off)
  -- Values may be per-gear tables; take the highest entry (mirrors the game's maxWastegateStart) so
  -- the floor is never optimistic. Guarded: exotic modded jbeams fall back to the old estimates.
  base.wgSpringStartPSI, base.wgSpringTargetPSI = nil, nil
  if base.hasTurbocharger then
    local okT, tdata = pcall(function()
      return (engine.jbeamData and engine.jbeamData.turbocharger and v.data[engine.jbeamData.turbocharger])
        or v.data.turbocharger
    end)
    if okT and type(tdata) == "table" then
      local function maxNum(x)
        if type(x) == "number" then return x end
        if type(x) == "table" then
          local m
          for _, n in pairs(x) do if type(n) == "number" and (not m or n > m) then m = n end end
          return m
        end
        return nil
      end
      local wgs = maxNum(tdata.wastegateStart)
      local wgl = maxNum(tdata.wastegateLimit)
      if wgs and wgs > 0 then
        base.wgSpringStartPSI = wgs
        -- no/invalid limit in jbeam -> the game defaults range to 0.01 psi; mirror that
        base.wgSpringTargetPSI = wgs + (((wgl and wgl > wgs) and (wgl - wgs) or 0.01) * 0.5)
      end
    end
  end

  -- diesel detection (#4): the engine device declares the fuel it burns. Vanilla BeamNG diesels set
  -- requiredEnergyType "diesel"; we also check requiredEnergyStorageType as a fallback. The UI's
  -- name-based setDiesel() (e.g. "Cummins", "Power Stroke", "TDI") covers modded diesels that omit it.
  local et = tostring(engine.requiredEnergyType or engine.requiredEnergyStorageType or ""):lower()
  base.isDiesel = et:find("diesel") ~= nil
  dieselOverride = nil   -- cleared on (re)init; the UI re-pushes its name-based detection on firstLink/respawn

  -- block strength: how much mechanical abuse this engine block survives before it grenades.
  -- Auto-detected from BeamNG's own per-part durability, so swapping a block part (e.g. a
  -- forged/built block from the parts menu) re-reads on respawn. No manual override: a stronger
  -- block has to earn the headroom, you can't fake it on a stock block.
  do
    local rating = tonumber(engine.maxTorqueRating) or -1      -- jbeam over-torque rating, -1 = unset
    local sod = tonumber(engine.maxOverTorqueDamage) or 1000   -- overstress before catastrophic, default 1000
    local peakPwr = tonumber(engine.maxPower) or 0             -- hp
    -- ADDER-INDEPENDENT base (NA) peak torque from the combustion torque curve. engine.maxTorque
    -- includes the turbo AND nitrous curves, so it balloons when you fit boost/NOS and would make
    -- a strong block read as weak. torqueCurve is the bare combustion curve (boost is applied later
    -- as intakeAirDensityCoef), so it is a stable reference for "how strong is the block".
    local baseTq = 0
    if type(engine.torqueCurve) == "table" then
      for _, v in pairs(engine.torqueCurve) do if type(v) == "number" and v > baseTq then baseTq = v end end
    end
    base.maxTorqueRating = rating
    base.peakTorqueNm = tonumber(engine.maxTorque) or 0   -- WITH adders, for the "rated torque" display
    base.baseTorqueNm = baseTq
    base.peakPowerHp = peakPwr
    if rating > 0 and baseTq > 1 then
      -- best signal: how far the block is rated ABOVE its own NA output. A built block rated well
      -- clear of its base torque reads tough; a stock block rated near it is fragile. Adding a
      -- turbo or NOS no longer changes this (baseTq is the NA curve).
      -- UNCAPPED (Orlando 2026-06-29): read whatever the jbeam advertises so the crazy 1500hp+
      -- billet/sleeved blocks people run read their TRUE strength, not a trimmed 3.0x. Floor 0.7
      -- only, so strength stays positive and the damage-rate division below never blows up.
      base.blockStrength = max((rating / baseTq) / 1.2, 0.7)
      base.blockStrengthSource = "rated"
    elseif sod ~= 1000 and sod > 0 then
      -- no usable rating but the modder set a custom overstress capacity: 1000 = stock 1.0x.
      -- also uncapped (floor only) - read the full jbeam overstress value.
      base.blockStrength = max(sod / 1000, 0.7)
      base.blockStrengthSource = "rated"
    elseif peakPwr > 1 then
      -- nothing advertised (no rating, no strength keyword in the name): infer from how much power
      -- the build makes. A genuine 800-1000hp swap is almost certainly running built internals, so
      -- give it believable headroom instead of treating it as a stock block that grenades in seconds.
      -- ~200hp = 1.0x (stock), 500hp ~ 1.5x, 800hp ~ 2.0x, 1000hp+ caps at 2.2x. Never below 1.0.
      base.blockStrength = clamp(1.0 + max(0, peakPwr - 200) / 600, 1.0, 2.2)
      base.blockStrengthSource = "estimated"
    else
      base.blockStrength = 1.0
      base.blockStrengthSource = "default"
    end
  end

  -- ENGINE IDENTITY V2 (V3.0.2 bug fix): the old fingerprint used assembled performance values
  -- (aspiration + idle + redline + torque-curve peak). Exhaust/intake/ECU parts legitimately alter
  -- those values, and any part edit rebuilds the vehicle, so the same block could be misclassified as
  -- a different engine and its saved Switchable MAP package rejected. The JBeam powertrain row's
  -- partPath identifies the part that DECLARED the engine device, which is the semantic boundary we
  -- need: engine-parent swaps change it; wheels, exhausts and child bolt-ons do not.
  --
  -- Keep the exact V2.6 value beside it for one-way migration of already-saved tunes. If a very old
  -- or unusual custom powertrain exposes no origin metadata, retaining the legacy identity is safer
  -- than weakening the real gas/diesel engine-swap guard.
  base.engineFpLegacy = (base.isDiesel and "D" or "G")
    .. (base.hasTurbocharger and "T" or (base.isSupercharger and "S" or "N"))
    .. "-" .. floor((base.idleRPM or 0) + 0.5)
    .. "-" .. floor((base.revLimiterRPM or 0) + 0.5)
    .. "-" .. floor((base.baseTorqueNm or 0) + 0.5)
  base.enginePartIdentity, base.engineIdentitySource = H.engineDevicePartIdentity(engineName, engine)
  local enginePartToken = H.engineIdentityToken(base.enginePartIdentity)
  local engineNameToken = H.engineIdentityToken(engineName) or "engine"
  base.engineFp = enginePartToken
    and ("E2:" .. (base.isDiesel and "D" or "G") .. ":" .. engineNameToken .. ":" .. enginePartToken)
    or base.engineFpLegacy

  buildAxes()
  buildMaps()
  settings = defaultSettings()
  H.resetMapBanksFromCurrent()   -- fresh controller: current factory calibration is MAP 1; MAP 2-4 empty
  smokeFx.reset()
  model.ectC = ev("watertemp", 85) or 85
  model.oilC = ev("oiltemp", 90) or 90

  -- the engine rewrites intakeAirDensityCoef every frame after controllers run,
  -- so wrap its update and apply our tune multiplier on top of its own value
  if not engineWrapped and engine.updateGFX then
    local orig = engine.updateGFX
    engine.updateGFX = function(device, dt)
      orig(device, dt)
      -- nil-guard: never multiply a nil (a non-combustion device that somehow slipped through) - that
      -- arithmetic-on-nil is what was crashing/"corrupting" trailers & EVs. No coef on no engine = no-op.
      if device.intakeAirDensityCoef then
        device.intakeAirDensityCoef = device.intakeAirDensityCoef * model.coef
      end
    end
    engine.oeProTuneWrapped = true
    engineWrapped = true
  end

  active = true
  controlling = false   -- start disconnected: monitor only until the user hits Connect
  wasControlling = false
  justInited = true     -- tell the UI to re-apply the saved tune to this fresh ECU
  lastAppliedLimiterRPM = -1
  stateDirty = true

  -- one line per controller birth in beamng.log - cheap, and it turns "which car/when" support
  -- questions into a grep instead of a guessing game
  if syslog then
    syslog("I", "oeProTune", "controller init [" .. instTag .. "] build V3.0.7 (selectable calibration grid, ITB manifold model, diesel truth) veh "
      .. tostring(obj and obj:getID() or "?") .. " - " .. tostring(carName)
      .. " engineFp=" .. tostring(base.engineFp) .. " legacy=" .. tostring(base.engineFpLegacy)
      .. " source=" .. tostring(base.engineIdentitySource))
  end
end

-- UI acknowledges it has seen the fresh-init signal and re-applied the tune
local function ackInit() justInited = false end

-- Rolling anti-lag button (held). Called by the bound OE ProTune input action and by the typed
-- remote dispatcher. A release is always honored; a press is accepted only while the feature is armed.
function R.setRollingHold(value)
  if not active then return false, "inactive" end
  if value == nil then return R.releaseMomentaryInputs() end   -- legacy onUp/no-arg behavior
  local held = H.booleanValue(value)
  if held == nil then return false, "invalid_held" end
  if not held then return R.releaseMomentaryInputs() end
  if settings.rollingEnabled ~= true then return false, "rolling_disabled" end
  rollingHold = true
  return true
end

R.commands = {
  setSetting = function(args) return setSetting(args.key, args.value) end,
  setMapCell = function(args) return setMapCell(args.map, args.row, args.col, args.value) end,
  setMapRow = function(args) return R.setMapRowValues(args.map, args.row, args.values) end,
  resetMaps = function() return resetMaps() end,
  resetMap = function(args) return resetMap(args.map) end,
  factoryReset = function() return factoryReset() end,
  applyPreset = function(args) return applyPreset(args.id) end,
  setAxis = function(args) return R.setAxisValues(args.which, args.values) end,
  resetAxes = function() return resetAxes() end,
  clearFaults = function() return clearFaults() end,
  resetDamage = function() return resetDamage() end,
  armDyno = function() return armDyno() end,
  setDynoAuto = function(args)
    if args.enabled == nil or args.gear == nil or args.startRpm == nil then return false, "missing_args" end
    return setDynoAuto(args.enabled, args.gear, args.startRpm)
  end,
  cancelDyno = function() return cancelDyno() end,
  setControl = function(args)
    if args.enabled == nil then return false, "missing_enabled" end
    return setControl(args.enabled)
  end,
  setRollingHold = function(args)
    if args.held == nil then return false, "missing_held" end
    return R.setRollingHold(args.held)
  end,
  selectMapBank = function(args)
    if args.index == nil then return false, "missing_index" end
    return H.selectMapBank(args.index)
  end,
  cycleMapBank = function(args)
    if args.direction == nil then return false, "missing_direction" end
    return H.cycleMapBank(args.direction)
  end
}

function R.queueCommandResult(id, method, ok, err)
  if not (obj and obj.queueGameEngineLua) then return false end
  local vehId = (obj.getID and obj:getID()) or 0
  local result = {
    id = id,
    ok = ok == true,
    error = ok and "" or tostring(err or "command_rejected"),
    method = method,
    phase = ok and "applied" or "rejected",
    calibrationRevision = H.revision,
    calibrationEpoch = H.epoch
  }
  local encodedOk, encoded = pcall(serialize, result)
  if not encodedOk or type(encoded) ~= "string" then return false end
  return pcall(function()
    obj:queueGameEngineLua("if oeProTuneRemote and oeProTuneRemote.commandResult then oeProTuneRemote.commandResult(" .. vehId .. "," .. encoded .. ") end")
  end)
end

-- Typed, explicit remote command boundary. This intentionally excludes hardware authority,
-- ackInit/requestState, markStored, releaseMomentaryInputs and infrastructure controls.
function R.command(id, method, args)
  local safeId = id
  local idValid = true
  if type(id) == "number" then
    if H.finiteNumber(id) == nil then safeId = ""; idValid = false end
  elseif type(id) == "string" then
    if #id == 0 or #id > 128 then safeId = id:sub(1, 128); idValid = false end
  else
    safeId = tostring(id or ""):sub(1, 128)
    idValid = false
  end
  if not idValid then
    R.queueCommandResult(safeId, type(method) == "string" and method:sub(1, 64) or "", false, "invalid_id")
    return false, "invalid_id"
  end

  local handler
  if type(method) == "string" and #method > 0 and #method <= 64 then handler = R.commands[method] end
  if not handler then
    R.queueCommandResult(safeId, type(method) == "string" and method:sub(1, 64) or "", false, "method_not_allowed")
    return false, "method_not_allowed"
  end
  if type(args) ~= "table" then
    R.queueCommandResult(safeId, method, false, "invalid_args")
    return false, "invalid_args"
  end

  local previousSource = H.mutationSource
  H.mutationSource = "remote"
  local callOk, accepted, err = pcall(handler, args)
  H.mutationSource = previousSource
  local ok = callOk and accepted == true
  if not callOk then err = "internal_error" elseif not ok and err == nil then err = "command_rejected" end
  R.queueCommandResult(safeId, method, ok, err)
  return ok, err
end

M.init = init
M.reset = reset
M.updateGFX = updateGFX
M.requestState = requestState
-- The game UI routes every mutation to the exact vehicle whose ECU it is painting. The object id
-- prevents cross-car delivery; this identity check also blocks a deleted/reused id or an engine
-- swap from accepting stale calibration before the UI has processed the new State message.
M.matchesIdentity = function(expectedCar, expectedEngineFp, expectedTurbo)
  return tostring(carName or "") == tostring(expectedCar or "")
    and tostring((base and base.engineFp) or "") == tostring(expectedEngineFp or "")
    and ((base and base.hasTurbo) and true or false) == (expectedTurbo and true or false)
end
M.ackInit = ackInit
M.setSetting = setSetting
M.setMapCell = setMapCell
M.setMapRow = setMapRow
M.resetMaps = resetMaps
M.resetMap = resetMap
M.factoryReset = factoryReset
M.applyPreset = applyPreset
M.setAxis = setAxis
M.resetAxes = resetAxes
-- V3.0.7. Deliberately NOT added to R.commands: a grid change rewrites every table, every M1-M4
-- bank body and the restore snapshot at once, and a browser Studio client holding a stale page
-- would be mid-edit against the old shape. In-game only until the remote clients handle a live
-- reshape (they already re-render on the axis broadcast, but an in-flight setMapCell would land on
-- the wrong cell). Tracked as deferred, not forgotten.
M.setGridResolution = function(rows, cols) return R.setGridResolution(rows, cols) end
M.clearFaults = clearFaults
M.resetDamage = resetDamage
M.armDyno = armDyno
M.setDynoAuto = setDynoAuto
M.cancelDyno = cancelDyno
M.setControl = setControl
M.setBlockStrength = function(v) blockOverride = (v ~= nil) and clamp(tonumber(v) or 1, 0.5, 3.5) or nil end
M.setHardware = function(t)
  if type(t) ~= "table" then return end
  local nextHw = {
    turbo = tonumber(t.turbo) or 0, cam = tonumber(t.cam) or 0,
    itb = tonumber(t.itb) or 0, inj = tonumber(t.inj) or 0,
    injAbs = tonumber(t.injAbs) or 0,   -- V3.0: absolute fitted cc for pump sizing (0 from older cached UIs that don't send it)
    itbAbs = tonumber(t.itbAbs) or 0,   -- V3.0.7: absolute fitted ITBs, ungated. `itb` above is a
                                        -- baseline DELTA, so a car that spawns with ITBs reports 0
                                        -- there forever - and whether the engine has a plenum is a
                                        -- property of the engine, not of what you bolted on later.
                                        -- Drives the manifold-pressure model. 0 from older cached UIs.
    displ = tonumber(t.displ) or 0, intake = tonumber(t.intake) or 0
  }
  -- injAbs and itbAbs are deliberately EXCLUDED from `changed`. injAbs is informational (pump
  -- sizing). itbAbs DOES drive physics, but it can only differ from `itb` for hardware the car
  -- SPAWNED with - and that cannot change without a respawn, which re-inits the controller anyway.
  -- Fitting ITBs mid-session moves the gated `itb`, which is in the list, so the relearn still fires
  -- through the right channel. Including itbAbs here would instead wipe learned trims on the first
  -- connect push of every ITB car, which the echo discipline says must survive.
  local changed = nextHw.turbo ~= hw.turbo or nextHw.cam ~= hw.cam or nextHw.itb ~= hw.itb
    or nextHw.inj ~= hw.inj or nextHw.displ ~= hw.displ or nextHw.intake ~= hw.intake
  hw = nextHw
  if changed then resetFuelLearningState("Hardware changed - relearning") end
end
M.setEngineSpec = function(cyl, displL)
  local nextCyl, nextDispl = floor(tonumber(cyl) or 0), tonumber(displL) or 0
  if nextCyl ~= engSpec.cyl or abs(nextDispl - engSpec.displL) > 0.0001 then
    engSpec.cyl, engSpec.displL = nextCyl, nextDispl
    resetFuelLearningState("Engine specification changed - relearning")
  end
end
-- V8 / LS / muscle flag from the UI: swaps the 2-step + rolling anti-lag sound to the deep V8 sample.
M.setEngineClass = function(v8)
  local newV8 = v8 and true or false
  if newV8 ~= engineIsV8 then
    engineIsV8 = newV8
    destroyLaunchSound()    -- rebuild the bark sources with the correct sample on the next launch/rolling
    destroyRollingSound()
  end
  engineClassKnown = true   -- the UI has told us; stop it re-detecting (confirm via the next state)
  stateDirty = true
end
-- UI name-based diesel detection (modded diesels that don't tag requiredEnergyType). Only forces
-- diesel ON; the controller's own requiredEnergyType detection still stands if this is 0.
M.setDiesel = function(on)
  local nextOverride = (tonumber(on) == 1) or nil
  if nextOverride ~= dieselOverride then
    dieselOverride = nextOverride
    resetFuelLearningState("Fuel equation family changed - relearning")
  end
end
-- compatibility no-op: the effects pack no longer gates on an availability round-trip (fxStyle
-- alone picks the particle set; modScript.lua guarantees the datablocks at mod mount). Older
-- CACHED copies of the UI still call this on connect - keep the function so they don't error.
M.setFxAvailable = function() end
-- Remote Dash: the GE server (oeProTuneRemote.lua) pokes this on/off. Deliberately NOT a
-- tune setting - it's per-machine infrastructure, so it never rides autosaves or share codes.
M.setRemoteDash = function(on)
  remoteDash = (on == true or on == 1)
  if not remoteDash then remoteTimer = 0 end
end
-- rolling anti-lag button (held). Called by the bound OE ProTune input action (onDown=1 / onUp=0)
-- and by the typed remote dispatcher; only latches while the ECU is active and the feature is
-- enabled, so a stray bind can't do anything (hardened body lives on R).
M.setRollingHold = R.setRollingHold
M.releaseMomentaryInputs = R.releaseMomentaryInputs
-- Trusted in-game switchable-map management. Saved banks are immutable to player tuning; the live
-- working tune changes only through normal tuning commands or a safe bank commit.
M.configureMapBank = H.configureMapBank
M.configureMapBankPackage = function(package, markDirty, token)
  local safeToken, tokenErr = H.validMapBankMutationToken(token)
  if safeToken == false then return false, tokenErr end
  local requestedActive = type(package) == "table" and H.mapBankIndex(package.active) or nil
  local ok, err = H.configureMapBankPackage(package, markDirty)
  -- Ordinary tune loads keep the legacy direct return. Safety-critical temporary-lesson restores
  -- provide a token so the UI can retain its recovery journal until State confirms this atomic
  -- four-slot package was accepted by the controller.
  if safeToken == nil then return ok, err end
  return H.recordMapBankMutation(safeToken, ok, err, nil, "package", requestedActive, nil)
end
M.saveCurrentToMapBank = function(index, name, markDirty, expectedActive, expectedSequence, expectedEpoch, token)
  local safeToken, tokenErr = H.validMapBankMutationToken(token)
  if safeToken == false then return false, tokenErr end
  local ok, err = H.saveCurrentToMapBank(index, name, markDirty, expectedActive, expectedSequence, expectedEpoch)
  local bank = ok and H.mapBanks[H.mapBankIndex(index)] or nil
  local savedData = bank and {
    maps = H.copyMapSet(bank.maps), settings = H.copySwitchableSettings(bank.settings),
    boostTableFactory = bank.boostTableFactory == true
  } or nil
  return H.recordMapBankMutation(safeToken, ok, err, savedData, "save", H.mapBankIndex(index), tostring(name or ""))
end
M.renameMapBank = function(index, name, markDirty, expectedSequence, expectedEpoch, token)
  local safeToken, tokenErr = H.validMapBankMutationToken(token)
  if safeToken == false then return false, tokenErr end
  local ok, err = H.renameMapBank(index, name, markDirty, expectedSequence, expectedEpoch)
  local bank = ok and H.mapBanks[H.mapBankIndex(index)] or nil
  local savedData = bank and {
    maps = H.copyMapSet(bank.maps), settings = H.copySwitchableSettings(bank.settings),
    boostTableFactory = bank.boostTableFactory == true
  } or nil
  return H.recordMapBankMutation(safeToken, ok, err, savedData, "rename", H.mapBankIndex(index), tostring(name or ""))
end
M.restoreMapBankSession = H.restoreMapBankSession
M.repairMapBankFactoryClaims = H.repairMapBankFactoryClaims
M.clearMapBank = function(index, markDirty, expectedActive, expectedSequence, expectedEpoch, token)
  local safeToken, tokenErr = H.validMapBankMutationToken(token)
  if safeToken == false then return false, tokenErr end
  local ok, err = H.clearMapBank(index, markDirty, expectedActive, expectedSequence, expectedEpoch)
  return H.recordMapBankMutation(safeToken, ok, err, nil, "clear", H.mapBankIndex(index), nil)
end
M.selectMapBank = H.selectMapBank
M.cycleMapBank = H.cycleMapBank
-- Keybind entry points. The bare aliases above stay for compatibility, but wheel/keyboard MAP
-- switching must never fail (or succeed) silently: the tuner app is often not even on screen while
-- driving, so every outcome surfaces as a game message (same channel as the engine-destroyed alert).
function H.mapBankInputMessage(txt, seconds)
  guihooks.message({ txt = "OE ProTune: " .. txt }, seconds or 4, "vehicle.oeprotune.mapswitch")
end
function H.mapBankInputResult(ok, err, index, hadPending)
  if ok then
    if H.pendingMapBank then
      H.pendingMapBankFromInput = true
      -- Only announce the arming when something actually blocks the commit; an unblocked switch
      -- lands within a frame and gets the ACTIVE message from commitMapBank instead.
      if H.mapBankSwitchBlocked() then
        local bank = H.mapBanks and H.mapBanks[H.pendingMapBank]
        H.mapBankInputMessage("M" .. H.pendingMapBank .. " (" .. (bank and bank.name or ("MAP " .. H.pendingMapBank)) .. ") queued - "
          .. (H.mapBankBlockReason or "lift throttle to apply safely"))
      end
    elseif hadPending then
      H.mapBankInputMessage("Pending MAP switch cancelled")
    elseif index and H.activeMapBank == index then
      H.mapBankInputMessage("M" .. index .. " is already the active MAP")
    end
    return
  end
  if err == "working_map_unsaved" then
    H.mapBankInputMessage("MAP switch blocked - the working tune has unsaved edits. Save or Undo them on the Tune Files page.", 5)
  elseif err == "bank_not_configured" then
    H.mapBankInputMessage(index and ("M" .. tostring(index) .. " is empty - save a tune to it first") or "No other saved MAP to switch to")
  elseif err ~= "inactive" then
    H.mapBankInputMessage("MAP switch rejected (" .. tostring(err) .. ")")
  end
end
M.selectMapBankInput = function(index)
  local hadPending = H.pendingMapBank ~= nil
  local ok, err = H.selectMapBank(index)
  H.mapBankInputResult(ok, err, tonumber(index), hadPending)
end
M.cycleMapBankInput = function(direction)
  local hadPending = H.pendingMapBank ~= nil
  local ok, err = H.cycleMapBank(direction)
  H.mapBankInputResult(ok, err, nil, hadPending)
end
M.discardWorkingMapBank = H.discardWorkingMapBank
M.setDefaultMapBank = function(index, markDirty, expectedSequence, expectedEpoch, token)
  local safeToken, tokenErr = H.validMapBankMutationToken(token)
  if safeToken == false then return false, tokenErr end
  local ok, err = H.setDefaultMapBank(index, markDirty, expectedSequence, expectedEpoch)
  return H.recordMapBankMutation(safeToken, ok, err, nil, "default", H.mapBankIndex(index), nil)
end
M.ackMapBankMutation = function(token)
  local safeToken, tokenErr = H.validMapBankMutationToken(token)
  if safeToken == false or safeToken == nil then return false, tokenErr or "invalid_mutation_token" end
  if not H.mapBankMutationResult or H.mapBankMutationResult.token ~= safeToken then return false, "unknown_mutation_token" end
  H.mapBankMutationResult = nil
  stateDirty = true
  return true
end
-- typed remote command boundary (browser -> GE relay -> here); everything else on M stays in-game-only
M.remoteCommand = R.command
-- trusted in-game persistence: the game UI advances storedRevision after a successful save
M.markStored = H.markStored

return M
